openvibers/openvibe.actor Commits

Actor: make your own agent — blueprints, versions, installs (#463)

OP OpenVibers committed 8 h ago · f81f4e754e · 11 files, +600 −17

* Actor: make your own agent — blueprints, versions, installs

Blueprints (agents.blueprint@1): drafts checked against the schema and openvibe-sdk/agents/blueprint's rules,
published versions that never change, mine and the public gallery, remixes that credit their source. Installs
(agents.install@1): the install screen in words, the person's own agent once every ask is agreed, versions that
ask for no more taken by installs set to same-or-less, wider ones pending until agreed. Migration 0071; pins
contracts v0.419.0, sdk v0.162.0.

* Blueprints: export and delete with the person; contracts v0.419.1 lists the routes

* Merge main (contracts v0.419.0 pin) into agent-blueprints; keep v0.419.1 and sdk v0.162.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6206537..2a6f9d0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,6 +1,27 @@
# Changelog
++## Unreleased — make your own agent (blueprints)
++
++- **Blueprints** (`agents.blueprint@1`, Contracts 0.419.0): a person's agent as one document of ten parts.
++ - `POST /api/v1/blueprints` makes a draft, checked against the schema and `openvibe-sdk/agents/blueprint`'s rules;
++ a blueprint carries no secret.
++ - `PUT …/:id/draft` edits it, and `POST …/:id/publish` freezes it as version N. A published version never
++ changes.
++ - `GET /api/v1/blueprints` lists mine (`?scope=public` lists the gallery), and `GET …/:id?version=N` reads one
++ version.
++ - `POST …/:id/remix` makes a private copy that credits its source, only when the author named a licence.
++- **Installs** (`agents.install@1`):
++ - `GET …/:id/asks` is the install screen in words.
++ - `POST …/:id/install` makes the person's own agent once every ask is agreed and the inputs are filled. Its
++ instructions carry the inputs and the floor in words.
++ - When a version is published, an install set to `same-or-less` takes it if it asks for no more. Any other install
++ keeps it `pending`.
++ - `POST /api/v1/agents/:id/install/update` takes the new version once its new asks are agreed.
++- Migration 0071 adds the blueprint, version and install tables and lets an agent's instructions run to 20,000
++ characters.
++- Pins: openvibe-contracts v0.419.1, openvibe-sdk v0.162.0.
++
## Unreleased — Stop, not E-STOP
- The robot tools and page copy say Stop; `list_my_robots` no longer reads a latch field.
ddiff --git a/README.md b/README.md
iindex 62f2767..f12dc5d 100644
---- a/README.md
++++ b/README.md
@@@ -265,7 +265,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.419.0
--- openvibe-sdk: v0.161.0
++- openvibe-contracts: v0.419.1
++- openvibe-sdk: v0.162.0
- openvibe-shared: v4.229.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 36a2d65..e5b9a16 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,12 +7,12 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"domain": "openvibe.actor",
-- "contracts": "openvibe-contracts v0.419.0",
-- "sdk": "openvibe-sdk v0.161.0",
++ "contracts": "openvibe-contracts v0.419.1",
++ "sdk": "openvibe-sdk v0.162.0",
"shared": "openvibe-shared v4.229.0",
"packages": [
-- "openvibe-contracts v0.419.0",
-- "openvibe-sdk v0.161.0",
++ "openvibe-contracts v0.419.1",
++ "openvibe-sdk v0.162.0",
"openvibe-shared v4.229.0"
],
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
ddiff --git a/migrations/0071_agent_blueprints.sql b/migrations/0071_agent_blueprints.sql
nnew file mode 100644
iindex 0000000..a085572
---- /dev/null
++++ b/migrations/0071_agent_blueprints.sql
@@@ -0,0 +1,50 @@
++-- phase: expand
++-- Make your own agent (agent-blueprints.md, Contracts 0.419.0): a blueprint is an agent anyone can read, change, share
++-- and run, as one document of ten parts. A blueprint row holds its draft (the next version) and who may see it; each
++-- published version is frozen in agent_blueprint_versions; an install is a person's own agent (actor_agents) made from
++-- one version, with the inputs they filled, the asks they agreed to and the machines they chose. Keys and grants are the
++-- person's own and are never copied here.
++CREATE TABLE IF NOT EXISTS agent_blueprints (
++ id text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ visibility text COLLATE "C" NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'link', 'public')),
++ latest integer CHECK (latest IS NULL OR latest >= 1),
++ draft jsonb,
++ forked_from text COLLATE "C",
++ installs integer NOT NULL DEFAULT 0,
++ remixes integer NOT NULL DEFAULT 0,
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ deleted_at text COLLATE "C"
++);
++CREATE INDEX IF NOT EXISTS agent_blueprints_owner ON agent_blueprints (owner, updated_at DESC);
++CREATE INDEX IF NOT EXISTS agent_blueprints_public ON agent_blueprints (updated_at DESC) WHERE visibility = 'public' AND deleted_at IS NULL AND latest IS NOT NULL;
++
++CREATE TABLE IF NOT EXISTS agent_blueprint_versions (
++ blueprint_id text COLLATE "C" NOT NULL REFERENCES agent_blueprints (id) ON DELETE CASCADE,
++ version integer NOT NULL CHECK (version >= 1),
++ doc jsonb NOT NULL,
++ state text COLLATE "C" NOT NULL DEFAULT 'published' CHECK (state IN ('published', 'retired')),
++ published_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (blueprint_id, version)
++);
++
++CREATE TABLE IF NOT EXISTS agent_installs (
++ agent_id text COLLATE "C" PRIMARY KEY REFERENCES actor_agents (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL,
++ blueprint_id text COLLATE "C" NOT NULL,
++ version integer NOT NULL CHECK (version >= 1),
++ inputs jsonb NOT NULL DEFAULT '{}'::jsonb,
++ agreed jsonb NOT NULL DEFAULT '[]'::jsonb,
++ places jsonb NOT NULL DEFAULT '{}'::jsonb,
++ updates text COLLATE "C" NOT NULL DEFAULT 'ask' CHECK (updates IN ('ask', 'same-or-less')),
++ pending integer CHECK (pending IS NULL OR pending >= 1),
++ installed_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
++CREATE INDEX IF NOT EXISTS agent_installs_blueprint ON agent_installs (blueprint_id, version);
++
++-- A blueprint's instructions may be up to 20,000 characters (agents.blueprint@1); an agent installed from one keeps
++-- them whole. Relaxing the check is backward compatible: every row that passed 4,000 passes 20,000.
++ALTER TABLE actor_agents DROP CONSTRAINT IF EXISTS actor_agents_instructions_check;
++ALTER TABLE actor_agents ADD CONSTRAINT actor_agents_instructions_check CHECK (char_length(instructions) BETWEEN 1 AND 20000);
ddiff --git a/package-lock.json b/package-lock.json
iindex 3a70fad..516377e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,8 +15,8 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0",
"pg": "^8.23.0",
"sharp": "^0.34.5"
@@@ -1712,9 +1712,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.419.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
-- "integrity": "sha512-VxrTdeHchT8gvl2wWC9ASaId92en7ZKfJ8F/0hKzCAD0f2Y00rPrqgBWF9BNPoLGARoyQvsMbhBEhSkhxGNnjA==",
++ "version": "0.419.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.1",
++ "integrity": "sha512-iSJ63vTpvOOHBo4eU+MGC91SFsUUroolrnO+4loG31KP0XEbcn4Dpt+9S4Pqo/Y5DrQIA6X1Hckom6Z6/vKTcw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1728,9 +1728,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.161.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
-- "integrity": "sha512-qReF7K9ct7LCLDJKuguwUQ30nc0kmrp+jlRZcFm4adpEvKL8r5G6i6Osbs0ROAMLmXKwnbI4GiZAgAyQ7oN7Ig==",
++ "version": "0.162.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0",
++ "integrity": "sha512-LQC+6X21smbLzoYIpk0K7f1fjewi9KRk7x/qNoX/89cGg5cHGNLW9gd/VcQWZ1O8KuzDG4IOwWctSZ5CStMttQ==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex e18c099..2de01e4 100644
---- a/package.json
++++ b/package.json
@@@ -28,8 +28,8 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0",
"pg": "^8.23.0",
"sharp": "^0.34.5"
ddiff --git a/server/agents/blueprints.js b/server/agents/blueprints.js
nnew file mode 100644
iindex 0000000..cc98977
---- /dev/null
++++ b/server/agents/blueprints.js
@@@ -0,0 +1,312 @@
++'use strict';
++
++/**
++ * Make your own agent (agent-blueprints.md; Contracts 0.419.0 agents.blueprint@1 and agents.install@1). A blueprint is
++ * an agent as one document of ten parts; Actor stores them and the agents made from them. The rules live in
++ * openvibe-sdk/agents/blueprint (validate, asks, wider, fill, compile), so a browser, Link and Actor agree on them.
++ *
++ * create(s, owner, doc) a new blueprint with doc as its draft (version 1)
++ * list(s, owner, { scope }) mine (drafts too) or public (published only), newest first
++ * get(s, viewer, id, { version }) the version asked for, the latest published, or the owner's draft
++ * saveDraft(s, owner, id, doc) the next version, editable until it is published
++ * publish(s, owner, id) freezes the draft as version N (published versions never change)
++ * remix(s, owner, id, { version }) a private copy of a version, with forked_from; the source counts it
++ * asks(s, viewer, id, { version }) the install screen: what installing it lets it do, in words
++ * install(s, config, owner, id, body) a person's own agent (actor_agents) from one version, with agents.install@1
++ * update(s, config, owner, agentId, b) moves an installed agent to another version; a wider one needs every new ask agreed
++ * wire(row, version) / installWire(row) the shapes the API answers with
++ *
++ * Who sees what: the owner sees their own blueprints and drafts; a link blueprint's published versions are readable by
++ * anyone signed in who has its id; a public one is also listed. Nothing else is there (404, never 403).
++ * A blueprint carries no secret and grants nothing: installing records the asks the person agreed to; a version that
++ * asks for more waits as `pending` until they agree to the new asks.
++ */
++const contracts = require('openvibe-contracts');
++const bp = require('openvibe-sdk/agents/blueprint');
++const contacts = require('./contacts');
++
++const ID = /^bp_[A-Za-z0-9_-]{3,40}$/;
++const OWNER = /^(user:usr_[0-9A-HJKMNP-TV-Z]{26}|service:actor)$/;
++const MAX_DRAFT_BYTES = 200 * 1024;
++
++class BlueprintError extends Error {
++ constructor(status, code, message, errors) {
++ super(message);
++ this.status = status;
++ this.code = code;
++ if (errors) this.errors = errors;
++ }
++}
++const fail = (status, code, message, errors) => { throw new BlueprintError(status, code, message, errors); };
++const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
++const subjectOf = (owner) => {
++ const [type, id] = String(owner).split(':');
++ return type === 'user' ? { type: 'user', id } : { type: 'service', id: id || 'actor' };
++};
++
++/** The shape and every rule: Contracts' schema, then the SDK's rules a schema cannot say. */
++function check(doc) {
++ const errors = [];
++ const v = contracts.validate('agents.blueprint@1', doc);
++ for (const e of (v && v.errors) || []) errors.push({ at: e.instancePath || e.path || '/', why: e.message || String(e) });
++ const r = bp.validate(doc);
++ for (const e of r.errors) if (!errors.some((x) => x.at === e.at)) errors.push(e);
++ return errors;
++}
++
++/** A draft as the person sent it, with what the store owns put in: id, version, state, author, dates. */
++function asDraft(row, input, version) {
++ if (!input || typeof input !== 'object' || Array.isArray(input)) fail(422, 'actor.blueprint.invalid', 'A blueprint is an object.');
++ if (Buffer.byteLength(JSON.stringify(input)) > MAX_DRAFT_BYTES) fail(413, 'actor.blueprint.too_large', 'A blueprint is at most 200 KB.');
++ const now = new Date().toISOString();
++ const doc = { ...input, id: row.id, version, state: 'draft', author: subjectOf(row.owner), visibility: input.visibility || row.visibility || 'private' };
++ delete doc.published_at;
++ doc.forked_from = row.forked_from || null;
++ doc.created_at = row.created_at || now;
++ doc.updated_at = now;
++ return doc;
++}
++
++function rowOut(row) {
++ return row && { ...row, draft: parse(row.draft) };
++}
++
++async function rowFor(s, id) {
++ if (!ID.test(String(id || ''))) return null;
++ return rowOut(await s.db.maybe('SELECT * FROM agent_blueprints WHERE id = $1 AND deleted_at IS NULL', [id]));
++}
++
++async function versionOf(s, id, version) {
++ const v = await s.db.maybe('SELECT * FROM agent_blueprint_versions WHERE blueprint_id = $1 AND version = $2', [id, version]);
++ return v ? { ...v, doc: parse(v.doc) } : null;
++}
++
++/** What `viewer` may read of a blueprint: its draft and every version (owner), or its published ones (link, public). */
++async function readable(s, viewer, id) {
++ const row = await rowFor(s, id);
++ if (!row) return null;
++ const own = row.owner === viewer;
++ if (!own && (row.visibility === 'private' || row.latest == null)) return null;
++ return { row, own };
++}
++
++async function create(s, owner, input) {
++ if (!OWNER.test(String(owner || ''))) fail(403, 'actor.blueprint.forbidden', 'Sign in to make an agent.');
++ const now = new Date().toISOString();
++ const id = s.newId('bp');
++ const vis = ['private', 'link', 'public'].includes(input && input.visibility) ? input.visibility : 'private';
++ const row = { id, owner, visibility: vis, forked_from: null, created_at: now };
++ const doc = asDraft(row, input, 1);
++ const errors = check(doc);
++ if (errors.length) fail(422, 'actor.blueprint.invalid', errors.map((e) => `${e.at}: ${e.why}`).join('; '), errors);
++ await s.db.query(`INSERT INTO agent_blueprints (id, owner, visibility, latest, draft, forked_from, created_at, updated_at)
++ VALUES ($1, $2, $3, NULL, $4::jsonb, NULL, $5, $5)`, [id, owner, vis, JSON.stringify(doc), now]);
++ return rowFor(s, id);
++}
++
++async function list(s, viewer, { scope = 'mine', limit = 50 } = {}) {
++ const n = Math.max(1, Math.min(100, Number(limit) || 50));
++ if (scope === 'public') {
++ return s.db.many(`SELECT * FROM agent_blueprints WHERE visibility = 'public' AND deleted_at IS NULL AND latest IS NOT NULL
++ ORDER BY updated_at DESC, id DESC LIMIT $1`, [n]).then((rows) => rows.map(rowOut));
++ }
++ return s.db.many('SELECT * FROM agent_blueprints WHERE owner = $1 AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC LIMIT $2', [viewer, n]).then((rows) => rows.map(rowOut));
++}
++
++/** The doc a viewer asked for: an exact published version, else the latest published, else (owner only) the draft. */
++async function get(s, viewer, id, { version = null, draft = false } = {}) {
++ const r = await readable(s, viewer, id);
++ if (!r) return null;
++ if (draft || (r.row.latest == null && r.own && version == null)) {
++ if (!r.own || !r.row.draft) return null;
++ return { row: r.row, doc: r.row.draft, own: true };
++ }
++ const n = version != null ? Number(version) : r.row.latest;
++ if (!Number.isInteger(n) || n < 1) return null;
++ const v = await versionOf(s, id, n);
++ return v ? { row: r.row, doc: v.doc, own: r.own, state: v.state } : null;
++}
++
++async function saveDraft(s, owner, id, input) {
++ const row = await rowFor(s, id);
++ if (!row || row.owner !== owner) return null;
++ const next = (row.latest || 0) + 1;
++ const doc = asDraft(row, input, next);
++ const errors = check(doc);
++ if (errors.length) fail(422, 'actor.blueprint.invalid', errors.map((e) => `${e.at}: ${e.why}`).join('; '), errors);
++ const vis = doc.visibility;
++ await s.db.query('UPDATE agent_blueprints SET draft = $2::jsonb, visibility = $3, updated_at = $4 WHERE id = $1', [id, JSON.stringify(doc), vis, doc.updated_at]);
++ return rowFor(s, id);
++}
++
++async function publish(s, owner, id) {
++ const row = await rowFor(s, id);
++ if (!row || row.owner !== owner) return null;
++ if (!row.draft) fail(409, 'actor.blueprint.no_draft', 'There is nothing new to publish.');
++ const version = (row.latest || 0) + 1;
++ const now = new Date().toISOString();
++ const doc = { ...row.draft, version, state: 'published', published_at: now, updated_at: now };
++ const errors = check(doc);
++ if (errors.length) fail(422, 'actor.blueprint.invalid', errors.map((e) => `${e.at}: ${e.why}`).join('; '), errors);
++ await s.db.query(`INSERT INTO agent_blueprint_versions (blueprint_id, version, doc, state, published_at) VALUES ($1, $2, $3::jsonb, 'published', $4)`,
++ [id, version, JSON.stringify(doc), now]);
++ await s.db.query('UPDATE agent_blueprints SET latest = $2, draft = NULL, updated_at = $3 WHERE id = $1', [id, version, now]);
++ // Installs set to take what asks for no more move now; the others wait with the new version pending.
++ const installs = await s.db.many('SELECT * FROM agent_installs WHERE blueprint_id = $1', [id]);
++ for (const inst of installs) {
++ const from = await versionOf(s, id, inst.version);
++ const wider = from ? bp.wider(from.doc, doc) : [];
++ if (inst.updates === 'same-or-less' && !wider.length) {
++ await s.db.query('UPDATE agent_installs SET version = $2, pending = NULL, updated_at = $3 WHERE agent_id = $1', [inst.agent_id, version, now]);
++ await applyToAgent(s, inst.owner, inst.agent_id, doc, parse(inst.inputs) || {});
++ } else {
++ await s.db.query('UPDATE agent_installs SET pending = $2, updated_at = $3 WHERE agent_id = $1', [inst.agent_id, version, now]);
++ }
++ }
++ return { row: await rowFor(s, id), doc };
++}
++
++async function remix(s, owner, id, { version = null } = {}) {
++ const src = await get(s, owner, id, { version });
++ if (!src) return null;
++ if (!src.own && !src.doc.licence) fail(403, 'actor.blueprint.no_licence', 'Its author has not allowed remixes (it names no licence).');
++ const from = `${id}@${src.doc.version}`;
++ const copy = { ...src.doc, visibility: 'private', name: src.own ? `${src.doc.name} (copy)`.slice(0, 60) : src.doc.name };
++ for (const k of ['id', 'version', 'state', 'author', 'published_at', 'created_at', 'updated_at', 'forked_from']) delete copy[k];
++ const now = new Date().toISOString();
++ const nid = s.newId('bp');
++ const row = { id: nid, owner, visibility: 'private', forked_from: src.doc.state === 'draft' ? null : from, created_at: now };
++ const doc = asDraft(row, copy, 1);
++ const errors = check(doc);
++ if (errors.length) fail(422, 'actor.blueprint.invalid', errors.map((e) => `${e.at}: ${e.why}`).join('; '), errors);
++ await s.db.query(`INSERT INTO agent_blueprints (id, owner, visibility, latest, draft, forked_from, created_at, updated_at)
++ VALUES ($1, $2, 'private', NULL, $3::jsonb, $4, $5, $5)`, [nid, owner, JSON.stringify(doc), row.forked_from, now]);
++ if (!src.own) await s.db.query('UPDATE agent_blueprints SET remixes = remixes + 1 WHERE id = $1', [id]);
++ return rowFor(s, nid);
++}
++
++async function asks(s, viewer, id, { version = null } = {}) {
++ const got = await get(s, viewer, id, { version });
++ if (!got) return null;
++ return { blueprint: `${id}@${got.doc.version}`, asks: bp.asks(got.doc), floor: bp.FLOOR };
++}
++
++/** The contact fields an agent made from a blueprint carries: its name and face, model, computer, apps, level. */
++function contactOf(doc, inputs) {
++ const d = bp.withDefaults(bp.fill(doc, inputs));
++ const brain = d.brain;
++ const model = brain.pick === 'exact' && brain.model
++ ? { engine: 'runtime', model: String(brain.model).slice(0, 120) }
++ : { engine: 'runtime', model: brain.pick === 'size' ? ({ small: 'fast', medium: 'smart', strong: 'best' }[brain.size] || 'smart') : 'smart' };
++ const order = d.places.order;
++ const needs = d.places.needs;
++ let computer = null;
++ if (order.includes('own') || order.includes('shared')) computer = { kind: 'your-computer', placement: 'link' };
++ else if (order.includes('openvibe')) computer = { kind: needs.includes('gpu') ? 'gpu-desktop' : needs.includes('desk') ? 'desktop' : 'kept-browser', placement: 'auto' };
++ else if (order.includes('sandbox')) computer = { kind: 'throwaway', placement: 'auto' };
++ const apps = [...new Set(d.hands.tools.map((t) => /^connect:([a-z0-9][a-z0-9-]{0,60})/.exec(t.tool)).filter(Boolean).map((m) => `connect.${m[1]}`))].slice(0, 50);
++ const face = d.face.avatar || `preset:${d.face.glyph || 'agent'}`;
++ return {
++ name: d.name, face: String(face).slice(0, 500), voice: d.face.voice || null, model, computer, apps, memory: null,
++ level: d.rules.level, instructions: bp.compile(doc, { inputs, install: { agent: 'agt_pending', inputs, places: {} } }).loop.system.slice(0, 20000),
++ };
++}
++
++async function applyToAgent(s, owner, agentId, doc, inputs) {
++ const c = contactOf(doc, inputs);
++ await s.db.query(`UPDATE actor_agents SET name = $3, face = $4, voice = $5, model_engine = $6, model_id = $7, computer = $8::jsonb,
++ apps = $9::jsonb, level = $10, instructions = $11, updated_at = $12 WHERE id = $1 AND owner = $2`,
++ [agentId, owner, c.name, c.face, c.voice, c.model.engine, c.model.model, c.computer ? JSON.stringify(c.computer) : null,
++ JSON.stringify(c.apps), c.level, c.instructions, new Date().toISOString()]);
++}
++
++/**
++ * A person's own agent from one published version. BODY (agents.install@1's inputs): { version?, inputs, agreed,
++ * places, updates }. Every ask the install screen showed must be in `agreed`; the inputs are typed and checked.
++ */
++async function install(s, config, owner, id, body = {}) {
++ const got = await get(s, owner, id, { version: body.version != null ? body.version : null });
++ if (!got || got.doc.state === 'draft') return null;
++ if (got.state === 'retired') fail(409, 'actor.blueprint.retired', 'That version takes no new installs.');
++ const inputs = body.inputs && typeof body.inputs === 'object' && !Array.isArray(body.inputs) ? body.inputs : {};
++ try { bp.fill(got.doc, inputs); } catch (e) { fail(422, 'actor.blueprint.inputs', e.message, e.errors); }
++ const agreed = Array.isArray(body.agreed) ? body.agreed.map(String) : [];
++ const missing = bp.unagreed(got.doc, agreed);
++ if (missing.length) fail(422, 'actor.blueprint.not_agreed', `Agree to what it asks first: ${missing.map((m) => m.words).join('; ')}.`, missing.map((m) => ({ at: m.key, why: m.words })));
++ const updates = body.updates === 'same-or-less' ? 'same-or-less' : 'ask';
++ const places = body.places && typeof body.places === 'object' ? {
++ own: Array.isArray(body.places.own) ? body.places.own.map(String).slice(0, 20) : [],
++ shared: Array.isArray(body.places.shared) ? body.places.shared.map(String).slice(0, 20) : [],
++ } : { own: [], shared: [] };
++ const fields = contactOf(got.doc, inputs);
++ const instructions = fields.instructions;
++ delete fields.instructions;
++ const made = await contacts.create(s, config, owner, { ...fields, instructions }, { fromBlueprint: true });
++ if (!made.row) fail(made.limit ? 429 : 422, made.limit ? 'actor.agent.limit' : 'actor.agent.invalid', made.error || 'The agent could not be made.');
++ const now = new Date().toISOString();
++ const keys = bp.asks(got.doc).map((a) => a.key).filter((k) => agreed.includes(k));
++ await s.db.query(`INSERT INTO agent_installs (agent_id, owner, blueprint_id, version, inputs, agreed, places, updates, pending, installed_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8, NULL, $9, $9)`,
++ [made.row.id, owner, id, got.doc.version, JSON.stringify(inputs), JSON.stringify(keys), JSON.stringify(places), updates, now]);
++ if (!got.own) await s.db.query('UPDATE agent_blueprints SET installs = installs + 1 WHERE id = $1', [id]);
++ return { agent: made.row, install: await installOf(s, owner, made.row.id) };
++}
++
++async function installOf(s, owner, agentId) {
++ return s.db.maybe('SELECT * FROM agent_installs WHERE agent_id = $1 AND owner = $2', [agentId, owner]);
++}
++
++/** Move an installed agent to VERSION (default: its pending one). A version that asks for more needs those asks agreed. */
++async function update(s, config, owner, agentId, body = {}) {
++ const inst = await installOf(s, owner, agentId);
++ if (!inst) return null;
++ const target = body.version != null ? Number(body.version) : inst.pending;
++ if (!Number.isInteger(target)) fail(409, 'actor.blueprint.no_update', 'There is no newer version to take.');
++ const from = await versionOf(s, inst.blueprint_id, inst.version);
++ const to = await versionOf(s, inst.blueprint_id, target);
++ if (!to) fail(404, 'actor.blueprint.not_found', 'No such version.');
++ const agreed = new Set([...(parse(inst.agreed) || []), ...(Array.isArray(body.agreed) ? body.agreed.map(String) : [])]);
++ const wider = from ? bp.wider(from.doc, to.doc) : bp.asks(to.doc);
++ const missing = wider.filter((w) => !agreed.has(w.key));
++ if (missing.length) fail(422, 'actor.blueprint.not_agreed', `This version asks for more: ${missing.map((m) => m.words).join('; ')}.`, missing.map((m) => ({ at: m.key, why: m.words })));
++ const inputs = parse(inst.inputs) || {};
++ try { bp.fill(to.doc, inputs); } catch (e) { fail(422, 'actor.blueprint.inputs', e.message, e.errors); }
++ const now = new Date().toISOString();
++ const keys = bp.asks(to.doc).map((a) => a.key).filter((k) => agreed.has(k) || !wider.some((w) => w.key === k));
++ await s.db.query('UPDATE agent_installs SET version = $2, pending = NULL, agreed = $3::jsonb, updated_at = $4 WHERE agent_id = $1',
++ [agentId, target, JSON.stringify(keys), now]);
++ await applyToAgent(s, owner, agentId, to.doc, inputs);
++ return installOf(s, owner, agentId);
++}
++
++function wire(row, doc) {
++ return {
++ id: row.id,
++ visibility: row.visibility,
++ latest: row.latest == null ? null : Number(row.latest),
++ has_draft: !!row.draft,
++ forked_from: row.forked_from || null,
++ installs: Number(row.installs) || 0,
++ remixes: Number(row.remixes) || 0,
++ updated_at: row.updated_at,
++ ...(doc ? { blueprint: doc } : {}),
++ };
++}
++
++function installWire(inst) {
++ if (!inst) return null;
++ return {
++ agent: inst.agent_id,
++ owner: subjectOf(inst.owner),
++ blueprint: `${inst.blueprint_id}@${inst.version}`,
++ inputs: parse(inst.inputs) || {},
++ agreed: parse(inst.agreed) || [],
++ places: parse(inst.places) || { own: [], shared: [] },
++ updates: inst.updates,
++ pending: inst.pending == null ? null : `${inst.blueprint_id}@${inst.pending}`,
++ installed_at: inst.installed_at,
++ updated_at: inst.updated_at,
++ };
++}
++
++module.exports = { create, list, get, saveDraft, publish, remix, asks, install, update, installOf, wire, installWire, contactOf, BlueprintError };
ddiff --git a/server/agents/contacts.js b/server/agents/contacts.js
iindex d539daf..82fe94f 100644
---- a/server/agents/contacts.js
++++ b/server/agents/contacts.js
@@@ -142,7 +142,9 @@ async function create(s, config, owner, input, extra = null) {
const invalid = validate(next);
if (invalid) return { error: invalid };
const instructions = body.instructions != null ? body.instructions : (base.instructions || `You are ${next.name}, one of this person's agents.`);
-- if (typeof instructions !== 'string' || !instructions.trim() || instructions.length > 4000) return { error: 'instructions must be 1–4000 characters.' };
++ // An agent made from a blueprint keeps its instructions whole (agents.blueprint@1 allows 20,000; migration 0071).
++ const maxInstructions = extra && extra.fromBlueprint ? 20000 : 4000;
++ if (typeof instructions !== 'string' || !instructions.trim() || instructions.length > maxInstructions) return { error: `instructions must be 1–${maxInstructions} characters.` };
// Vibe comes first even when the first thing a person does is create another contact.
await ensureVibe(s, config, owner);
const count = Number(await s.db.value('SELECT count(*) FROM actor_agents WHERE owner = $1', [owner]));
ddiff --git a/server/http/api.js b/server/http/api.js
iindex d8d6fce..9533ae8 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -39,6 +39,7 @@ const { findArtifact, sendArtifact } = require('../runtime/artifacts');
const trace = require('../runtime/trace');
const personal = require('../agents/personal');
const contacts = require('../agents/contacts');
++const blueprints = require('../agents/blueprints');
const voice = require('../agents/voice');
const memoryStore = require('../memory');
const approvals = require('../agents/approvals');
@@@ -293,6 +294,71 @@ function createApi(ctx) {
res.json({ id: out.row.id, state: out.row.state });
});
++ // ── Make your own agent: blueprints (agents.blueprint@1, agents.install@1; 2026-11-12) ──
++ // A person's blueprints, the public gallery's, one version, the draft, publish, remix, the install screen, install,
++ // and moving an installed agent to another version. Everything is the person's own session or an app they allowed
++ // (actor.agent.manage); a blueprint they may not see is 404.
++ const bpFail = (req, res, e) => {
++ if (e instanceof blueprints.BlueprintError) return problem(req, res, e.status, e.code, e.message, e.errors ? { errors: e.errors.slice(0, 50) } : undefined);
++ throw e;
++ };
++ const bpRoute = (fn) => async (req, res) => { try { await fn(req, res); } catch (e) { bpFail(req, res, e); } };
++ const bpVersion = (req) => (req.query.version != null && /^[1-9][0-9]{0,5}$/.test(String(req.query.version)) ? Number(req.query.version) : null);
++ r.get('/blueprints', principal.requireCapability('actor.agent.manage'), personOnly, limits.reads('actor.agent.manage'), bpRoute(async (req, res) => {
++ const scope = req.query.scope === 'public' ? 'public' : 'mine';
++ const rows = await blueprints.list(s, req.principal.requester, { scope, limit: req.query.limit });
++ res.set('Cache-Control', 'private, no-store');
++ res.json({ scope, blueprints: rows.map((row) => blueprints.wire(row)) });
++ }));
++ r.post('/blueprints', principal.requireCapability('actor.agent.manage'), personOnly, limits.budget('actor.agent.manage'), bpRoute(async (req, res) => {
++ const row = await blueprints.create(s, req.principal.requester, req.body || {});
++ res.set('Location', `/api/v1/blueprints/${row.id}`);
++ res.status(201).json(blueprints.wire(row, row.draft));
++ }));
++ r.get('/blueprints/:id', principal.requireCapability('actor.agent.manage'), personOnly, limits.reads('actor.agent.manage'), bpRoute(async (req, res) => {
++ const got = await blueprints.get(s, req.principal.requester, req.params.id, { version: bpVersion(req), draft: req.query.draft === '1' });
++ if (!got) return problem(req, res, 404, 'actor.blueprint.not_found', 'No such blueprint.');
++ res.set('Cache-Control', 'private, no-store');
++ res.json(blueprints.wire(got.row, got.doc));
++ }));
++ r.put('/blueprints/:id/draft', principal.requireCapability('actor.agent.manage'), personOnly, limits.budget('actor.agent.manage'), bpRoute(async (req, res) => {
++ const row = await blueprints.saveDraft(s, req.principal.requester, req.params.id, req.body || {});
++ if (!row) return problem(req, res, 404, 'actor.blueprint.not_found', 'No such blueprint of yours.');
++ res.json(blueprints.wire(row, row.draft));
++ }));
++ r.post('/blueprints/:id/publish', principal.requireCapability('actor.agent.manage'), personOnly, limits.budget('actor.agent.manage'), bpRoute(async (req, res) => {
++ const out = await blueprints.publish(s, req.principal.requester, req.params.id);
++ if (!out) return problem(req, res, 404, 'actor.blueprint.not_found', 'No such blueprint of yours.');
++ res.json(blueprints.wire(out.row, out.doc));
++ }));
++ r.post('/blueprints/:id/remix', principal.requireCapability('actor.agent.manage'), personOnly, limits.budget('actor.agent.manage'), bpRoute(async (req, res) => {
++ const row = await blueprints.remix(s, req.principal.requester, req.params.id, { version: bpVersion(req) });
++ if (!row) return problem(req, res, 404, 'actor.blueprint.not_found', 'No such blueprint.');
++ res.set('Location', `/api/v1/blueprints/${row.id}`);
++ res.status(201).json(blueprints.wire(row, row.draft));
++ }));
++ r.get('/blueprints/:id/asks', principal.requireCapability('actor.agent.manage'), personOnly, limits.reads('actor.agent.manage'), bpRoute(async (req, res) => {
++ const out = await blueprints.asks(s, req.principal.requester, req.params.id, { version: bpVersion(req) });
++ if (!out) return problem(req, res, 404, 'actor.blueprint.not_found', 'No such blueprint.');
++ res.json(out);
++ }));
++ r.post('/blueprints/:id/install', principal.requireCapability('actor.agent.manage'), personOnly, limits.budget('actor.agent.manage'), bpRoute(async (req, res) => {
++ const out = await blueprints.install(s, config, req.principal.requester, req.params.id, req.body || {});
++ if (!out) return problem(req, res, 404, 'actor.blueprint.not_found', 'No such published blueprint.');
++ res.set('Location', `/api/v1/agents/${out.agent.id}`);
++ res.status(201).json({ agent: contacts.wire(out.agent), install: blueprints.installWire(out.install) });
++ }));
++ r.get('/agents/:id/install', principal.requireCapability('actor.agent.manage'), personOnly, limits.reads('actor.agent.manage'), bpRoute(async (req, res) => {
++ const inst = await blueprints.installOf(s, req.principal.requester, req.params.id);
++ if (!inst) return problem(req, res, 404, 'actor.install.not_found', 'That agent was not made from a blueprint.');
++ res.json(blueprints.installWire(inst));
++ }));
++ r.post('/agents/:id/install/update', principal.requireCapability('actor.agent.manage'), personOnly, limits.budget('actor.agent.manage'), bpRoute(async (req, res) => {
++ const inst = await blueprints.update(s, config, req.principal.requester, req.params.id, req.body || {});
++ if (!inst) return problem(req, res, 404, 'actor.install.not_found', 'That agent was not made from a blueprint.');
++ res.json(blueprints.installWire(inst));
++ }));
++
// ── Agents as contacts (A-1) ─────────────────────────────
// The person's own agents: list (Vibe first, created on this first read), read, create (optionally from a preset),
// change, delete (Vibe refuses 409 actor.agent.vibe) and set the level (actor.level-change@1).
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex e5e5c0d..878fe4f 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -116,6 +116,11 @@ const TABLES = [
{ table: 'actor_fabric_observe', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-observe.json', erase: 'delete' },
{ table: 'actor_fabric_lane_changes', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-lane-changes.json', erase: 'delete' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
++ // The agents they made as blueprints (agents.blueprint@1, migration 0071): their drafts and how they are shared. The
++ // published versions go with the blueprint (ON DELETE CASCADE); an install of someone else's keeps its agent.
++ { table: 'agent_blueprints', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-blueprints.json', erase: 'delete' },
++ // The agents they installed from a blueprint (agents.install@1): the version, the inputs and the asks they agreed to.
++ { table: 'agent_installs', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-installs.json', erase: 'delete' },
// The voices they shared (agents/voice-shares.js): the public link, the document it holds and who shared it.
// The document is their own text, and the link is theirs to stop sharing.
{ table: 'actor_shared_voices', subject: 'owner', value: (usr) => `user:${usr}`, file: 'shared-voices.json',
ddiff --git a/test/agent-blueprints.test.js b/test/agent-blueprints.test.js
nnew file mode 100644
iindex 0000000..6f20cab
---- /dev/null
++++ b/test/agent-blueprints.test.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * Make your own agent (agent-blueprints.md; agents.blueprint@1, agents.install@1): a person makes a blueprint as a
++ * draft, publishes versions that never change, shares it by link or in public, and another person installs it as their
++ * own agent after agreeing to every ask; a version that asks for more waits until they agree; a remix is a private copy
++ * that credits its source. Nothing of a private blueprint is visible to anyone else (404).
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const CLIP = {
++ name: 'Clip finder', summary: 'Watches your stream\'s chat and clips the moments people react to.', visibility: 'public', licence: 'MIT',
++ inputs: [{ id: 'channel', type: 'channel', label: 'Your channel', required: true }],
++ face: { glyph: 'scissors', hue: 270, voice: null, tone: 'Short and cheerful.' },
++ brain: { pick: 'size', size: 'small', caps: ['tools'] },
++ knows: { instructions: 'You watch {{channel}}\'s stream. Clip what chat reacts to.' },
++ hands: { tools: [{ tool: 'ov:live.chat.read', mode: 'allow' }, { tool: 'ov:media.clip.create', mode: 'allow' }, { tool: 'ov:live.chat.post', mode: 'ask' }] },
++ places: { order: ['none'] },
++ memory: { scope: 'person', 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'] },
++};
++
++(async () => {
++ const t = await boot();
++ try {
++ const ada = t.network.addUser('bp-ada');
++ const sam = t.network.addUser('bp-sam');
++ const call = (method, path, json, as = ada) => t.get(path, { as, method, json, headers: SAME });
++ let id;
++
++ await check('a draft: made, checked against agents.blueprint@1, and the store owns its id, version and author', async () => {
++ const bad = await call('POST', '/api/v1/blueprints', { ...CLIP, knows: { instructions: 'Post in {{room}}' } });
++ assert.strictEqual(bad.status, 422, bad.text);
++ assert.ok(bad.json().errors.some((e) => e.at === '/knows/instructions'), bad.text);
++ const secret = await call('POST', '/api/v1/blueprints', { ...CLIP, knows: { instructions: `Use sk-proj-${'a'.repeat(24)} for {{channel}}` } });
++ assert.strictEqual(secret.status, 422, 'a blueprint carries no secret');
++ const r = await call('POST', '/api/v1/blueprints', CLIP);
++ assert.strictEqual(r.status, 201, r.text);
++ const body = r.json();
++ id = body.id;
++ assert.match(id, /^bp_/);
++ assert.deepStrictEqual([body.latest, body.has_draft, body.blueprint.version, body.blueprint.state], [null, true, 1, 'draft']);
++ assert.deepStrictEqual(body.blueprint.author, { type: 'user', id: ada.subject.replace(/^user:/, '') || body.blueprint.author.id });
++ assert.deepStrictEqual(contracts.validate('agents.blueprint@1', body.blueprint).errors, []);
++ });
++
++ await check('nobody else sees a draft, and nobody can install one', async () => {
++ assert.strictEqual((await call('GET', `/api/v1/blueprints/${id}`, undefined, sam)).status, 404);
++ assert.strictEqual((await call('POST', `/api/v1/blueprints/${id}/install`, { inputs: { channel: 'x' }, agreed: [] }, sam)).status, 404);
++ assert.strictEqual((await call('PUT', `/api/v1/blueprints/${id}/draft`, CLIP, sam)).status, 404, 'only its owner edits it');
++ });
++
++ await check('publishing freezes version 1; the gallery lists it; its install screen is in words', async () => {
++ const p = await call('POST', `/api/v1/blueprints/${id}/publish`);
++ assert.strictEqual(p.status, 200, p.text);
++ assert.deepStrictEqual([p.json().latest, p.json().blueprint.state, p.json().has_draft], [1, 'published', false]);
++ assert.deepStrictEqual(contracts.validate('agents.blueprint@1', p.json().blueprint).errors, []);
++ assert.strictEqual((await call('POST', `/api/v1/blueprints/${id}/publish`)).status, 409, 'nothing new to publish');
++ const gallery = (await call('GET', '/api/v1/blueprints?scope=public', undefined, sam)).json();
++ assert.ok(gallery.blueprints.some((b) => b.id === id), 'public: listed for anyone signed in');
++ const asks = (await call('GET', `/api/v1/blueprints/${id}/asks`, undefined, sam)).json();
++ assert.strictEqual(asks.blueprint, `${id}@1`);
++ assert.ok(asks.asks.some((a) => a.key === 'hands:ov:live.chat.post' && a.ask === true));
++ assert.ok(asks.floor.includes('money'));
++ });
++
++ let agent;
++ await check('installing: every ask agreed and the inputs filled make the person\'s own agent (agents.install@1)', async () => {
++ const asks = (await call('GET', `/api/v1/blueprints/${id}/asks`, undefined, sam)).json().asks.map((a) => a.key);
++ const partly = await call('POST', `/api/v1/blueprints/${id}/install`, { inputs: { channel: 'samlive' }, agreed: asks.slice(1) }, sam);
++ assert.strictEqual(partly.status, 422, 'every ask is agreed first');
++ assert.strictEqual(partly.json().code, 'actor.blueprint.not_agreed');
++ const noInput = await call('POST', `/api/v1/blueprints/${id}/install`, { agreed: asks }, sam);
++ assert.strictEqual(noInput.status, 422, 'its inputs are filled');
++ const r = await call('POST', `/api/v1/blueprints/${id}/install`, { inputs: { channel: 'samlive' }, agreed: asks, updates: 'same-or-less' }, sam);
++ assert.strictEqual(r.status, 201, r.text);
++ agent = r.json().agent;
++ const install = r.json().install;
++ assert.deepStrictEqual(contracts.validate('agents.install@1', install).errors, []);
++ assert.deepStrictEqual([install.blueprint, install.updates, install.pending], [`${id}@1`, 'same-or-less', null]);
++ assert.deepStrictEqual([agent.name, agent.level, agent.model.model], ['Clip finder', 'auto-edits', 'fast']);
++ const row = await t.ctx.s.db.maybe('SELECT instructions, owner FROM actor_agents WHERE id = $1', [agent.id]);
++ assert.match(row.instructions, /^You watch samlive's stream/, 'the inputs are filled in');
++ assert.match(row.instructions, /Always ask the person first before spending money/, 'the floor, said in words');
++ assert.strictEqual(row.owner, sam.subject.startsWith('user:') ? sam.subject : `user:${sam.subject}`);
++ assert.strictEqual((await call('GET', `/api/v1/blueprints/${id}`)).json().installs, 1, 'the author sees it counted');
++ });
++
++ await check('a version that asks for no more moves an install set to take it; one that asks for more waits', async () => {
++ const less = { ...CLIP, rules: { ...CLIP.rules, budget: { run_usd: 0.04, day_usd: 0.5 } } };
++ assert.strictEqual((await call('PUT', `/api/v1/blueprints/${id}/draft`, less)).status, 200);
++ assert.strictEqual((await call('POST', `/api/v1/blueprints/${id}/publish`)).json().latest, 2);
++ let inst = (await call('GET', `/api/v1/agents/${agent.id}/install`, undefined, sam)).json();
++ assert.deepStrictEqual([inst.blueprint, inst.pending], [`${id}@2`, null], 'less is taken without asking');
++
++ const more = { ...less, hands: { tools: [...CLIP.hands.tools, { tool: 'ov:community.post.create', mode: 'ask' }] } };
++ assert.strictEqual((await call('PUT', `/api/v1/blueprints/${id}/draft`, more)).status, 200);
++ assert.strictEqual((await call('POST', `/api/v1/blueprints/${id}/publish`)).json().latest, 3);
++ inst = (await call('GET', `/api/v1/agents/${agent.id}/install`, undefined, sam)).json();
++ assert.deepStrictEqual([inst.blueprint, inst.pending], [`${id}@2`, `${id}@3`], 'more waits for a yes');
++ const no = await call('POST', `/api/v1/agents/${agent.id}/install/update`, {}, sam);
++ assert.strictEqual(no.status, 422, 'not without agreeing to what is new');
++ assert.match(no.json().detail, /community: post create/);
++ const yes = await call('POST', `/api/v1/agents/${agent.id}/install/update`, { agreed: ['hands:ov:community.post.create'] }, sam);
++ assert.strictEqual(yes.status, 200, yes.text);
++ assert.deepStrictEqual([yes.json().blueprint, yes.json().pending], [`${id}@3`, null]);
++ assert.ok(yes.json().agreed.includes('hands:ov:community.post.create'));
++ assert.strictEqual((await call('GET', `/api/v1/blueprints/${id}?version=1`, undefined, sam)).json().blueprint.version, 1, 'old versions stay readable');
++ });
++
++ await check('a remix is a private copy that credits its source; a private blueprint stays unseen', async () => {
++ const r = await call('POST', `/api/v1/blueprints/${id}/remix?version=2`, {}, sam);
++ assert.strictEqual(r.status, 201, r.text);
++ const copy = r.json();
++ assert.deepStrictEqual([copy.visibility, copy.forked_from, copy.blueprint.forked_from, copy.blueprint.author.type], ['private', `${id}@2`, `${id}@2`, 'user']);
++ assert.strictEqual((await call('GET', `/api/v1/blueprints/${copy.id}`)).status, 404, 'Sam\'s private copy is not Ada\'s to see');
++ assert.strictEqual((await call('GET', `/api/v1/blueprints/${id}`)).json().remixes, 1);
++ const mine = (await call('GET', '/api/v1/blueprints', undefined, sam)).json().blueprints.map((b) => b.id);
++ assert.ok(mine.includes(copy.id) && !mine.includes(id), 'mine is mine');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });