openvibers/openvibe.sdk Commits

openvibe-sdk 0.163.0: agents/rules, agents/router, agents/metrics (#248)

OP OpenVibers committed 13 h ago · a04d8b2743 · 20 files, +488 −11

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 23f535e..4d05b06 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,28 @@
# Changelog
++## 0.163.0 — 2026-11-12
++
++From reading 24 open agent projects (one-system-v2.md). Three new modules, all pure functions:
++
++- **`openvibe-sdk/agents/rules`:** permissions as data, from Gemini CLI and OpenCode (agents.permission-rule@1).
++ - `decide()` lets the last matching rule win; with no rule, the run's level decides.
++ - Unattended work never waits on an ask. It settles to allow at the levels approve-for-me and full, and to deny below
++ them.
++ - The floor always asks; with nobody there, it is denied.
++ - `fromBlueprint()` compiles a blueprint to rules: only the tools it names, never `.git/hooks` or its own config,
++ and its sandbox and network.
++- **`openvibe-sdk/agents/router`:** pays for the strong model only when it is likely to matter, in RouteLLM's shape
++ over OpenVibe Decisions.
++ - It asks for a calibrated probability that the small model is not enough, and routes against a threshold
++ calibrated to a budget (the share of steps that should go strong).
++ - It draws the quality-cost points the scoreboards show.
++ - The strategy chain is the person's pick, then the blueprint's pin, then the router. When Decisions is down, the
++ cheaper model runs.
++- **`openvibe-sdk/agents/metrics`:** what a run measured (agents.run@1 1.4.0).
++ - The charged cost comes first; an estimate counts only where nothing was charged, and unknown is null, never 0.
++ - It records tokens with cache reads and writes, steps, latency, a named exit and evidence.
++ - Also `passAtK` (unbiased), `passHatK` and `flakeRate`.
++
## 0.162.0 — 2026-11-12
- **New `openvibe-sdk/agents/blueprint`: make your own agent** (agent-blueprints.md; Contracts 0.419.0's
ddiff --git a/README.md b/README.md
iindex 128bb49..0fa522b 100644
---- a/README.md
++++ b/README.md
@@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.163.0
```
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.162.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.163.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.163.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -223,6 +223,9 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/agents/loop` | both | The light agent loop (a port of pi's, MIT): `runLoop({ model, tools, system, messages, prompts, budget, beforeToolCall, afterToolCall, transformContext, steering, followUp, finishTurn, onEvent, signal })` resolves `{ messages, stop, usage, cost, turns }` (stop: done, terminated, budget, turns, aborted, error). Tools in parallel (results in the model's order) or in sequence; an unknown tool, refused input, a throw or a timeout comes back to the model as an error; a call cut off by the output limit never runs; repeats get a reminder; idempotent tools answer from a memo; long outputs are clipped. `createStepSink({ append, update, delta, startSeq, startTurn })` turns its events into `agents.step@1` steps |
| `openvibe-sdk/agents/dispatch` | both | The Dispatcher, as pure functions over a person's choices: `ladder(choices, { need, pin, avoid, budgetLeft })` (their own machine, a signed-in plan, their key, the free allowance, then credits, cheapest first; what cannot do the step is listed in `.skipped` with why; a person's pick wins when it can), `pick`, `escalate(current, evidence, choices)` (only on a failed test, schema, tool or grader, or the model's own "not sure"), `harnessFor(task, harnesses, { plans })`, `placeFor(run, { machines, cloud })`, `estimate(choice, tokens)`, `slice(parentBudget, share)` |
| `openvibe-sdk/agents/blueprint` | both | Make your own agent (agents.blueprint@1, ten parts): `validate(doc)` (the shape and the rules a schema cannot say: no secret in it, every `{{input}}` declared, level full has a budget, machine and robot tools have somewhere to run, a served tool has its shapes, a team never contains itself), `withDefaults` (talks only, asks first, keeps nothing), `asks(doc, { describe })` (the install screen in words, with the keys `agents.install@1` records), `wider(old, new)` (what a new version asks for that the old did not), `unagreed(doc, agreed)`, `fill(doc, inputs)`, `compile(doc, { install, inputs, text, tryIt })` (run-start, the loop's system and tools, the Dispatcher's need, places, memory, team; Try it asks before every tool and spends at most 5¢), `estimate(doc, choices)` (the price of a run and where it is free), `ref` / `parseRef` |
++| `openvibe-sdk/agents/rules` | both | Permissions as data (agents.permission-rule@1): `decide(rules, call, { attended, level })` (the last matching rule wins; with none, the level; unattended work never waits; the floor always asks, and unattended it is denied), `fromBlueprint(doc)` (only the tools it names, never `.git/hooks` or its own config, its sandbox and network), `matches` |
++| `openvibe-sdk/agents/router` | both | Pay for the strong model only when it matters (RouteLLM's shape over OpenVibe Decisions): `question(step)`, `probability(answer)`, `route({ p, threshold, small, strong })`, `calibrate(history, { strongShare })` (the threshold that sends that share strong), `outcomes(history)` (the quality-cost points), `chain(step, { pick, pin, ask })` |
++| `openvibe-sdk/agents/metrics` | both | What a run measured (agents.run@1 1.4.0 metrics): `createRecorder()` (charged cost first, an estimate apart, unknown is null; tokens with cache; steps; latency; a named exit; evidence), `passAtK`, `passHatK`, `flakeRate` |
| `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) |
| `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) |
ddiff --git a/STATUS.json b/STATUS.json
iindex 704efef..847ec1d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.162.0",
++ "version": "0.163.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 00d603e..cef7dab 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.162.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.163.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.162.0';
++const SDK_VERSION = '0.163.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex cc59513..b13e831 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -707,6 +707,38 @@
"createStepSink": "function",
"runLoop": "function"
},
++ "openvibe-sdk/agents/metrics": {
++ "EVIDENCE": "array",
++ "EXITS": "array",
++ "createRecorder": "function",
++ "flakeRate": "function",
++ "passAtK": "function",
++ "passHatK": "function"
++ },
++ "openvibe-sdk/agents/router": {
++ "calibrate": "function",
++ "chain": "function",
++ "outcomes": "function",
++ "probability": "function",
++ "question": "function",
++ "route": "function"
++ },
++ "openvibe-sdk/agents/rules": {
++ "EFFECTS": "array",
++ "LEVELS": "array",
++ "MACHINE_KIND": {
++ "object": [
++ "browser",
++ "desk",
++ "files",
++ "screen",
++ "shell"
++ ]
++ },
++ "decide": "function",
++ "fromBlueprint": "function",
++ "matches": "function"
++ },
"openvibe-sdk/ai": {
"CLASSES": "array",
"OPS": "array",
ddiff --git a/esm/agents-metrics.mjs b/esm/agents-metrics.mjs
nnew file mode 100644
iindex 0000000..bc0dc6b
---- /dev/null
++++ b/esm/agents-metrics.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/agents/metrics.js. Do not edit.
++import cjs from '../src/agents/metrics.js';
++
++export const {
++ createRecorder, passAtK, passHatK, flakeRate, EXITS, EVIDENCE,
++} = cjs;
++export default cjs;
ddiff --git a/esm/agents-router.mjs b/esm/agents-router.mjs
nnew file mode 100644
iindex 0000000..5b695ca
---- /dev/null
++++ b/esm/agents-router.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/agents/router.js. Do not edit.
++import cjs from '../src/agents/router.js';
++
++export const {
++ question, probability, route, calibrate, outcomes, chain,
++} = cjs;
++export default cjs;
ddiff --git a/esm/agents-rules.mjs b/esm/agents-rules.mjs
nnew file mode 100644
iindex 0000000..3e698b6
---- /dev/null
++++ b/esm/agents-rules.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/agents/rules.js. Do not edit.
++import cjs from '../src/agents/rules.js';
++
++export const {
++ decide, fromBlueprint, matches, LEVELS, EFFECTS, MACHINE_KIND,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex fe4780c..ed6c254 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.162.0",
++ "version": "0.163.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.162.0",
++ "version": "0.163.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 30ccf20..7975f55 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.162.0",
++ "version": "0.163.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -329,6 +329,21 @@
"import": "./esm/agents-blueprint.mjs",
"default": "./src/agents/blueprint.js"
},
++ "./agents/rules": {
++ "types": "./types/agents-rules.d.ts",
++ "import": "./esm/agents-rules.mjs",
++ "default": "./src/agents/rules.js"
++ },
++ "./agents/router": {
++ "types": "./types/agents-router.d.ts",
++ "import": "./esm/agents-router.mjs",
++ "default": "./src/agents/router.js"
++ },
++ "./agents/metrics": {
++ "types": "./types/agents-metrics.d.ts",
++ "import": "./esm/agents-metrics.mjs",
++ "default": "./src/agents/metrics.js"
++ },
"./agents/api": {
"types": "./types/agents-api.d.ts",
"import": "./esm/agents-api.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex d4d4e8e..722f932 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -80,6 +80,9 @@ const ENTRIES = {
'agents-loop.mjs': '../src/agents/loop.js',
'agents-dispatch.mjs': '../src/agents/dispatch.js',
'agents-api.mjs': '../src/agents/api.js',
++ 'agents-rules.mjs': '../src/agents/rules.js',
++ 'agents-router.mjs': '../src/agents/router.js',
++ 'agents-metrics.mjs': '../src/agents/metrics.js',
'agents-blueprint.mjs': '../src/agents/blueprint.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
ddiff --git a/src/agents/metrics.js b/src/agents/metrics.js
nnew file mode 100644
iindex 0000000..93b0133
---- /dev/null
++++ b/src/agents/metrics.js
@@@ -0,0 +1,81 @@
++'use strict';
++/**
++ * openvibe-sdk/agents/metrics — what a run measured (agents.run@1 1.4.0 `metrics`; one-system-v2.md §4).
++ *
++ * createRecorder({ now }) → { call(usage), step(), evidence(e), end(exit) → metrics, snapshot() }
++ * passAtK(results, k) → the chance at least one of k tries passes (unbiased, from n tries with c passing)
++ * passHatK(results, k) → the chance all k tries pass (pass^k)
++ * flakeRate(runs) → the share of cases whose tries disagree
++ *
++ * Charged first, estimate never (browser-use): a call's cost is the provider's charge when it says one; an estimate
++ * counts only for calls that were not charged, and is reported apart; nothing known is null, never 0.
++ */
++
++const EXITS = ['done', 'limits', 'timeout', 'format_error', 'refused', 'tool_error', 'stopped', 'error'];
++const EVIDENCE = ['test', 'schema', 'grader', 'person', 'decision'];
++const round = (n) => Math.round(n * 1e6) / 1e6;
++
++function createRecorder({ now = () => Date.now() } = {}) {
++ const started = now();
++ let charged = null; let estimated = null;
++ let tin = 0; let tout = 0; let cr = 0; let cw = 0; let steps = 0;
++ const evidence = [];
++ let exit = null; let ended = null;
++ const n = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : 0);
++ const api = {
++ call(u = {}) {
++ tin += n(u.tokens_in); tout += n(u.tokens_out); cr += n(u.cache_read); cw += n(u.cache_write);
++ if (u.charged_usd != null && Number.isFinite(Number(u.charged_usd))) charged = (charged || 0) + Number(u.charged_usd);
++ else if (u.estimated_usd != null && Number.isFinite(Number(u.estimated_usd))) estimated = (estimated || 0) + Number(u.estimated_usd);
++ return api;
++ },
++ step() { steps += 1; return api; },
++ evidence(e = {}) {
++ if (!EVIDENCE.includes(e.kind) || typeof e.passed !== 'boolean') throw new TypeError('evidence is { kind: test|schema|grader|person|decision, passed }');
++ const item = { kind: e.kind, passed: e.passed };
++ if (e.detail) item.detail = String(e.detail).slice(0, 500);
++ if (typeof e.probability === 'number') item.probability = Math.max(0, Math.min(1, e.probability));
++ if (evidence.length < 20) evidence.push(item);
++ return api;
++ },
++ end(how = 'done') {
++ exit = EXITS.includes(how) ? how : 'error';
++ ended = now();
++ return api.snapshot();
++ },
++ snapshot() {
++ return {
++ charged_usd: charged == null ? null : round(charged), estimated_usd: estimated == null ? null : round(estimated),
++ tokens_in: tin, tokens_out: tout, cache_read: cr, cache_write: cw,
++ latency_ms: Math.max(0, (ended || now()) - started), steps,
++ ...(exit ? { exit } : {}), evidence: evidence.slice(),
++ };
++ },
++ };
++ return api;
++}
++
++/** Unbiased pass@k from n tries with c passing (Chen et al., HumanEval): 1 - C(n-c, k) / C(n, k). */
++function passAtK(results, k) {
++ const r = Array.isArray(results) ? results : [];
++ const n = r.length; const c = r.filter(Boolean).length;
++ if (!n || k < 1) return null;
++ if (n - c < k) return 1;
++ let p = 1;
++ for (let i = n - c + 1; i <= n; i++) p *= 1 - k / i;
++ return round(1 - p);
++}
++/** pass^k: the chance that k independent tries all pass, from the observed pass rate. */
++function passHatK(results, k) {
++ const r = Array.isArray(results) ? results : [];
++ if (!r.length || k < 1) return null;
++ return round((r.filter(Boolean).length / r.length) ** k);
++}
++/** The share of cases whose tries do not all agree. CASES is [[true, false, true], …]. */
++function flakeRate(cases) {
++ const cs = (Array.isArray(cases) ? cases : []).filter((c) => Array.isArray(c) && c.length > 1);
++ if (!cs.length) return null;
++ return round(cs.filter((c) => c.some(Boolean) && !c.every(Boolean)).length / cs.length);
++}
++
++module.exports = { createRecorder, passAtK, passHatK, flakeRate, EXITS, EVIDENCE };
ddiff --git a/src/agents/router.js b/src/agents/router.js
nnew file mode 100644
iindex 0000000..62f9f45
---- /dev/null
++++ b/src/agents/router.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * openvibe-sdk/agents/router — pay for the strong model only when it is likely to matter (one-system-v2.md §4; RouteLLM's
++ * shape, Apache-2.0: route on a predicted win rate, calibrate the threshold to a budget). The prediction comes from
++ * OpenVibe Decisions (POST /api/v1/decisions, a calibrated probability); this module is pure and takes the caller's
++ * own function to ask it.
++ *
++ * question(step) → the Decisions request body for one step
++ * probability(answer) → P(the small model is not enough) from a Decisions answer, or null
++ * route({ p, threshold, small, strong }) → { choice, strong: boolean, why }
++ * calibrate(history, { strongShare }) → the threshold that sends that share of steps to the strong model
++ * outcomes(history) → the quality-cost points per share (the scoreboard Exchange draws)
++ * chain(steps, { pin, ask }) → the ordered strategy (Gemini CLI): the person's pick → a pin → the router
++ *
++ * history is [{ p, strong, ok, usd }]: the probability seen, whether the strong model ran, whether the step succeeded by
++ * checkable evidence, and what it cost.
++ */
++
++function question(step) {
++ const text = String((step && (step.text || step.task)) || '').slice(0, 20000);
++ return {
++ input: text,
++ questions: {
++ needs_strong: {
++ type: 'noul',
++ instructions: 'Would a small, fast model most likely get this step wrong or do it badly, so that a stronger model is worth paying for? Answer yes only for hard reasoning, long or subtle code, careful judgement or a high cost of error.',
++ },
++ },
++ prefer: 'calibrated',
++ };
++}
++
++function probability(answer) {
++ const a = answer && answer.answers && answer.answers.needs_strong;
++ const p = a && (typeof a.probability === 'number' ? a.probability : typeof a.yes === 'number' ? a.yes : null);
++ return typeof p === 'number' && p >= 0 && p <= 1 ? p : null;
++}
++
++function route({ p, threshold = 0.5, small, strong }) {
++ if (p == null) return { choice: small, strong: false, why: 'no prediction: the cheaper model, escalating only on evidence' };
++ const s = p >= threshold;
++ return { choice: s ? strong : small, strong: s, why: `P(needs strong) ${p.toFixed(2)} ${s ? '≥' : '<'} threshold ${threshold.toFixed(2)}` };
++}
++
++/** The (1 - share) quantile of the probabilities seen: that share of steps would have gone strong. */
++function calibrate(history, { strongShare = 0.2 } = {}) {
++ const ps = (Array.isArray(history) ? history : []).map((h) => h && h.p).filter((p) => typeof p === 'number').sort((a, b) => a - b);
++ if (!ps.length) return { threshold: 0.5, samples: 0 };
++ const share = Math.max(0, Math.min(1, strongShare));
++ const k = Math.round(share * ps.length); // how many of the steps seen would have gone strong
++ if (k <= 0) return { threshold: 1.000001, samples: ps.length };
++ return { threshold: ps[ps.length - k], samples: ps.length };
++}
++
++/** Quality and cost at each strong share, for the steps that ran both ways or either. */
++function outcomes(history, shares = [0, 0.2, 0.5, 0.8, 1]) {
++ const h = (Array.isArray(history) ? history : []).filter((x) => x && typeof x.p === 'number');
++ return shares.map((share) => {
++ const { threshold } = calibrate(h, { strongShare: share });
++ const routed = h.filter((x) => (x.p >= threshold) === !!x.strong);
++ const ok = routed.filter((x) => x.ok).length;
++ const usd = routed.reduce((s, x) => s + (Number(x.usd) || 0), 0);
++ return { share, threshold, steps: routed.length, quality: routed.length ? ok / routed.length : null, usd_per_step: routed.length ? usd / routed.length : null };
++ });
++}
++
++/** The ordered strategy: the person's own pick wins; then a pin; then the router (ASK returns a probability). */
++async function chain(step, { pick = null, pin = null, ask = null, threshold = 0.5, small, strong } = {}) {
++ if (pick) return { choice: pick, strong: null, why: 'the person’s own pick' };
++ if (pin) return { choice: pin, strong: null, why: 'the blueprint’s pin' };
++ let p = null;
++ if (typeof ask === 'function') { try { p = probability(await ask(question(step))); } catch { p = null; } }
++ return route({ p, threshold, small, strong });
++}
++
++module.exports = { question, probability, route, calibrate, outcomes, chain };
ddiff --git a/src/agents/rules.js b/src/agents/rules.js
nnew file mode 100644
iindex 0000000..52882ae
---- /dev/null
++++ b/src/agents/rules.js
@@@ -0,0 +1,97 @@
++'use strict';
++/**
++ * openvibe-sdk/agents/rules — permissions as data (agents.permission-rule@1; one-system-v2.md §3.1-3.3, from Gemini CLI
++ * and OpenCode). Pure functions; nothing here asks a person or calls a network.
++ *
++ * decide(rules, call, { attended, level }) → { effect: 'allow' | 'ask' | 'deny', rule, why }
++ * fromBlueprint(doc) → the rules a blueprint's hands, sandbox and network compile to
++ * matches(pattern, value) → a tool or resource pattern with * (and ** for paths)
++ *
++ * A call is { action, resource?, floor?, kind? }: ACTION a tool name (ov:live.chat.post, machine:shell), RESOURCE what it
++ * touches (a path, a domain, a command line), FLOOR true for money, a password, a deletion, a new recipient or posting in
++ * the person's name (the caller marks it; the floor ASKS whatever any rule says, and a deny still denies), KIND one of
++ * read | edit | exec | network | other, which the level's default uses.
++ *
++ * Rules are read in order and the LAST one that matches wins (a person's narrow rule after a blueprint's broad one). A
++ * rule's `when` limits it to attended or unattended work. With no rule matching, the level decides:
++ * ask ask
++ * auto-edits allow reads, edits and its own commands; ask the rest
++ * approve-for-me allow
++ * full allow
++ * Unattended work (a schedule, a trigger, a check on a node) never waits: an ask there settles to allow at the levels
++ * approve-for-me and full, and to deny below them. The floor is the exception: unattended, it is denied.
++ */
++
++const LEVELS = ['ask', 'auto-edits', 'approve-for-me', 'full'];
++const EFFECTS = ['allow', 'ask', 'deny'];
++
++const esc = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
++/** A pattern with * (one segment or any run of characters in a name) and ** (any path), anchored. */
++function toRegExp(pattern) {
++ const p = String(pattern || '');
++ let out = '';
++ for (let i = 0; i < p.length; i++) {
++ if (p[i] === '*' && p[i + 1] === '*') { out += '.*'; i++; continue; }
++ if (p[i] === '*') { out += p.includes('/') ? '[^/]*' : '.*'; continue; }
++ out += esc(p[i]);
++ }
++ return new RegExp(`^${out}$`);
++}
++function matches(pattern, value) {
++ if (pattern == null || pattern === '' || pattern === '*') return true;
++ return toRegExp(pattern).test(String(value == null ? '' : value));
++}
++/** A resource rule: a path glob, a domain (*.example.org matches its subdomains and itself), or a command prefix. */
++function resourceMatches(rule, resource) {
++ if (rule.resource == null) return true;
++ const r = String(resource == null ? '' : resource);
++ const pat = String(rule.resource);
++ if (/^\*\.[a-z0-9.-]+$/i.test(pat)) { const base = pat.slice(2).toLowerCase(); const host = r.toLowerCase(); return host === base || host.endsWith(`.${base}`); }
++ if (pat.includes('*')) return matches(pat, r);
++ return r === pat || r.startsWith(`${pat} `) || r.startsWith(`${pat}/`);
++}
++
++function byLevel(level, call) {
++ if (level === 'full' || level === 'approve-for-me') return 'allow';
++ if (level === 'auto-edits' && ['read', 'edit', 'exec'].includes(call.kind)) return 'allow';
++ return 'ask';
++}
++
++function decide(rules, call, { attended = true, level = 'ask' } = {}) {
++ const lv = LEVELS.includes(level) ? level : 'ask';
++ let hit = null;
++ for (const rule of Array.isArray(rules) ? rules : []) {
++ if (!rule || !EFFECTS.includes(rule.effect)) continue;
++ const when = rule.when || 'any';
++ if ((when === 'attended' && !attended) || (when === 'unattended' && attended)) continue;
++ if (matches(rule.action, call.action) && resourceMatches(rule, call.resource)) hit = rule;
++ }
++ let effect = hit ? hit.effect : byLevel(lv, call);
++ let why = hit ? `rule ${hit.action}${hit.resource ? ` on ${hit.resource}` : ''}: ${hit.effect}` : `level ${lv}`;
++ if (call.floor && effect !== 'deny') { effect = 'ask'; why = 'the floor always asks'; }
++ if (!attended && effect === 'ask') {
++ if (call.floor) { effect = 'deny'; why = 'the floor asks, and nobody is there to answer'; }
++ else { effect = lv === 'full' || lv === 'approve-for-me' ? 'allow' : 'deny'; why += `; unattended at level ${lv}: ${effect}`; }
++ }
++ return { effect, rule: hit, why };
++}
++
++const MACHINE_KIND = { files: 'edit', shell: 'exec', desk: 'other', browser: 'network', screen: 'read' };
++/** The rules a blueprint compiles to: each tool's mode, the sandbox's protected writes, and its network. */
++function fromBlueprint(doc) {
++ const out = [];
++ const tools = doc && doc.hands && Array.isArray(doc.hands.tools) ? doc.hands.tools : [];
++ out.push({ action: '*', effect: 'deny', reason: 'only the tools the blueprint names' });
++ for (const t of tools) out.push({ action: t.tool, effect: t.mode === 'allow' ? 'allow' : 'ask' });
++ if (doc && doc.hands && doc.hands.search) out.push({ action: 'tool_search', effect: 'allow' });
++ const rules = (doc && doc.rules) || {};
++ const sandbox = rules.sandbox || 'workspace-write';
++ if (sandbox === 'read-only') out.push({ action: 'machine:files', resource: '**', effect: 'deny', reason: 'read-only' }, { action: 'machine:shell', effect: 'deny', reason: 'read-only' });
++ if (sandbox !== 'full') out.push({ action: 'machine:files', resource: '**/.git/hooks/**', effect: 'deny', reason: 'never the hooks' }, { action: 'machine:files', resource: '**/.openvibe/**', effect: 'deny', reason: 'never its own config' });
++ const net = rules.network || { mode: 'public' };
++ if (net.mode === 'none') out.push({ action: 'network', effect: 'deny', reason: 'no network' });
++ if (net.mode === 'listed') { out.push({ action: 'network', effect: 'deny', reason: 'listed domains only' }); for (const d of net.domains || []) out.push({ action: 'network', resource: d, effect: 'allow' }); }
++ return out;
++}
++
++module.exports = { decide, fromBlueprint, matches, LEVELS, EFFECTS, MACHINE_KIND };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 3ebc0b6..bd32a34 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.162.0';
++const SDK_VERSION = '0.163.0';
module.exports = {
createClient,
ddiff --git a/test/agents-rules-router-metrics.test.js b/test/agents-rules-router-metrics.test.js
nnew file mode 100644
iindex 0000000..f8ab2d9
---- /dev/null
++++ b/test/agents-rules-router-metrics.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++// openvibe-sdk/agents/rules, agents/router and agents/metrics (one-system-v2.md; agents.permission-rule@1, agents.run@1
++// 1.4.0 metrics): permissions as data with an unattended set and the floor, routing on a Decisions probability with a
++// budget-calibrated threshold, and a recorder that keeps the charged cost first.
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const rules = require('../src/agents/rules');
++const router = require('../src/agents/router');
++const metrics = require('../src/agents/metrics');
++
++run([
++ ['rules: the last match wins; the level decides when none does; the floor always asks', () => {
++ const R = [{ action: 'ov:*', effect: 'allow' }, { action: 'ov:live.chat.post', effect: 'ask' }];
++ assert.equal(rules.decide(R, { action: 'ov:live.chat.read' }).effect, 'allow');
++ assert.equal(rules.decide(R, { action: 'ov:live.chat.post' }).effect, 'ask', 'the narrower rule after the broad one');
++ assert.equal(rules.decide([], { action: 'machine:shell', kind: 'exec' }, { level: 'ask' }).effect, 'ask');
++ assert.equal(rules.decide([], { action: 'machine:shell', kind: 'exec' }, { level: 'auto-edits' }).effect, 'allow');
++ assert.equal(rules.decide([], { action: 'ov:billing.pay', kind: 'other' }, { level: 'auto-edits' }).effect, 'ask');
++ const pay = rules.decide([{ action: '*', effect: 'allow' }], { action: 'ov:billing.pay', floor: true }, { level: 'full' });
++ assert.deepEqual([pay.effect, pay.why], ['ask', 'the floor always asks']);
++ assert.equal(rules.decide([{ action: 'ov:billing.pay', effect: 'deny' }], { action: 'ov:billing.pay', floor: true }).effect, 'deny', 'a deny still denies');
++ }],
++ ['rules: resources are paths, domains or command prefixes; when limits a rule', () => {
++ const R = [{ action: 'machine:files', resource: 'src/**', effect: 'allow' }, { action: 'network', resource: '*.openvibe.live', effect: 'allow' }, { action: 'machine:shell', resource: 'npm test', effect: 'allow', when: 'unattended' }];
++ assert.equal(rules.decide(R, { action: 'machine:files', resource: 'src/a/b.js' }).effect, 'allow');
++ assert.equal(rules.decide(R, { action: 'machine:files', resource: 'secrets/.env' }).effect, 'ask');
++ assert.equal(rules.decide(R, { action: 'network', resource: 'api.openvibe.live' }).effect, 'allow');
++ assert.equal(rules.decide(R, { action: 'network', resource: 'openvibe.live' }).effect, 'allow', 'the base domain too');
++ assert.equal(rules.decide(R, { action: 'network', resource: 'evil-openvibe.live' }).effect, 'ask');
++ assert.equal(rules.decide(R, { action: 'machine:shell', resource: 'npm test -- core' }, { attended: false }).effect, 'allow');
++ assert.equal(rules.decide(R, { action: 'machine:shell', resource: 'npm test' }, { attended: true }).effect, 'ask', 'an unattended rule does not hold while someone is there');
++ }],
++ ['rules: unattended work never waits; the floor unattended is denied', () => {
++ assert.equal(rules.decide([], { action: 'ov:live.chat.post' }, { attended: false, level: 'ask' }).effect, 'deny');
++ assert.equal(rules.decide([], { action: 'ov:live.chat.post' }, { attended: false, level: 'approve-for-me' }).effect, 'allow');
++ assert.equal(rules.decide([], { action: 'ov:billing.pay', floor: true }, { attended: false, level: 'full' }).effect, 'deny');
++ }],
++ ['rules: a blueprint compiles to rules: only its tools, never the hooks, its network', () => {
++ const doc = { hands: { tools: [{ tool: 'ov:live.chat.read', mode: 'allow' }, { tool: 'machine:files', mode: 'allow' }] }, rules: { level: 'auto-edits', sandbox: 'workspace-write', network: { mode: 'listed', domains: ['*.openvibe.live'] } } };
++ const R = rules.fromBlueprint(doc);
++ assert.equal(rules.decide(R, { action: 'ov:live.chat.read' }).effect, 'allow');
++ assert.equal(rules.decide(R, { action: 'ov:community.post.create' }).effect, 'deny', 'a tool it does not name');
++ assert.equal(rules.decide(R, { action: 'machine:files', resource: 'repo/.git/hooks/pre-commit' }).effect, 'deny');
++ assert.equal(rules.decide(R, { action: 'machine:files', resource: 'repo/src/x.js' }).effect, 'allow');
++ assert.equal(rules.decide(R, { action: 'network', resource: 'example.org' }).effect, 'deny');
++ assert.equal(rules.decide(R, { action: 'network', resource: 'cdn.openvibe.live' }).effect, 'allow');
++ const ro = rules.fromBlueprint({ hands: { tools: [{ tool: 'machine:files', mode: 'allow' }] }, rules: { sandbox: 'read-only' } });
++ assert.equal(rules.decide(ro, { action: 'machine:files', resource: 'a.txt' }).effect, 'deny', 'read-only writes nothing');
++ }],
++ ['router: a Decisions probability and a threshold calibrated to a budget', () => {
++ const q = router.question({ text: 'Refactor the payment retries' });
++ assert.equal(q.questions.needs_strong.type, 'noul');
++ assert.equal(q.prefer, 'calibrated');
++ assert.equal(router.probability({ answers: { needs_strong: { probability: 0.81 } } }), 0.81);
++ assert.equal(router.probability({}), null);
++ assert.deepEqual(router.route({ p: 0.81, threshold: 0.7, small: 'flash', strong: 'pro' }).choice, 'pro');
++ assert.deepEqual(router.route({ p: 0.2, threshold: 0.7, small: 'flash', strong: 'pro' }).choice, 'flash');
++ assert.equal(router.route({ p: null, small: 'flash', strong: 'pro' }).choice, 'flash', 'no prediction: cheaper, escalate on evidence');
++ const h = Array.from({ length: 10 }, (_, i) => ({ p: i / 10 }));
++ assert.equal(router.calibrate(h, { strongShare: 0.2 }).threshold, 0.8, 'two of ten at or above it');
++ assert.equal(h.filter((x) => x.p >= router.calibrate(h, { strongShare: 0.3 }).threshold).length, 3);
++ assert.equal(router.calibrate([], {}).threshold, 0.5);
++ }],
++ ['router: the strategy chain and the quality-cost points', async () => {
++ assert.equal((await router.chain({ text: 'x' }, { pick: 'mine', small: 'a', strong: 'b' })).choice, 'mine');
++ assert.equal((await router.chain({ text: 'x' }, { pin: 'pinned', small: 'a', strong: 'b' })).choice, 'pinned');
++ assert.equal((await router.chain({ text: 'x' }, { ask: async () => ({ answers: { needs_strong: { probability: 0.9 } } }), threshold: 0.5, small: 'a', strong: 'b' })).choice, 'b');
++ assert.equal((await router.chain({ text: 'x' }, { ask: async () => { throw new Error('down'); }, small: 'a', strong: 'b' })).choice, 'a', 'Decisions down: the cheaper one');
++ const hist = [{ p: 0.9, strong: true, ok: true, usd: 0.01 }, { p: 0.1, strong: false, ok: true, usd: 0.001 }, { p: 0.6, strong: false, ok: false, usd: 0.001 }];
++ const pts = router.outcomes(hist, [0, 1]);
++ assert.equal(pts.length, 2);
++ assert.ok(pts.every((x) => 'quality' in x && 'usd_per_step' in x));
++ }],
++ ['metrics: charged first, an estimate apart, unknown is null; a named exit; evidence', () => {
++ let t = 1000;
++ const rec = metrics.createRecorder({ now: () => t });
++ assert.deepEqual([rec.snapshot().charged_usd, rec.snapshot().estimated_usd], [null, null], 'nothing known is null, never 0');
++ rec.call({ tokens_in: 1000, tokens_out: 100, cache_read: 800, charged_usd: 0.002 }).step();
++ rec.call({ tokens_in: 500, tokens_out: 50, estimated_usd: 0.0007 }).step();
++ rec.evidence({ kind: 'test', passed: true, detail: '12 passed' });
++ assert.throws(() => rec.evidence({ kind: 'vibes', passed: true }), TypeError);
++ t = 4100;
++ const m = rec.end('done');
++ assert.deepEqual([m.charged_usd, m.estimated_usd, m.tokens_in, m.tokens_out, m.cache_read, m.steps, m.latency_ms, m.exit], [0.002, 0.0007, 1500, 150, 800, 2, 3100, 'done']);
++ assert.equal(metrics.createRecorder().end('crashed').exit, 'error', 'an unknown exit is error');
++ }],
++ ['metrics: pass@k, pass^k and flake rate', () => {
++ assert.equal(metrics.passAtK([true, false, false, false], 1), 0.25);
++ assert.equal(metrics.passAtK([true, false, false, false], 4), 1);
++ assert.equal(metrics.passAtK([false, false], 1), 0);
++ assert.equal(metrics.passHatK([true, true, false, true], 2), 0.5625);
++ assert.equal(metrics.flakeRate([[true, true], [true, false], [false, false], [false, true, true]]), 0.5);
++ assert.equal(metrics.flakeRate([]), null);
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 4e3f406..6e5d265 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -72,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 116); // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
++ assert.equal(subpaths.length, 119); // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/agents-metrics.d.ts b/types/agents-metrics.d.ts
nnew file mode 100644
iindex 0000000..6e6c62a
---- /dev/null
++++ b/types/agents-metrics.d.ts
@@@ -0,0 +1,12 @@
++// openvibe-sdk/agents/metrics: what a run measured (agents.run@1 1.4.0 metrics).
++export type Exit = 'done' | 'limits' | 'timeout' | 'format_error' | 'refused' | 'tool_error' | 'stopped' | 'error';
++export interface Evidence { kind: 'test' | 'schema' | 'grader' | 'person' | 'decision'; passed: boolean; detail?: string; probability?: number; }
++export interface Metrics { charged_usd: number | null; estimated_usd: number | null; tokens_in: number; tokens_out: number; cache_read: number; cache_write: number; latency_ms: number; steps: number; exit?: Exit; evidence: Evidence[]; }
++export interface Usage { tokens_in?: number; tokens_out?: number; cache_read?: number; cache_write?: number; charged_usd?: number | null; estimated_usd?: number | null; }
++export interface Recorder { call(u: Usage): Recorder; step(): Recorder; evidence(e: Evidence): Recorder; end(exit?: Exit | string): Metrics; snapshot(): Metrics; }
++export function createRecorder(options?: { now?: () => number }): Recorder;
++export function passAtK(results: boolean[], k: number): number | null;
++export function passHatK(results: boolean[], k: number): number | null;
++export function flakeRate(cases: boolean[][]): number | null;
++export const EXITS: Exit[];
++export const EVIDENCE: Evidence['kind'][];
ddiff --git a/types/agents-router.d.ts b/types/agents-router.d.ts
nnew file mode 100644
iindex 0000000..dfea995
---- /dev/null
++++ b/types/agents-router.d.ts
@@@ -0,0 +1,8 @@
++// openvibe-sdk/agents/router: route on a Decisions probability with a budget-calibrated threshold.
++export interface Outcome { p: number; strong?: boolean; ok?: boolean; usd?: number; }
++export function question(step: { text?: string; task?: string }): object;
++export function probability(answer: unknown): number | null;
++export function route<T>(o: { p: number | null; threshold?: number; small: T; strong: T }): { choice: T; strong: boolean; why: string };
++export function calibrate(history: Outcome[], options?: { strongShare?: number }): { threshold: number; samples: number };
++export function outcomes(history: Outcome[], shares?: number[]): Array<{ share: number; threshold: number; steps: number; quality: number | null; usd_per_step: number | null }>;
++export function chain<T>(step: { text?: string; task?: string }, options?: { pick?: T | null; pin?: T | null; ask?: ((body: object) => Promise<unknown>) | null; threshold?: number; small: T; strong: T }): Promise<{ choice: T; strong: boolean | null; why: string }>;
ddiff --git a/types/agents-rules.d.ts b/types/agents-rules.d.ts
nnew file mode 100644
iindex 0000000..6c80b22
---- /dev/null
++++ b/types/agents-rules.d.ts
@@@ -0,0 +1,11 @@
++// openvibe-sdk/agents/rules: permissions as data (agents.permission-rule@1).
++export type Effect = 'allow' | 'ask' | 'deny';
++export type Level = 'ask' | 'auto-edits' | 'approve-for-me' | 'full';
++export interface PermissionRule { action: string; resource?: string; effect: Effect; when?: 'attended' | 'unattended' | 'any'; reason?: string; }
++export interface Call { action: string; resource?: string; floor?: boolean; kind?: 'read' | 'edit' | 'exec' | 'network' | 'other'; }
++export function decide(rules: PermissionRule[], call: Call, options?: { attended?: boolean; level?: Level }): { effect: Effect; rule: PermissionRule | null; why: string };
++export function fromBlueprint(doc: object): PermissionRule[];
++export function matches(pattern: string | null | undefined, value: string): boolean;
++export const LEVELS: Level[];
++export const EFFECTS: Effect[];
++export const MACHINE_KIND: Record<string, string>;