openvibers/openvibe.sdk Commits
openvibe-sdk 0.162.0: agents/blueprint, make your own agent (#246)
Changes
@@@ -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@@@ -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/tagsAfter 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)) |@@@ -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",@@@ -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,@@@ -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",nnew file mode 100644@@@ -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;@@@ -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"@@@ -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",@@@ -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',nnew file mode 100644@@@ -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,++};@@@ -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,nnew file mode 100644@@@ -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');++ }],++]);@@@ -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 });nnew file mode 100644@@@ -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;
@@ -1,5 +1,27 @@
@@ -1,5 +1,27 @@
# Changelog
# 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
## 0.161.0 — 2026-11-12
- **`openvibe-sdk/cam`: `capture(options)`** opens a camera through the site's own shared capture core (Shared's
- **`openvibe-sdk/cam`: `capture(options)`** opens a camera through the site's own shared capture core (Shared's
@@ -8,12 +8,12 @@
@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
```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.
> 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.
**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.
**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
@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
After v0.97.1 is tagged:
```bash
```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).
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
@@ -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/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/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/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/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/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)) |
| `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)) |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -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
// 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.
// (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 */
/* eslint-disable */
@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -657,6 +657,29 @@
@@ -657,6 +657,29 @@
"createRunApi": "function",
"createRunApi": "function",
"startInput": "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": {
"openvibe-sdk/agents/dispatch": {
"EVIDENCE": "array",
"EVIDENCE": "array",
"RUNGS": "array",
"RUNGS": "array",
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.161.0",
+ "version": "0.162.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.161.0",
+ "version": "0.162.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -324,6 +324,11 @@
@@ -324,6 +324,11 @@
"import": "./esm/agents-dispatch.mjs",
"import": "./esm/agents-dispatch.mjs",
"default": "./src/agents/dispatch.js"
"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": {
"./agents/api": {
"types": "./types/agents-api.d.ts",
"types": "./types/agents-api.d.ts",
"import": "./esm/agents-api.mjs",
"import": "./esm/agents-api.mjs",
@@ -80,6 +80,7 @@ const ENTRIES = {
@@ -80,6 +80,7 @@ const ENTRIES = {
'agents-loop.mjs': '../src/agents/loop.js',
'agents-loop.mjs': '../src/agents/loop.js',
'agents-dispatch.mjs': '../src/agents/dispatch.js',
'agents-dispatch.mjs': '../src/agents/dispatch.js',
'agents-api.mjs': '../src/agents/api.js',
'agents-api.mjs': '../src/agents/api.js',
+ 'agents-blueprint.mjs': '../src/agents/blueprint.js',
'tools.mjs': '../src/tools.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
'geo.mjs': '../src/geo.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,441 @@
@@ -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,
+};
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -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');
+ }],
+]);
@@ -72,7 +72,7 @@ run([
@@ -72,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
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.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');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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;