openvibers/openvibe.sdk Commits

openvibe-sdk 0.162.0: agents/blueprint, make your own agent (#246)

OP OpenVibers committed 15 h ago · ef33557504 · 14 files, +754 −11

* openvibe-sdk 0.162.0: agents/blueprint, make your own agent

validate (no secret in it, inputs declared, budgets, places, served shapes, teams), withDefaults,
asks and unagreed (the install screen, keys for agents.install@1), wider (what a new version asks
for), fill, compile (one run: 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 cents) and estimate (the price of a
run and where it is free). Contracts 0.419.0's agents.blueprint@1 and agents.install@1.

* openvibe-sdk 0.162.0: the version in every place that names it

core.SDK_VERSION, the lockfile, STATUS.json, the README's install line, the browser bundle, and docs/api-surface.json
with agents/blueprint's exports.

* pack test: agents/blueprint is the 116th subpath

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7bed442..23f535e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,27 @@
# Changelog
++## 0.162.0 — 2026-11-12
++
++- **New `openvibe-sdk/agents/blueprint`: make your own agent** (agent-blueprints.md; Contracts 0.419.0's
++ `agents.blueprint@1` and `agents.install@1`). These are pure functions, so a browser, Link, a CLI and every service
++ check and run a blueprint the same way:
++ - `validate`: the shape a person edits, plus the rules a schema cannot say. It carries no secret (anything that
++ looks like a key or a token is refused where it is). Every `{{input}}` is declared. Level `full` has a budget.
++ Machine and robot tools have somewhere to run. A shared place says what it needs, and never names a machine. A
++ tool served over MCP has its shapes. A team never contains itself.
++ - `withDefaults`: it talks only, asks first, keeps nothing, answers its owner, and spends 25¢ a run and $2 a day.
++ - `asks`: the install screen in plain words, with the keys an install records as agreed. `unagreed` lists what an
++ install has not agreed to yet.
++ - `wider`: what a new version asks for that the old one did not. That includes a tool going from ask to allow, a
++ higher level, a bigger budget, more people who may ask, more memory, a new place, trigger or team member. An
++ empty list means an install set to "same-or-less" takes the version without asking.
++ - `fill`: inputs, typed and checked.
++ - `compile`: one run. It returns `agents.run-start@1`, the loop's system prompt (with the floor said in words) and
++ tools, the Dispatcher's need, pin, rungs and price cap, the places and the install's machines, memory, and the
++ team. Try it, or a run without an install, asks before every tool and spends at most 5¢.
++ - `estimate`: the price of a run on the cheapest choice it may use, through `agents/dispatch`, and the rungs on
++ which it is free.
++
## 0.161.0 — 2026-11-12
- **`openvibe-sdk/cam`: `capture(options)`** opens a camera through the site's own shared capture core (Shared's
ddiff --git a/README.md b/README.md
iindex e8126a5..0589ff5 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.161.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0
```
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.161.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.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.
**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.161.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.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).
@@@ -222,6 +222,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `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/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 6d4ae06..07db2c4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.161.0",
++ "version": "0.162.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 2a19edb..00d603e 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.161.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.162.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.161.0';
++const SDK_VERSION = '0.162.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex b23f493..cc59513 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -657,6 +657,29 @@
"createRunApi": "function",
"startInput": "function"
},
++ "openvibe-sdk/agents/blueprint": {
++ "BP_REF": "object",
++ "BlueprintError": "class",
++ "FLOOR": "array",
++ "INPUT_TYPES": "array",
++ "LEVELS": "array",
++ "MEMORY": "array",
++ "PLACES": "array",
++ "TOOL": "object",
++ "WHEN": "array",
++ "WHO": "array",
++ "asks": "function",
++ "compile": "function",
++ "estimate": "function",
++ "fill": "function",
++ "parseRef": "function",
++ "ref": "function",
++ "toolWords": "function",
++ "unagreed": "function",
++ "validate": "function",
++ "wider": "function",
++ "withDefaults": "function"
++ },
"openvibe-sdk/agents/dispatch": {
"EVIDENCE": "array",
"RUNGS": "array",
ddiff --git a/esm/agents-blueprint.mjs b/esm/agents-blueprint.mjs
nnew file mode 100644
iindex 0000000..c5099f9
---- /dev/null
++++ b/esm/agents-blueprint.mjs
@@@ -0,0 +1,10 @@
++// Generated by scripts/esm.js from src/agents/blueprint.js. Do not edit.
++import cjs from '../src/agents/blueprint.js';
++
++export const {
++ validate, withDefaults, asks, wider, unagreed, fill,
++ compile, estimate, ref, parseRef, toolWords, BlueprintError,
++ FLOOR, LEVELS, PLACES, MEMORY, WHO, WHEN,
++ INPUT_TYPES, TOOL, BP_REF,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 0133730..ad91ed3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.161.0",
++ "version": "0.162.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.161.0",
++ "version": "0.162.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 972d6e3..d07ade3 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.161.0",
++ "version": "0.162.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",
@@@ -324,6 +324,11 @@
"import": "./esm/agents-dispatch.mjs",
"default": "./src/agents/dispatch.js"
},
++ "./agents/blueprint": {
++ "types": "./types/agents-blueprint.d.ts",
++ "import": "./esm/agents-blueprint.mjs",
++ "default": "./src/agents/blueprint.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 8b15cea..d4d4e8e 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -80,6 +80,7 @@ const ENTRIES = {
'agents-loop.mjs': '../src/agents/loop.js',
'agents-dispatch.mjs': '../src/agents/dispatch.js',
'agents-api.mjs': '../src/agents/api.js',
++ 'agents-blueprint.mjs': '../src/agents/blueprint.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
ddiff --git a/src/agents/blueprint.js b/src/agents/blueprint.js
nnew file mode 100644
iindex 0000000..6fc8bc7
---- /dev/null
++++ b/src/agents/blueprint.js
@@@ -0,0 +1,441 @@
++'use strict';
++/**
++ * openvibe-sdk/agents/blueprint: make your own agent (agent-blueprints.md, 2026-11-12). A blueprint
++ * (agents.blueprint@1) is an agent as one small document of ten parts: face, brain, knows, hands, places, memory,
++ * rules, when, makes and team, with `inputs` an installer fills and `needs` they must connect. These are pure
++ * functions; nothing here calls a network or stores anything.
++ *
++ * validate(doc) → { ok, errors: [{ at, why }] }: the shape and the rules a schema cannot say
++ * withDefaults(doc) → a copy with every absent part at its safe default
++ * asks(doc, { describe }) → what installing it lets it do, in words: [{ key, part, words, ask }]
++ * wider(oldDoc, newDoc) → what a new version asks for that the old one did not ([] = not wider)
++ * unagreed(doc, agreed) → the asks an install has not agreed to yet
++ * fill(doc, inputs) → a copy with {{inputs}} replaced, typed and checked (throws BlueprintError)
++ * compile(doc, { install, inputs, text, context, tryIt })
++ * → { start, loop, dispatch, place, memory, team }: one run of it
++ * estimate(doc, choices, { turns }) → { usd_per_run, choice, free_on, why } through agents/dispatch
++ * ref(doc) / parseRef('bp_…@3') → the version's address, and back
++ *
++ * The rules beyond the schema (Contracts validates the shape with the schema itself; validate() here keeps the
++ * rules so a browser, Link or a CLI can check a draft without a schema validator):
++ * - a blueprint carries no secret: a string that looks like a key, a token or a private key is refused;
++ * - every {{input}} it uses is declared in INPUTS;
++ * - level `full` needs a budget (run_usd or day_usd);
++ * - a `machine:` tool needs a place to run on (PLACES other than none); a `robot:` tool needs `robot` in
++ * PLACES.NEEDS or a robot in NEEDS; `shared` places say what they need (a blueprint never names a machine);
++ * - served as an MCP tool, it has an answer shape (MAKES.SCHEMA) and an input shape (SERVE.INPUT);
++ * - a team's roles are other blueprints at fixed versions, never itself.
++ * The floor is not a field: money, passwords, deletions, new recipients and posting in a person's name ask at every
++ * level (FLOOR), whatever a blueprint says; products enforce it at their gates, and compile() says it in the prompt.
++ */
++
++const dispatch = require('./dispatch');
++
++const BP_ID = /^bp_[A-Za-z0-9_-]{3,40}$/;
++const BP_REF = /^(bp_[A-Za-z0-9_-]{3,40})@([1-9][0-9]{0,5})$/;
++const TOOL = new RegExp('^(?:'
++ + 'ov:[a-z][a-z0-9-]{0,40}(?:\\.[a-z0-9_-]{1,60}){1,4}'
++ + '|tools:[a-z0-9][a-z0-9-]{0,60}(?:\\.[a-z0-9_-]{1,60}){0,3}'
++ + '|connect:[a-z0-9][a-z0-9-]{0,60}(?:\\.[a-z0-9_-]{1,60}){0,3}'
++ + '|mcp:https://[^\\s]{4,1800}'
++ + '|machine:(?:files|shell|desk|browser|screen)'
++ + '|robot:[a-z0-9_][a-z0-9_.-]{0,80}'
++ + '|harness:(?:auto|dsh|codex|claude-code|opencode|hermes|gemini)'
++ + '|agent:(?:bp_[A-Za-z0-9_-]{3,40}(?:@[1-9][0-9]{0,5})?|agt_[A-Za-z0-9]{6,40})'
++ + ')$');
++const INPUT_RE = /\{\{\s*([a-z][a-z0-9_]{0,30})\s*\}\}/g;
++
++const STATES = ['draft', 'published', 'retired'];
++const VISIBILITY = ['private', 'link', 'public'];
++const LEVELS = ['ask', 'auto-edits', 'approve-for-me', 'full'];
++const WHO = ['owner', 'list', 'followers', 'anyone'];
++const MEMORY = ['none', 'thread', 'person', 'team'];
++const PLACES = ['none', 'own', 'shared', 'openvibe', 'sandbox'];
++const PLACE_NEEDS = ['gpu', 'desk', 'browser', 'linux', 'windows', 'macos', 'android', 'robot'];
++const WHEN = ['manual', 'chat', 'feed', 'comments', 'git', 'composer', 'notifications', 'mail', 'schedule', 'goal', 'event', 'webhook', 'machine'];
++const INPUT_TYPES = ['text', 'number', 'boolean', 'choice', 'channel', 'room', 'repository', 'machine', 'url'];
++const FLOOR = ['money', 'password', 'deletion', 'new-recipient', 'post-as-you'];
++const FLOOR_WORDS = 'spending money, a password, deleting anything, a new recipient, and posting in your name';
++
++/** Strings that look like a credential. A blueprint is shared and public; a secret never belongs in one. */
++const SECRET_RE = [
++ /\bsk-[A-Za-z0-9_-]{16,}/, /\bsk-ant-[A-Za-z0-9_-]{10,}/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/,
++ /\bgithub_pat_[A-Za-z0-9_]{30,}/, /\bxox[abpr]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
++ /\bhbt_[A-Za-z0-9]{20,}/, /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
++];
++
++class BlueprintError extends Error {
++ constructor(errors) {
++ super(errors.map((e) => `${e.at}: ${e.why}`).join('; '));
++ this.name = 'BlueprintError';
++ this.errors = errors;
++ }
++}
++
++const isObj = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
++const arr = (v) => (Array.isArray(v) ? v : []);
++const copy = (v) => JSON.parse(JSON.stringify(v));
++const rank = (list, v) => list.indexOf(v);
++
++/** Every string in a document, with where it is. */
++function strings(v, at = '', out = []) {
++ if (typeof v === 'string') out.push([at || '/', v]);
++ else if (Array.isArray(v)) v.forEach((x, i) => strings(x, `${at}/${i}`, out));
++ else if (isObj(v)) for (const [k, x] of Object.entries(v)) strings(x, `${at}/${k}`, out);
++ return out;
++}
++
++/** The {{inputs}} a document uses: in its instructions, greeting and triggers. */
++function usedInputs(doc) {
++ const places = [
++ ['/knows/instructions', doc.knows && doc.knows.instructions],
++ ['/face/greeting', doc.face && doc.face.greeting],
++ ...arr(doc.when).flatMap((w, i) => [[`/when/${i}/rule`, w && w.rule], [`/when/${i}/where/id`, w && w.where && w.where.id]]),
++ ];
++ const out = [];
++ for (const [at, s] of places) {
++ if (typeof s !== 'string') continue;
++ for (const m of s.matchAll(INPUT_RE)) out.push({ at, id: m[1] });
++ }
++ return out;
++}
++
++/**
++ * Check a blueprint. Returns { ok, errors: [{ at, why }] }; `at` is a JSON pointer. It checks the parts a person
++ * edits and every rule above; a service also validates the whole shape against Contracts' agents.blueprint@1.
++ */
++function validate(doc) {
++ const errors = [];
++ const bad = (at, why) => errors.push({ at, why });
++ if (!isObj(doc)) return { ok: false, errors: [{ at: '/', why: 'a blueprint is an object' }] };
++
++ if (!BP_ID.test(String(doc.id || ''))) bad('/id', 'an id is bp_ and 3 to 40 letters, digits, - or _');
++ if (!Number.isInteger(doc.version) || doc.version < 1) bad('/version', 'a version is a whole number from 1');
++ if (!STATES.includes(doc.state)) bad('/state', `one of ${STATES.join(', ')}`);
++ if (typeof doc.name !== 'string' || !doc.name.trim() || doc.name.length > 60) bad('/name', 'a name of 1 to 60 characters');
++ if (typeof doc.summary !== 'string' || !doc.summary.trim() || doc.summary.length > 300) bad('/summary', 'a summary of 1 to 300 characters');
++ if (!isObj(doc.author) || !['user', 'service'].includes(doc.author.type) || !doc.author.id) bad('/author', 'who made it: { type: user | service, id }');
++ if (!VISIBILITY.includes(doc.visibility)) bad('/visibility', `one of ${VISIBILITY.join(', ')}`);
++ if (doc.state === 'published' && !doc.published_at) bad('/published_at', 'a published version says when');
++ if (doc.forked_from != null && !BP_REF.test(String(doc.forked_from))) bad('/forked_from', 'a version: bp_…@N');
++ if (!isObj(doc.knows) || typeof doc.knows.instructions !== 'string' || !doc.knows.instructions.trim()) bad('/knows/instructions', 'it needs instructions');
++
++ for (const [at, s] of strings(doc)) if (SECRET_RE.some((re) => re.test(s))) bad(at, 'this looks like a key or a token: a blueprint carries no secret (the installer connects their own)');
++
++ const declared = new Map();
++ arr(doc.inputs).forEach((inp, i) => {
++ if (!isObj(inp) || !/^[a-z][a-z0-9_]{0,30}$/.test(String(inp.id || ''))) return bad(`/inputs/${i}/id`, 'an input id is lower case: a-z, 0-9, _');
++ if (declared.has(inp.id)) bad(`/inputs/${i}/id`, `${inp.id} is declared twice`);
++ if (!INPUT_TYPES.includes(inp.type)) bad(`/inputs/${i}/type`, `one of ${INPUT_TYPES.join(', ')}`);
++ if (inp.type === 'choice' && arr(inp.choices).length < 2) bad(`/inputs/${i}/choices`, 'a choice needs two or more choices');
++ declared.set(inp.id, inp);
++ });
++ for (const u of usedInputs(doc)) if (!declared.has(u.id)) bad(u.at, `{{${u.id}}} is not one of its inputs`);
++
++ const brain = isObj(doc.brain) ? doc.brain : {};
++ if (brain.pick === 'exact' && !brain.model) bad('/brain/model', 'an exact pick names the model');
++ if (brain.pick === 'size' && !brain.size) bad('/brain/size', 'a size pick names the size');
++ for (const r of arr(brain.rungs)) if (!dispatch.RUNGS.includes(r)) bad('/brain/rungs', `${r} is not a rung (${dispatch.RUNGS.join(', ')})`);
++
++ const tools = arr(doc.hands && doc.hands.tools);
++ tools.forEach((t, i) => {
++ if (!isObj(t) || !TOOL.test(String(t.tool || ''))) return bad(`/hands/tools/${i}/tool`, `${t && t.tool} is not a tool OpenVibe knows (ov:, tools:, connect:, mcp:https://, machine:, robot:, harness:, agent:)`);
++ if (!['allow', 'ask'].includes(t.mode)) bad(`/hands/tools/${i}/mode`, 'allow or ask');
++ });
++
++ const places = isObj(doc.places) ? doc.places : {};
++ const order = arr(places.order).length ? places.order : ['none'];
++ for (const p of order) if (!PLACES.includes(p)) bad('/places/order', `${p} is not a kind of place (${PLACES.join(', ')}); a blueprint never names a machine, the install does`);
++ for (const n of arr(places.needs)) if (!PLACE_NEEDS.includes(n)) bad('/places/needs', `${n} is not something a place can have`);
++ const runsSomewhere = order.some((p) => p !== 'none');
++ if (tools.some((t) => /^machine:/.test(t && t.tool)) && !runsSomewhere) bad('/places/order', 'a machine: tool needs a place to run on');
++ const robotNeeded = arr(places.needs).includes('robot') || arr(doc.needs).some((n) => n && n.kind === 'robot');
++ if (tools.some((t) => /^robot:/.test(t && t.tool)) && !robotNeeded) bad('/places/needs', 'a robot: tool needs a robot (robot in places.needs, or a robot in needs)');
++ if (order.includes('shared') && !arr(places.needs).length) bad('/places/needs', 'a shared place says what it needs (gpu, desk, …), so the installer picks a machine that has it');
++
++ const memory = doc.memory;
++ if (memory != null && (!isObj(memory) || !MEMORY.includes(memory.scope))) bad('/memory/scope', `one of ${MEMORY.join(', ')}`);
++
++ const rules = isObj(doc.rules) ? doc.rules : {};
++ if (rules.level != null && !LEVELS.includes(rules.level)) bad('/rules/level', `one of ${LEVELS.join(', ')}`);
++ if (rules.level === 'full' && !(isObj(rules.budget) && (rules.budget.run_usd != null || rules.budget.day_usd != null))) bad('/rules/budget', 'level full needs a budget (run_usd or day_usd)');
++ if (rules.who != null && !(isObj(rules.who) && WHO.includes(rules.who.may))) bad('/rules/who/may', `one of ${WHO.join(', ')}`);
++
++ arr(doc.when).forEach((w, i) => {
++ if (!isObj(w) || !WHEN.includes(w.kind)) return bad(`/when/${i}/kind`, `one of ${WHEN.join(', ')}`);
++ if (w.kind === 'schedule' && !/^\S+(?: \S+){4}$/.test(String(w.cron || ''))) bad(`/when/${i}/cron`, 'a schedule has five cron fields');
++ if (w.kind === 'event' && !/^[a-z][a-z0-9]{0,40}(?:\.[a-z0-9_]{1,60}){1,4}$/.test(String(w.event || ''))) bad(`/when/${i}/event`, 'an event is an OpenVibe.Events type (live.stream.started)');
++ });
++
++ const makes = isObj(doc.makes) ? doc.makes : {};
++ const serve = isObj(makes.serve) ? makes.serve : {};
++ if (serve.mcp === true && !isObj(makes.schema)) bad('/makes/schema', 'served as a tool, it says the shape of its answer');
++ if (serve.mcp === true && !isObj(serve.input)) bad('/makes/serve/input', 'served as a tool, it says the shape of what a caller sends');
++
++ if (doc.team != null) {
++ const team = doc.team;
++ if (!isObj(team) || !/^[a-z][a-z0-9-]{0,60}$/.test(String(team.play || ''))) bad('/team/play', 'a play id');
++ const roles = isObj(team && team.roles) ? Object.entries(team.roles) : [];
++ if (!roles.length) bad('/team/roles', 'a team has roles');
++ for (const [role, r] of roles) {
++ const m = BP_REF.exec(String(r || ''));
++ if (!m) bad(`/team/roles/${role}`, 'a role is another blueprint at a fixed version: bp_…@N');
++ else if (m[1] === doc.id) bad(`/team/roles/${role}`, 'a blueprint cannot be a role in its own team');
++ }
++ }
++ return { ok: errors.length === 0, errors };
++}
++
++/** A copy with every absent part at its safe default: talks only, asks first, keeps nothing, starts when asked. */
++function withDefaults(doc) {
++ const d = copy(doc || {});
++ d.inputs = arr(d.inputs);
++ d.needs = arr(d.needs);
++ d.face = { glyph: 'sparkle', voice: null, ...(d.face || {}) };
++ d.brain = { pick: 'auto', rungs: dispatch.RUNGS.slice(), depth: 'balanced', advisor: null, decisions: false, ...(d.brain || {}) };
++ d.knows = { skills: [], knowledge: [], examples: [], ...(d.knows || {}) };
++ d.hands = { tools: [], search: false, ...(d.hands || {}) };
++ d.places = { order: ['none'], needs: [], ...(d.places || {}) };
++ d.memory = { scope: 'none', keep_days: null, ...(d.memory || {}) };
++ const rules = d.rules || {};
++ d.rules = { level: 'ask', ...rules, budget: { run_usd: 0.25, day_usd: 2, ...(rules.budget || {}) }, who: rules.who || { may: 'owner' } };
++ d.when = arr(d.when);
++ d.makes = { answer: ['text'], schema: null, ...(d.makes || {}) };
++ d.makes.serve = { a2a: false, mcp: false, ...(d.makes.serve || {}) };
++ if (d.team === undefined) d.team = null;
++ return d;
++}
++
++/** A tool's id in words, when no catalogue describes it: ov:live.chat.post → "live: chat post". */
++function toolWords(tool) {
++ const [ns, rest = ''] = String(tool).split(/:(.*)/s);
++ if (ns === 'mcp') return `your own tool server at ${rest.replace(/^https:\/\//, '').split('/')[0]}`;
++ if (ns === 'machine') return { files: 'files on its machine', shell: 'a terminal on its machine', desk: 'a private desk on its machine', browser: 'a browser on its machine', screen: 'the screen of its machine' }[rest] || rest;
++ if (ns === 'robot') return `the robot's ${rest.replace(/[._]/g, ' ')}`;
++ if (ns === 'harness') return rest === 'auto' ? 'a coding agent' : `the coding agent ${rest}`;
++ if (ns === 'agent') return `another agent (${rest})`;
++ const [product, ...parts] = rest.split('.');
++ return `${product}: ${parts.join(' ').replace(/[_-]/g, ' ')}`.trim();
++}
++
++const money = (n) => (n < 1 ? `${Math.round(n * 100)}¢` : `$${Number(n).toFixed(2).replace(/\.00$/, '')}`);
++
++/**
++ * What installing it lets it do, in words, for the install screen and for `agreed` (by key). Each item:
++ * { key, part, words, ask } where ask true means every use asks first. `describe(tool)` may return better words
++ * from a catalogue (the one MCP endpoint's tool list); absent, the id is put in words.
++ */
++function asks(doc, { describe } = {}) {
++ const d = withDefaults(doc);
++ const out = [];
++ const add = (key, part, words, ask = false) => out.push({ key, part, words, ask });
++ for (const t of d.hands.tools) {
++ const w = (describe && describe(t.tool)) || toolWords(t.tool);
++ add(`hands:${t.tool}`, 'hands', t.mode === 'ask' ? `uses ${w} (asks you first each time)` : `uses ${w}`, t.mode === 'ask');
++ }
++ if (d.hands.search) add('hands:search', 'hands', 'finds more OpenVibe tools when it needs them (each asks you first)', true);
++ for (const p of d.places.order) {
++ if (p === 'none') continue;
++ const needs = d.places.needs.length ? ` that has ${d.places.needs.join(', ')}` : '';
++ add(`places:${p}`, 'places', {
++ own: `runs work on a machine of yours${needs}`,
++ shared: `runs work on a machine shared with you${needs} (that machine's grant still applies)`,
++ openvibe: 'runs work on an OpenVibe computer',
++ sandbox: 'runs work in a throwaway sandbox',
++ }[p]);
++ }
++ if (d.memory.scope !== 'none') {
++ const keep = d.memory.keep_days ? ` for ${d.memory.keep_days} days` : '';
++ add(`memory:${d.memory.scope}`, 'memory', { thread: `remembers within a conversation${keep}`, person: `remembers what it learns about your work${keep}`, team: `shares what it remembers with its team${keep}` }[d.memory.scope]);
++ }
++ if (d.rules.level !== 'ask') {
++ add(`rules:level:${d.rules.level}`, 'rules', {
++ 'auto-edits': 'edits and runs its own work without asking',
++ 'approve-for-me': 'answers its own questions for you, within its budget',
++ full: 'acts without asking, within its budget',
++ }[d.rules.level]);
++ }
++ const b = d.rules.budget;
++ add(`rules:budget:${b.run_usd != null ? b.run_usd : '-'}/${b.day_usd != null ? b.day_usd : '-'}`, 'rules',
++ `spends at most ${[b.run_usd != null && `${money(b.run_usd)} a run`, b.day_usd != null && `${money(b.day_usd)} a day`].filter(Boolean).join(' and ') || 'what you allow'}`);
++ if (d.rules.who.may !== 'owner') add(`rules:who:${d.rules.who.may}`, 'rules', { list: 'can be asked by people you list', followers: 'can be asked by your followers', anyone: 'can be asked by anyone where it is' }[d.rules.who.may]);
++ for (const w of d.when) {
++ if (w.kind === 'manual' || w.kind === 'composer') continue;
++ const key = w.kind === 'event' ? `when:event:${w.event}` : `when:${w.kind}`;
++ const words = w.kind === 'schedule' ? `starts on a schedule (${w.cron}${w.tz ? ` ${w.tz}` : ''})`
++ : w.kind === 'event' ? `starts when ${w.event} happens`
++ : `starts from ${w.kind}${w.rule ? `: ${w.rule}` : ''}`;
++ if (!out.some((o) => o.key === key)) add(key, 'when', words);
++ }
++ if (d.makes.serve.a2a) add('makes:a2a', 'makes', 'other agents can ask it (only the people its rules allow)');
++ if (d.makes.serve.mcp) add('makes:mcp', 'makes', 'apps can use it as a tool (only the people its rules allow)');
++ if (d.team) for (const [role, r] of Object.entries(d.team.roles || {})) add(`team:${role}:${r}`, 'team', `works with ${r} as its ${role}`);
++ return out;
++}
++
++/**
++ * What NEW asks for that OLD did not: new tools, a tool from ask to allow, a wider place, more memory, a higher
++ * level, a bigger budget, more people who may ask, a new trigger, being served, a new team member. [] means a
++ * version an install with updates "same-or-less" takes without asking.
++ */
++function wider(oldDoc, newDoc) {
++ const a = withDefaults(oldDoc);
++ const b = withDefaults(newDoc);
++ const out = [];
++ const oldAsks = new Map(asks(a).map((x) => [x.key, x]));
++ const mode = (d) => new Map(d.hands.tools.map((t) => [t.tool, t.mode]));
++ const am = mode(a);
++ for (const x of asks(b)) {
++ if (x.part === 'rules' || x.part === 'memory') continue; // compared by rank below
++ if (oldAsks.has(x.key)) {
++ if (x.part === 'hands' && x.key !== 'hands:search' && am.get(x.key.slice(6)) === 'ask' && !x.ask) out.push({ key: x.key, words: `${x.words} without asking first` });
++ continue;
++ }
++ out.push({ key: x.key, words: x.words });
++ }
++ if (rank(LEVELS, b.rules.level) > rank(LEVELS, a.rules.level)) out.push({ key: `rules:level:${b.rules.level}`, words: asks(b).find((x) => x.key === `rules:level:${b.rules.level}`).words });
++ if (rank(WHO, b.rules.who.may) > rank(WHO, a.rules.who.may)) out.push({ key: `rules:who:${b.rules.who.may}`, words: asks(b).find((x) => x.key === `rules:who:${b.rules.who.may}`).words });
++ if (rank(MEMORY, b.memory.scope) > rank(MEMORY, a.memory.scope) || ((b.memory.keep_days == null) !== (a.memory.keep_days == null) && b.memory.keep_days == null && b.memory.scope !== 'none') || (a.memory.keep_days != null && b.memory.keep_days != null && b.memory.keep_days > a.memory.keep_days)) {
++ out.push({ key: `memory:${b.memory.scope}`, words: (asks(b).find((x) => x.part === 'memory') || { words: 'remembers more' }).words });
++ }
++ const more = (x, y) => (y == null && x != null) || (x != null && y != null && y > x);
++ if (more(a.rules.budget.run_usd, b.rules.budget.run_usd) || more(a.rules.budget.day_usd, b.rules.budget.day_usd)) {
++ const bud = asks(b).find((x) => x.key.startsWith('rules:budget:'));
++ out.push({ key: bud.key, words: bud.words });
++ }
++ return out;
++}
++
++/** The asks of DOC an install has not agreed to (its `agreed` keys). */
++function unagreed(doc, agreed) {
++ const have = new Set(arr(agreed));
++ return asks(doc).filter((x) => !have.has(x.key));
++}
++
++/** Check INPUTS against the declared inputs: required, typed, a choice from its list; defaults filled. */
++function inputValues(doc, inputs) {
++ const errors = [];
++ const values = {};
++ const given = isObj(inputs) ? inputs : {};
++ for (const inp of arr(doc.inputs)) {
++ let v = given[inp.id];
++ if (v === undefined || v === '') v = inp.default;
++ if (v === undefined) { if (inp.required) errors.push({ at: `/inputs/${inp.id}`, why: `${inp.label || inp.id} is needed` }); continue; }
++ if (inp.type === 'number') { v = Number(v); if (!Number.isFinite(v)) errors.push({ at: `/inputs/${inp.id}`, why: 'a number' }); }
++ else if (inp.type === 'boolean') v = v === true || v === 'true';
++ else v = String(v);
++ if (inp.type === 'choice' && !arr(inp.choices).includes(v)) errors.push({ at: `/inputs/${inp.id}`, why: `one of ${arr(inp.choices).join(', ')}` });
++ if (inp.type === 'url' && !/^https:\/\//.test(v)) errors.push({ at: `/inputs/${inp.id}`, why: 'an https address' });
++ if (typeof v === 'string' && v.length > 2000) errors.push({ at: `/inputs/${inp.id}`, why: 'at most 2000 characters' });
++ values[inp.id] = v;
++ }
++ for (const k of Object.keys(given)) if (!arr(doc.inputs).some((i) => i.id === k)) errors.push({ at: `/inputs/${k}`, why: 'not one of its inputs' });
++ return { values, errors };
++}
++
++/** A copy with {{inputs}} replaced. Throws BlueprintError when an input is missing, mistyped or unknown. */
++function fill(doc, inputs) {
++ const { values, errors } = inputValues(doc, inputs);
++ for (const u of usedInputs(doc)) if (!(u.id in values) && !arr(doc.inputs).some((i) => i.id === u.id)) errors.push({ at: u.at, why: `{{${u.id}}} is not one of its inputs` });
++ if (errors.length) throw new BlueprintError(errors);
++ const sub = (s) => (typeof s === 'string' ? s.replace(INPUT_RE, (_, id) => (id in values ? String(values[id]) : '')) : s);
++ const d = copy(doc);
++ if (d.knows) d.knows.instructions = sub(d.knows.instructions);
++ if (d.face && d.face.greeting) d.face.greeting = sub(d.face.greeting);
++ for (const w of arr(d.when)) {
++ if (w.rule) w.rule = sub(w.rule);
++ if (w.where && w.where.id) w.where.id = sub(w.where.id);
++ }
++ return d;
++}
++
++const ref = (doc) => `${doc.id}@${doc.version}`;
++function parseRef(s) {
++ const m = BP_REF.exec(String(s || ''));
++ return m ? { id: m[1], version: Number(m[2]) } : null;
++}
++
++/** The Dispatcher's need for this brain: capabilities and, for a size pick, the tier. */
++function needOf(brain, tools) {
++ const caps = new Set(arr(brain.caps));
++ if (tools.length) caps.add('tools');
++ const need = { caps: [...caps] };
++ if (brain.pick === 'size' && brain.size) need.tier = brain.size;
++ return need;
++}
++
++/**
++ * One run of a blueprint. INSTALL (agents.install@1) supplies the agent, inputs and machines; without one (TRY_IT
++ * or a run of a version once), every tool asks first and the budget is at most 5¢. Returns:
++ * start: agents.run-start@1 (text, agent or blueprint, play, budget, depth, context)
++ * loop: { system, tools: [{ name, mode }], search, budget, schema } what agents/loop needs
++ * dispatch: { pin, rungs, maxPrice, need, advisor, decisions } what agents/dispatch needs
++ * place: { order, needs, machines } where its work may run
++ * memory: { scope, remember, keep_days }
++ * team: the play and its roles, or null
++ */
++function compile(doc, { install = null, inputs, text = '', context, tryIt = false } = {}) {
++ const v = validate(doc);
++ if (!v.ok) throw new BlueprintError(v.errors);
++ const once = tryIt || !install;
++ const d = withDefaults(fill(doc, inputs !== undefined ? inputs : (install && install.inputs) || {}));
++ const tools = d.hands.tools.map((t) => ({ name: t.tool, mode: once ? 'ask' : t.mode }));
++ const budget = {};
++ if (d.rules.budget.run_usd != null) budget.usd = d.rules.budget.run_usd;
++ if (d.rules.budget.run_seconds != null) budget.seconds = d.rules.budget.run_seconds;
++ if (d.rules.budget.run_steps != null) budget.steps = d.rules.budget.run_steps;
++ if (once) budget.usd = Math.min(budget.usd != null ? budget.usd : 0.05, 0.05);
++
++ const lines = [d.knows.instructions.trim()];
++ if (d.face.tone) lines.push(`How you talk: ${d.face.tone}`);
++ if (d.memory.remember && d.memory.scope !== 'none') lines.push(`Worth remembering: ${d.memory.remember}`);
++ lines.push(`Always ask the person first before ${FLOOR_WORDS}, whatever else you are allowed.`);
++
++ const start = { text: String(text || ''), title: d.name };
++ if (install && !tryIt) start.agent = install.agent; else start.blueprint = ref(doc);
++ if (d.team) start.play = d.team.play;
++ if (Object.keys(budget).length) start.budget = budget;
++ if (d.brain.depth) start.depth = d.brain.depth;
++ if (context) start.context = context;
++
++ return {
++ start,
++ loop: { system: lines.join('\n\n'), tools, search: !!d.hands.search, budget, schema: isObj(d.makes.schema) ? d.makes.schema : null },
++ dispatch: {
++ pin: d.brain.pick === 'exact' ? d.brain.model : null,
++ rungs: arr(d.brain.rungs).length ? d.brain.rungs.slice() : dispatch.RUNGS.slice(),
++ maxPrice: d.brain.max_usd_per_m != null ? d.brain.max_usd_per_m : null,
++ need: needOf(d.brain, tools),
++ advisor: d.brain.advisor || null,
++ decisions: !!d.brain.decisions,
++ },
++ place: { order: d.places.order.slice(), needs: d.places.needs.slice(), machines: (install && install.places) || { own: [], shared: [] } },
++ memory: { scope: d.memory.scope, remember: d.memory.remember || null, keep_days: d.memory.keep_days },
++ team: d.team ? copy(d.team) : null,
++ };
++}
++
++/**
++ * What one run costs on the cheapest choice that can do it (Exchange's choices merged with the person's own, as
++ * agents/dispatch takes them). TURNS model calls of TOKENS each (defaults: 4 turns of 2000 in, 800 out). FREE_ON
++ * lists the rungs among the person's choices on which it costs nothing (their machine, a plan, the allowance).
++ */
++function estimate(doc, choices, { turns = 4, tokens = { tokensIn: 2000, tokensOut: 800 } } = {}) {
++ const d = withDefaults(doc);
++ const rungs = new Set(arr(d.brain.rungs).length ? d.brain.rungs : dispatch.RUNGS);
++ const maxPrice = d.brain.max_usd_per_m;
++ const allowed = arr(choices).filter((c) => c && rungs.has(c.rung)
++ && (maxPrice == null || !c.price || ((c.price.in || 0) <= maxPrice && (c.price.out || 0) <= maxPrice)));
++ const need = needOf(d.brain, d.hands.tools);
++ const pin = d.brain.pick === 'exact' ? d.brain.model : null;
++ const p = dispatch.pick(allowed, { need, pin, tokens });
++ if (!p.choice) return { usd_per_run: null, choice: null, free_on: [], why: p.why || 'nothing it may use can do it' };
++ const usd = Math.round(dispatch.estimate(p.choice, tokens) * turns * 1e6) / 1e6;
++ const free = dispatch.ladder(allowed, { need, tokens }).filter((c) => dispatch.estimate(c, tokens) === 0).map((c) => c.rung);
++ return { usd_per_run: usd, choice: p.choice, free_on: [...new Set(free)], why: p.why };
++}
++
++module.exports = {
++ validate, withDefaults, asks, wider, unagreed, fill, compile, estimate, ref, parseRef, toolWords,
++ BlueprintError, FLOOR, LEVELS, PLACES, MEMORY, WHO, WHEN, INPUT_TYPES, TOOL, BP_REF,
++};
ddiff --git a/src/core/index.js b/src/core/index.js
iindex d9901e4..3ebc0b6 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.161.0';
++const SDK_VERSION = '0.162.0';
module.exports = {
createClient,
ddiff --git a/test/agents-blueprint.test.js b/test/agents-blueprint.test.js
nnew file mode 100644
iindex 0000000..ef2dc40
---- /dev/null
++++ b/test/agents-blueprint.test.js
@@@ -0,0 +1,169 @@
++'use strict';
++// openvibe-sdk/agents/blueprint: make your own agent (agent-blueprints.md). The blueprints here are Contracts'
++// agents.blueprint@1 fixtures (Clip finder, Vibe, a tool for other agents).
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const bp = require('../src/agents/blueprint');
++
++const T = '2026-11-12T21:00:00Z';
++const clip = () => ({
++ id: 'bp_01JD8C4QJ7Z3K9W2X5V6N1M0PA', version: 3, state: 'published', name: 'Clip finder',
++ summary: 'Watches your stream\'s chat and speech, and clips the moments people react to.',
++ author: { type: 'user', id: 'usr_01JD8C4QJ7Z3K9W2X5V6N1M0PB' }, visibility: 'public', forked_from: null, licence: 'MIT',
++ inputs: [{ id: 'channel', type: 'channel', label: 'Your channel', required: true }],
++ face: { avatar: 'preset:scissors', hue: 270, voice: null, tone: 'Short and cheerful.' },
++ brain: { pick: 'auto', rungs: ['machine', 'plan', 'key', 'allowance', 'credits'], max_usd_per_m: 1, caps: ['tools'], decisions: true },
++ knows: { instructions: 'You watch {{channel}}\'s stream. Clip what chat reacts to.' },
++ hands: { tools: [
++ { tool: 'ov:live.transcript.read', mode: 'allow' },
++ { tool: 'ov:live.chat.read', mode: 'allow' },
++ { tool: 'ov:media.clip.create', mode: 'allow' },
++ { tool: 'ov:live.chat.post', mode: 'ask' },
++ ], search: false },
++ places: { order: ['none'] },
++ memory: { scope: 'person', remember: 'Which moments the streamer kept.', keep_days: 30 },
++ rules: { level: 'auto-edits', budget: { run_usd: 0.05, day_usd: 0.5 }, who: { may: 'owner' } },
++ when: [{ kind: 'event', event: 'live.stream.started', where: { service: 'live', kind: 'channel', id: '{{channel}}' } }],
++ makes: { answer: ['card:clip'], serve: { a2a: false, mcp: false } },
++ team: null, created_at: T, updated_at: T, published_at: T,
++});
++const install = { agent: 'agt_01JD8C4QJ7Z3K9W2X5V6N1M0PJ', owner: { type: 'user', id: 'usr_01JD8C4QJ7Z3K9W2X5V6N1M0PK' }, blueprint: 'bp_01JD8C4QJ7Z3K9W2X5V6N1M0PA@3', inputs: { channel: 'maticus' }, agreed: [], places: { own: ['dev_desk'], shared: [] }, updates: 'same-or-less' };
++const why = (doc) => bp.validate(doc).errors.map((e) => `${e.at} ${e.why}`).join(' | ');
++
++run([
++ ['a whole blueprint is valid; the rules a schema cannot say are kept', () => {
++ assert.equal(bp.validate(clip()).ok, true, why(clip()));
++ const cases = [
++ [(d) => { d.rules = { level: 'full' }; }, '/rules/budget'],
++ [(d) => { d.hands.tools.push({ tool: 'shell:rm', mode: 'allow' }); }, '/hands/tools/4/tool'],
++ [(d) => { d.knows.instructions = 'Post in {{room}}'; }, '/knows/instructions'],
++ [(d) => { d.hands.tools.push({ tool: 'machine:shell', mode: 'ask' }); }, '/places/order'],
++ [(d) => { d.hands.tools.push({ tool: 'robot:drive', mode: 'ask' }); d.places = { order: ['own'] }; }, '/places/needs'],
++ [(d) => { d.places = { order: ['shared'] }; }, '/places/needs'],
++ [(d) => { d.places = { order: ['desk-pc'] }; }, '/places/order'],
++ [(d) => { d.makes.serve = { mcp: true }; }, '/makes/schema'],
++ [(d) => { d.team = { play: 'grader-loop', roles: { doer: `${d.id}@2` } }; }, '/team/roles/doer'],
++ [(d) => { d.team = { play: 'grader-loop', roles: { doer: 'bp_other' } }; }, '/team/roles/doer'],
++ [(d) => { delete d.published_at; }, '/published_at'],
++ [(d) => { d.when = [{ kind: 'schedule' }]; }, '/when/0/cron'],
++ [(d) => { d.brain = { pick: 'exact' }; }, '/brain/model'],
++ ];
++ for (const [mut, at] of cases) {
++ const d = clip(); mut(d);
++ const v = bp.validate(d);
++ assert.equal(v.ok, false, `${at} should be refused`);
++ assert.ok(v.errors.some((e) => e.at === at), `${at}: got ${why(d)}`);
++ }
++ }],
++ ['a blueprint carries no secret: anything that looks like a key or a token is refused where it is', () => {
++ for (const s of ['sk-proj-abcdefghijklmnop1234', 'ghp_' + 'a'.repeat(36), 'AKIA' + 'B'.repeat(16), '-----BEGIN OPENSSH PRIVATE KEY-----']) {
++ const d = clip(); d.knows.instructions = `Use ${s} to sign in.`;
++ const v = bp.validate(d);
++ assert.ok(v.errors.some((e) => e.at === '/knows/instructions' && /secret/.test(e.why)), s.slice(0, 6));
++ }
++ const d = clip(); d.hands.tools.push({ tool: 'mcp:https://tools.example.org/mcp', mode: 'ask', note: 'Bearer ' + 'x'.repeat(8) });
++ assert.equal(bp.validate(d).ok, true, 'a plain note is fine');
++ }],
++ ['defaults are the safe ones: talks only, asks first, keeps nothing, owner only, a small budget', () => {
++ const d = bp.withDefaults({ id: 'bp_tiny', version: 1, state: 'draft', name: 'Tiny', summary: 'x', author: { type: 'user', id: 'usr_1' }, visibility: 'private', knows: { instructions: 'Hi' } });
++ assert.deepEqual(d.places.order, ['none']);
++ assert.equal(d.rules.level, 'ask');
++ assert.equal(d.memory.scope, 'none');
++ assert.deepEqual(d.rules.who, { may: 'owner' });
++ assert.deepEqual(d.rules.budget, { run_usd: 0.25, day_usd: 2 });
++ assert.deepEqual(d.makes.serve, { a2a: false, mcp: false });
++ assert.equal(d.brain.pick, 'auto');
++ }],
++ ['asks: the install screen in words, each with a key for agreed', () => {
++ const a = bp.asks(clip());
++ const byKey = Object.fromEntries(a.map((x) => [x.key, x]));
++ assert.equal(byKey['hands:ov:live.chat.post'].words, 'uses live: chat post (asks you first each time)');
++ assert.equal(byKey['hands:ov:live.chat.post'].ask, true);
++ assert.equal(byKey['hands:ov:media.clip.create'].words, 'uses media: clip create');
++ assert.equal(byKey['memory:person'].words, 'remembers what it learns about your work for 30 days');
++ assert.equal(byKey['rules:level:auto-edits'].words, 'edits and runs its own work without asking');
++ assert.equal(byKey['rules:budget:0.05/0.5'].words, 'spends at most 5¢ a run and 50¢ a day');
++ assert.equal(byKey['when:event:live.stream.started'].words, 'starts when live.stream.started happens');
++ const nicer = bp.asks(clip(), { describe: (t) => (t === 'ov:live.chat.post' ? 'your stream chat (posting)' : null) });
++ assert.equal(nicer.find((x) => x.key === 'hands:ov:live.chat.post').words, 'uses your stream chat (posting) (asks you first each time)');
++ for (const x of a) assert.match(x.key, /^[a-z]+:[^\s]{1,1900}$/, 'agreed keys fit agents.install@1');
++ assert.deepEqual(bp.unagreed(clip(), a.map((x) => x.key)), []);
++ assert.deepEqual(bp.unagreed(clip(), a.slice(1).map((x) => x.key)).map((x) => x.key), [a[0].key]);
++ }],
++ ['wider: only a version that asks for more waits for a yes', () => {
++ assert.deepEqual(bp.wider(clip(), clip()), []);
++ const fewer = clip(); fewer.hands.tools.pop(); fewer.rules.budget.day_usd = 0.2; fewer.memory.keep_days = 7;
++ assert.deepEqual(bp.wider(clip(), fewer), [], 'less is not wider');
++ const keys = (mut) => { const d = clip(); mut(d); return bp.wider(clip(), d).map((x) => x.key); };
++ assert.deepEqual(keys((d) => { d.hands.tools.push({ tool: 'ov:community.post.create', mode: 'ask' }); }), ['hands:ov:community.post.create']);
++ assert.deepEqual(keys((d) => { d.hands.tools[3].mode = 'allow'; }), ['hands:ov:live.chat.post']);
++ assert.match(bp.wider(clip(), Object.assign(clip(), { hands: { tools: clip().hands.tools.map((t) => ({ ...t, mode: 'allow' })) } }))[0].words, /without asking first$/);
++ assert.deepEqual(keys((d) => { d.rules.level = 'full'; }), ['rules:level:full']);
++ assert.deepEqual(keys((d) => { d.rules.who = { may: 'anyone' }; }), ['rules:who:anyone']);
++ assert.deepEqual(keys((d) => { d.rules.budget.day_usd = 5; }), ['rules:budget:0.05/5']);
++ assert.deepEqual(keys((d) => { d.memory.keep_days = null; }), ['memory:person']);
++ assert.deepEqual(keys((d) => { d.places = { order: ['none', 'own'], needs: ['gpu'] }; }), ['places:own']);
++ assert.deepEqual(keys((d) => { d.when.push({ kind: 'schedule', cron: '0 9 * * *' }); }), ['when:schedule']);
++ assert.deepEqual(keys((d) => { d.makes.serve.a2a = true; }), ['makes:a2a']);
++ }],
++ ['fill: inputs typed, checked and put in place', () => {
++ const d = bp.fill(clip(), { channel: 'maticus' });
++ assert.equal(d.knows.instructions, 'You watch maticus\'s stream. Clip what chat reacts to.');
++ assert.equal(d.when[0].where.id, 'maticus');
++ assert.throws(() => bp.fill(clip(), {}), (e) => e instanceof bp.BlueprintError && e.errors[0].why === 'Your channel is needed');
++ assert.throws(() => bp.fill(clip(), { channel: 'x', city: 'Paris' }), /not one of its inputs/);
++ const c = clip(); c.inputs.push({ id: 'style', type: 'choice', label: 'Style', choices: ['short', 'full'], default: 'short' });
++ c.knows.instructions += ' Names are {{style}}.';
++ assert.match(bp.fill(c, { channel: 'm' }).knows.instructions, /Names are short\.$/);
++ assert.throws(() => bp.fill(c, { channel: 'm', style: 'long' }), /one of short, full/);
++ }],
++ ['compile: an installed agent runs with its own modes; a Try it run asks for every tool and spends at most 5¢', () => {
++ const r = bp.compile(clip(), { install, text: 'Find tonight\'s moments' });
++ assert.equal(r.start.agent, install.agent);
++ assert.equal(r.start.blueprint, undefined);
++ assert.deepEqual(r.start.budget, { usd: 0.05 });
++ assert.deepEqual(r.loop.tools.map((t) => t.mode), ['allow', 'allow', 'allow', 'ask']);
++ assert.match(r.loop.system, /^You watch maticus's stream/);
++ assert.match(r.loop.system, /How you talk: Short and cheerful\./);
++ assert.match(r.loop.system, /Always ask the person first before spending money, a password, deleting anything, a new recipient, and posting in your name/);
++ assert.deepEqual(r.dispatch.need, { caps: ['tools'] });
++ assert.equal(r.dispatch.maxPrice, 1);
++ assert.deepEqual(r.place.machines, { own: ['dev_desk'], shared: [] });
++ assert.equal(r.memory.scope, 'person');
++
++ const t = bp.compile(clip(), { inputs: { channel: 'maticus' }, text: 'Try', tryIt: true });
++ assert.equal(t.start.blueprint, 'bp_01JD8C4QJ7Z3K9W2X5V6N1M0PA@3');
++ assert.equal(t.start.agent, undefined);
++ assert.ok(t.loop.tools.every((x) => x.mode === 'ask'));
++ const big = clip(); big.rules.budget.run_usd = 3;
++ assert.deepEqual(bp.compile(big, { inputs: { channel: 'm' }, tryIt: true }).start.budget, { usd: 0.05 });
++ const bad = clip(); bad.rules = { level: 'full' };
++ assert.throws(() => bp.compile(bad, { install }), bp.BlueprintError);
++ }],
++ ['estimate: the cheapest choice it may use, the price of a run, where it is free', () => {
++ const C = [
++ { id: 'credits:big', rung: 'credits', tier: 'strong', caps: ['tools', 'vision'], price: { in: 3, out: 15 } },
++ { id: 'credits:cheap', rung: 'credits', tier: 'medium', caps: ['tools', 'json'], price: { in: 0.28, out: 0.42 } },
++ { id: 'machine:qwen', rung: 'machine', tier: 'small', caps: ['tools'] },
++ ];
++ const e = bp.estimate(clip(), C);
++ assert.equal(e.choice.id, 'machine:qwen');
++ assert.equal(e.usd_per_run, 0);
++ assert.deepEqual(e.free_on, ['machine']);
++ const noMachine = clip(); noMachine.brain.rungs = ['credits'];
++ const e2 = bp.estimate(noMachine, C);
++ assert.equal(e2.choice.id, 'credits:cheap', 'credits:big costs more per million than it may pay');
++ assert.equal(e2.usd_per_run, Math.round(((0.28 * 2000 + 0.42 * 800) / 1e6) * 4 * 1e6) / 1e6);
++ const exact = clip(); exact.brain = { pick: 'exact', model: 'credits:big' };
++ assert.equal(bp.estimate(exact, C).choice.id, 'credits:big');
++ const none = clip(); none.brain.rungs = ['plan'];
++ assert.equal(bp.estimate(none, C).usd_per_run, null);
++ }],
++ ['refs: a version\'s address, and back', () => {
++ assert.equal(bp.ref(clip()), 'bp_01JD8C4QJ7Z3K9W2X5V6N1M0PA@3');
++ assert.deepEqual(bp.parseRef('bp_vibe@12'), { id: 'bp_vibe', version: 12 });
++ assert.equal(bp.parseRef('bp_vibe'), null);
++ assert.equal(bp.toolWords('machine:desk'), 'a private desk on its machine');
++ assert.equal(bp.toolWords('mcp:https://tools.example.org/mcp'), 'your own tool server at tools.example.org');
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 91a3f8f..4e3f406 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, 115); // 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, 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.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-blueprint.d.ts b/types/agents-blueprint.d.ts
nnew file mode 100644
iindex 0000000..48ea917
---- /dev/null
++++ b/types/agents-blueprint.d.ts
@@@ -0,0 +1,71 @@
++// openvibe-sdk/agents/blueprint: make your own agent (agent-blueprints.md), agents.blueprint@1 as pure functions.
++import type { Choice, Rung, Tier } from './agents-dispatch';
++
++export type Level = 'ask' | 'auto-edits' | 'approve-for-me' | 'full';
++export type PlaceKind = 'none' | 'own' | 'shared' | 'openvibe' | 'sandbox';
++export type MemoryScope = 'none' | 'thread' | 'person' | 'team';
++export type Who = 'owner' | 'list' | 'followers' | 'anyone';
++export type WhenKind = 'manual' | 'chat' | 'feed' | 'comments' | 'git' | 'composer' | 'notifications' | 'mail' | 'schedule' | 'goal' | 'event' | 'webhook' | 'machine';
++export type InputType = 'text' | 'number' | 'boolean' | 'choice' | 'channel' | 'room' | 'repository' | 'machine' | 'url';
++
++export interface BlueprintInput { id: string; type: InputType; label: string; help?: string; required?: boolean; default?: string | number | boolean; choices?: string[]; }
++export interface Blueprint {
++ id: string;
++ version: number;
++ state: 'draft' | 'published' | 'retired';
++ name: string;
++ summary: string;
++ author: { type: 'user' | 'service'; id: string };
++ visibility: 'private' | 'link' | 'public';
++ forked_from?: string | null;
++ licence?: string;
++ tags?: string[];
++ inputs?: BlueprintInput[];
++ needs?: Array<{ kind: 'machine' | 'key' | 'plan' | 'app' | 'robot'; what: string; why?: string }>;
++ face?: { avatar?: string; glyph?: string; hue?: number; voice?: string | null; tone?: string; greeting?: string; elements?: string[] };
++ brain?: { pick?: 'auto' | 'size' | 'exact'; size?: Tier; model?: string; rungs?: Rung[]; max_usd_per_m?: number; caps?: string[]; depth?: 'quick' | 'balanced' | 'deep'; advisor?: { size?: 'medium' | 'strong'; consults: number } | null; decisions?: boolean };
++ knows: { instructions: string; skills?: string[]; knowledge?: Array<{ kind: 'file' | 'media' | 'wiki' | 'search' | 'repository' | 'url'; ref: string; note?: string }>; examples?: Array<{ ask: string; good: string }> };
++ hands?: { tools?: Array<{ tool: string; mode: 'allow' | 'ask'; note?: string }>; search?: boolean };
++ places?: { order?: PlaceKind[]; needs?: string[] };
++ memory?: { scope: MemoryScope; remember?: string; keep_days?: number | null };
++ rules?: { level?: Level; budget?: { run_usd?: number; day_usd?: number; run_seconds?: number; run_steps?: number }; who?: { may: Who } };
++ when?: Array<{ kind: WhenKind; rule?: string; where?: { service: string; kind?: string; id?: string }; event?: string; cron?: string; tz?: string }>;
++ makes?: { answer?: string[]; schema?: object | null; serve?: { a2a?: boolean; mcp?: boolean; input?: object | null } };
++ team?: { play: string; roles: Record<string, string> } | null;
++ created_at?: string;
++ updated_at?: string;
++ published_at?: string | null;
++}
++export interface Install { agent: string; owner: { type: 'user'; id: string }; blueprint: string; inputs?: Record<string, string | number | boolean>; agreed: string[]; places?: { own?: string[]; shared?: string[] }; updates: 'ask' | 'same-or-less'; pending?: string | null; installed_at?: string; updated_at?: string; }
++export interface Problem { at: string; why: string; }
++export interface Ask { key: string; part: string; words: string; ask: boolean; }
++
++export class BlueprintError extends Error { errors: Problem[]; }
++export function validate(doc: unknown): { ok: boolean; errors: Problem[] };
++export function withDefaults(doc: Blueprint): Required<Blueprint>;
++export function asks(doc: Blueprint, options?: { describe?: (tool: string) => string | null | undefined }): Ask[];
++export function wider(oldDoc: Blueprint, newDoc: Blueprint): Array<{ key: string; words: string }>;
++export function unagreed(doc: Blueprint, agreed: string[]): Ask[];
++export function fill(doc: Blueprint, inputs?: Record<string, unknown>): Blueprint;
++export interface Compiled {
++ start: { text: string; title: string; agent?: string; blueprint?: string; play?: string; budget?: { usd?: number; seconds?: number; steps?: number }; depth?: string; context?: object };
++ loop: { system: string; tools: Array<{ name: string; mode: 'allow' | 'ask' }>; search: boolean; budget: { usd?: number; seconds?: number; steps?: number }; schema: object | null };
++ dispatch: { pin: string | null; rungs: Rung[]; maxPrice: number | null; need: { caps: string[]; tier?: Tier }; advisor: { size?: string; consults: number } | null; decisions: boolean };
++ place: { order: PlaceKind[]; needs: string[]; machines: { own?: string[]; shared?: string[] } };
++ memory: { scope: MemoryScope; remember: string | null; keep_days: number | null };
++ team: { play: string; roles: Record<string, string> } | null;
++}
++export function compile(doc: Blueprint, options?: { install?: Install | null; inputs?: Record<string, unknown>; text?: string; context?: object; tryIt?: boolean }): Compiled;
++export function estimate(doc: Blueprint, choices: Choice[], options?: { turns?: number; tokens?: { tokensIn?: number; tokensOut?: number } }): { usd_per_run: number | null; choice: (Choice & { why: string }) | null; free_on: Rung[]; why: string };
++export function ref(doc: { id: string; version: number }): string;
++export function parseRef(s: string): { id: string; version: number } | null;
++export function toolWords(tool: string): string;
++export const FLOOR: string[];
++export const LEVELS: Level[];
++export const PLACES: PlaceKind[];
++export const MEMORY: MemoryScope[];
++export const WHO: Who[];
++export const WHEN: WhenKind[];
++export const INPUT_TYPES: InputType[];
++export const TOOL: RegExp;
++export const BP_REF: RegExp;