openvibers/openvibe.actor Commits
An agent's voice as a file: export, import, borrow (#330)
Changes
@@@ -100,6 +100,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- Tasks, their event logs and daily spend (`migrations/0001_tasks.sql`). Tasks are kept 30 days, then pruned with their events. Nothing stored is a credential, a key or a token.- Request telemetry (`migrations/0010_telemetry.sql`): each HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` builds the collector (`createHttpTelemetry`) and mounts its middleware; `server/index.js` flushes it every minute and on stop, and runs the 14-day retention.- The agent catalog and its rate cards; the routing policy over them (placement itself is `openvibe-sdk/placement`).++- An agent's voice as a file (`actor.voice@1`, [server/agents/voice.js](server/agents/voice.js)): its name, a short bio, the house voice it speaks in, its rules, its level, and the model and apps it prefers (ids, never a key). The document's integrity hash shows the file was not edited after it was written and says nothing about who wrote it, so the author in a file is only a claim. A shared voice ([server/agents/voice-shares.js](server/agents/voice-shares.js), `migrations/0040_agent_shared_voices.sql`) is the public link `/voices/<id>`: the owner may stop sharing it, the document is served as JSON at `/voices/<id>.json`, and Actor's own record of who shared it is the only place a voice's author is vouched for.- The home page (`server/http/pages.js`, `public/css/home.css`, `test/home.test.js`) is a taste and a door, about five phone screens, built from the shared kit (`kit.css`, `room.css`, `showcase.css`): the task box, numbers read from the catalog, the running config and OpenVibe.Tools' catalog (the runtime's own cached copy, waited for at most a second) as one `kit.stats` strip, task templates as a rail of cards that fill the task box (`/?task=…&mode=…`, kept by no shared cache; signed out, the task waits for sign-in; what a robot, computer, desk or private task needs is said in the box), the agents as rows that open `/agents`, the parts read once as folds (how a task finds its agent, the comparison, what works today, the Tools tools the runtime can run, the API samples; a link to `#tools` or `#api` opens its fold), the questions people ask first and the rest of OpenVibe as a launcher, with JSON-LD WebSite, WebApplication, WebPage, ItemList (the agents) and FAQPage (exactly the questions shown).## Does not ownnnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- An agent's voice as a file (actor.voice@1): the parts of an agent that make it the same voice in somebody++-- else's hands — a short bio, the rules it keeps, and where its voice came from (the author's subject and the++-- link it was imported from). Never memory, never a connector's token, never a file: a voice is a voice.++ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS bio text;++ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS rules jsonb NOT NULL DEFAULT '[]'::jsonb;++ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_author text;++ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_author_name text;++ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_source text;nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- A voice shared by its owner: the public link (/voices/<id>) that lets somebody else borrow it. Actor's own record++-- of who shared it — the only place a voice's author is vouched for, because the author in a file is only a claim.++CREATE TABLE IF NOT EXISTS actor_shared_voices (++ id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ owner_name text,++ agent_id text COLLATE "C" NOT NULL,++ voice jsonb NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ revoked_at timestamptz++);++CREATE INDEX IF NOT EXISTS actor_shared_voices_owner_idx ON actor_shared_voices (owner, created_at DESC);++-- Whether the author of an imported voice may be vouched for: Actor's own record of a shared voice says yes; a file++-- says only what it claims, and the page says so.++ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_author_checked boolean NOT NULL DEFAULT false;++-- The apps the voice an agent was imported from used: never connected for it (an app binds to the borrower's own++-- connections), shown on its page so the person adds the ones they want.++ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_suggested_apps jsonb NOT NULL DEFAULT '[]'::jsonb;@@@ -115,7 +115,11 @@ async function list(s, config, owner) {const get = (s, owner, id) => (ID.test(String(id || '')) ? s.db.maybe('SELECT * FROM actor_agents WHERE id = $1 AND owner = $2', [id, owner]) : null);--async function create(s, config, owner, input) {++/**++ * `extra` carries the voice parts of an agent (its bio, its rules and where its voice came from). They are not++ * part of the public create body (actor.agent-manage-request@1 does not know them): only the voice import sets them.++ */++async function create(s, config, owner, input, extra = null) {const body = input && typeof input === 'object' && !Array.isArray(input) ? input : {};if (Object.keys(body).some((k) => !ALLOWED.includes(k))) return { error: 'Unknown agent setting.' };const p = body.preset != null ? preset(body.preset) : null;@@@ -146,11 +150,16 @@ async function create(s, config, owner, input) {const now = s.iso();const row = await s.db.maybe(`INSERT INTO actor_agents(id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, status, created_at, updated_at,-- face, voice, model_engine, model_id, computer, apps, memory_scope, level, is_vibe, preset)-- VALUES ($1,$2,$3,$4,'balanced',$5,1,'off','active',$6,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,false,$15) RETURNING *`,++ face, voice, model_engine, model_id, computer, apps, memory_scope, level, is_vibe, preset, bio, rules, voice_author, voice_author_name, voice_author_checked, voice_source, voice_suggested_apps)++ VALUES ($1,$2,$3,$4,'balanced',$5,1,'off','active',$6,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,false,$15,$16,$17::jsonb,$18,$19,$20,$21,$22::jsonb) RETURNING *`,[s.newId('agt'), owner, next.name, instructions, Math.max(0.000001, Number(config.allowance.perTaskUsd) || 0.05), now,next.face, next.voice, next.model.engine, next.model.model, next.computer ? JSON.stringify(next.computer) : null,-- JSON.stringify(next.apps), next.memory, next.level, p ? p.id : null]);++ JSON.stringify(next.apps), next.memory, next.level, p ? p.id : null,++ extra && extra.bio ? extra.bio : null, JSON.stringify((extra && extra.rules) || []),++ extra && extra.voice_author ? extra.voice_author : null, extra && extra.voice_author_name ? extra.voice_author_name : null,++ extra && extra.voice_author_checked ? true : false,++ extra && extra.voice_source ? extra.voice_source : null,++ JSON.stringify((extra && Array.isArray(extra.suggested_apps) ? extra.suggested_apps : []).slice(0, 50))]);return { row };}nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++/**++ * A voice shared by its owner: the public link (/voices/<id>) that lets somebody else borrow it.++ *++ * Actor's own record of who shared it — the only place a voice's author is vouched for, because the author in a++ * file is only a claim. One row per share: the owner, the agent it is, the document as it was shared, and when it++ * was stopped. Stopping a share does not delete the row: the link answers 410 from then on, and the owner may++ * share the voice again.++ *++ * share(s, owner, agent, doc, name) the public link for this agent's voice++ * get(s, id) the share behind a link (a stopped one is still there)++ * current(s, owner, agentId) the share that is live for this agent++ * revoke(s, owner, agentId) stop sharing it++ */++const ID = /^shv_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** The share for this agent's voice: the live one, or a new row. Sharing again after a stop makes a new row. */++async function share(s, owner, agent, doc, name = null) {++ const existing = await current(s, owner, agent.id);++ if (existing) return existing;++ return s.db.maybe("INSERT INTO actor_shared_voices (id, owner, owner_name, agent_id, voice, created_at) VALUES ($1,$2,$3,$4,$5::jsonb,now()) RETURNING *",++ [s.newId('shv'), owner, name || null, agent.id, JSON.stringify(doc)]);++}++++/** The share behind a public link. A stopped share is still here: the link answers 410, not 404. */++const get = (s, id) => (ID.test(String(id || '')) ? s.db.maybe('SELECT * FROM actor_shared_voices WHERE id = $1', [id]) : null);++++/** The live share of one agent, if it has one. */++const current = (s, owner, agentId) => s.db.maybe('SELECT * FROM actor_shared_voices WHERE owner = $1 AND agent_id = $2 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', [owner, agentId]);++++/** Every voice the person shares, newest first. */++const list = (s, owner) => s.db.many('SELECT * FROM actor_shared_voices WHERE owner = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 100', [owner]);++++/** Stop sharing: the link answers 410 from now on. */++const revoke = (s, owner, agentId) => s.db.maybe('UPDATE actor_shared_voices SET revoked_at = now() WHERE owner = $1 AND agent_id = $2 AND revoked_at IS NULL RETURNING *', [owner, agentId]);++++module.exports = { ID, share, get, current, list, revoke };nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++++/**++ * An agent's voice as a file: actor.voice@1 (OpenVibe.Contracts, contracts/actor/voice.v1.json).++ *++ * A voice is a voice and nothing else. It carries the agent's name, a short bio, its instructions (the house voice it++ * speaks in), the rules it keeps, its default level, the model and the apps it prefers (ids, never a key) and an++ * optional face. It never carries memory, a connector's token or a file: a file that does is refused, not stripped.++ *++ * The document carries an INTEGRITY block, not a signature: a sha256 over the canonical JSON, which shows the file++ * was not edited after it was written. It says nothing about who wrote it — anyone can write a file that names an++ * author and compute its hash — so the author in a document is a claim, and whoever reads a file must say so. Only++ * Actor, which owns the agent, may vouch for an author, and it does so in its own record of a shared voice++ * (server/agents/voice-shares.js).++ *++ * Importing a voice makes a new agent at level ask, whatever the file says: a borrowed agent starts cautious, and++ * the person raises it themselves.++ *++ * build(row, author) the document for one of the person's agents++ * read(input) a document in: the fields it keeps, or the reason it is refused++ * toContact(doc, source) the new agent a document makes++ */++const crypto = require('crypto');++const levels = require('./levels');++++const CONTRACT = 'actor.voice@1';++const FIELDS = ['voice', 'name', 'bio', 'face', 'instructions', 'rules', 'level', 'model', 'apps', 'author', 'integrity', 'created_at'];++const APP = /^[a-z][a-z0-9._-]{0,63}$/;++const ENGINE = ['runtime', 'own-keys', 'open-gpu', 'link'];++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const HASH = /^[0-9a-f]{64}$/;++// A voice never carries these. They are refused, not stripped: a file that smuggles a memory or a token must not++// become an agent by being imported.++const NEVER = /^(memory|memories|notes?|tokens?|secret|secrets|keys?|credentials?|files?|attachments?|connectors?)$/i;++++const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v);++const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);++++/** The canonical JSON of a document: the fields the contract names, in the order it names them. */++function canonical(doc) {++ const o = { voice: doc.voice, name: doc.name };++ if (doc.bio) o.bio = doc.bio;++ if (doc.face) o.face = doc.face;++ o.instructions = doc.instructions;++ if (doc.rules && doc.rules.length) o.rules = doc.rules;++ o.level = doc.level;++ if (doc.model) o.model = { engine: doc.model.engine, model: doc.model.model };++ if (doc.apps && doc.apps.length) o.apps = doc.apps;++ if (doc.author) o.author = doc.author.name ? { subject: doc.author.subject, name: doc.author.name } : { subject: doc.author.subject };++ return JSON.stringify(o);++}++++/** The sha256 of the canonical JSON: the document's integrity hash. It says nothing about who wrote the file. */++const hashOf = (doc) => crypto.createHash('sha256').update(canonical(doc), 'utf8').digest('hex');++++const rulesOf = (row) => { const r = parse(row && row.rules); return Array.isArray(r) ? r.filter((x) => typeof x === 'string') : []; };++const appsOf = (row) => { const a = parse(row && row.apps); return Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []; };++const modelOf = (row) => (row && row.model_engine && row.model_id ? { engine: row.model_engine, model: row.model_id } : null);++++/** The document for one of the person's agents. It never carries the agent's memory, its connectors or any file. */++function build(row, { subject, name = null, at = null } = {}) {++ const created_at = at || new Date().toISOString();++ const doc = {++ voice: CONTRACT,++ name: String((row && row.name) || '').trim(),++ instructions: String((row && row.instructions) || ''),++ level: levels.LEVELS.includes(row && row.level) ? row.level : 'ask',++ created_at,++ integrity: { alg: 'sha256', hash: '' },++ };++ if (subject) {++ const who = { subject: String(subject).replace(/^user:/, '') };++ if (name) who.name = String(name).slice(0, 80);++ doc.author = who;++ }++ if (row && row.bio) doc.bio = String(row.bio);++ if (row && row.face) doc.face = String(row.face);++ const rules = rulesOf(row);++ if (rules.length) doc.rules = rules;++ const model = modelOf(row);++ if (model) doc.model = model;++ const apps = appsOf(row);++ if (apps.length) doc.apps = apps;++ doc.integrity.hash = hashOf(doc);++ return doc;++}++++/** The key of a forbidden field anywhere in a document, or null. */++function findNever(value, depth = 0) {++ if (depth > 4 || !value || typeof value !== 'object') return null;++ if (Array.isArray(value)) {++ for (const v of value) { const k = findNever(v, depth + 1); if (k) return k; }++ return null;++ }++ for (const [k, v] of Object.entries(value)) {++ if (NEVER.test(k)) return k;++ const found = findNever(v, depth + 1);++ if (found) return found;++ }++ return null;++}++++/**++ * A document in: the fields it keeps (anything the contract does not know is stripped) or the reason it is refused.++ * The hash is checked last, so a document edited after it was written does not match.++ */++function read(input) {++ if (!isPlain(input)) return { error: 'A voice file must be a JSON object.' };++ if (input.voice !== CONTRACT) return { error: 'This is not a voice file: it says ' + JSON.stringify(input.voice === undefined ? null : input.voice) + '.' };++ const smuggled = findNever(input);++ if (smuggled) return { error: 'A voice never carries ' + smuggled + '.' };++ const doc = {};++ for (const k of FIELDS) if (input[k] !== undefined) doc[k] = input[k];++ if (typeof doc.name !== 'string' || !doc.name.trim() || doc.name.length > 60) return { error: 'The name must be 1–60 characters.' };++ if (doc.bio !== undefined && (typeof doc.bio !== 'string' || doc.bio.length > 280)) return { error: 'The bio must be at most 280 characters.' };++ if (doc.face !== undefined && (typeof doc.face !== 'string' || !doc.face.trim() || doc.face.length > 500)) return { error: 'The face must be an avatar ref of 1–500 characters.' };++ if (typeof doc.instructions !== 'string' || !doc.instructions.trim() || doc.instructions.length > 4000) return { error: 'The instructions must be 1–4000 characters.' };++ if (doc.rules !== undefined) {++ if (!Array.isArray(doc.rules) || doc.rules.length > 20) return { error: 'The rules must be at most 20 lines.' };++ for (const r of doc.rules) if (typeof r !== 'string' || !r.trim() || r.length > 200) return { error: 'Each rule must be 1–200 characters.' };++ }++ if (!levels.LEVELS.includes(doc.level)) return { error: 'The level must be one of ' + levels.LEVELS.join(', ') + '.' };++ if (doc.model !== undefined) {++ if (!isPlain(doc.model) || !ENGINE.includes(doc.model.engine)) return { error: 'The model engine must be one of ' + ENGINE.join(', ') + '.' };++ if (typeof doc.model.model !== 'string' || !doc.model.model.trim() || doc.model.model.length > 120) return { error: 'The model id must be 1–120 characters.' };++ }++ if (doc.apps !== undefined) {++ if (!Array.isArray(doc.apps) || doc.apps.length > 50) return { error: 'The apps must be at most 50 ids.' };++ for (const a of doc.apps) if (typeof a !== 'string' || !APP.test(a)) return { error: 'Each app id must be a connect slug.' };++ }++ // The author is a claim: the document may name nobody, and when it names somebody, nothing here checks it.++ if (doc.author !== undefined) {++ if (!isPlain(doc.author)) return { error: 'The author must be a Network subject.' };++ const author = { subject: String(doc.author.subject || '').replace(/^user:/, '') };++ if (!SUBJECT.test(author.subject)) return { error: 'The author must be a Network subject (usr_…).' };++ if (doc.author.name !== undefined) {++ if (typeof doc.author.name !== 'string' || doc.author.name.length > 80) return { error: 'The author name must be at most 80 characters.' };++ author.name = doc.author.name;++ }++ doc.author = author;++ }++ if (!isPlain(doc.integrity) || doc.integrity.alg !== 'sha256' || !HASH.test(String(doc.integrity.hash || ''))) return { error: 'The integrity block must be a sha256 hash.' };++ if (doc.created_at !== undefined && (typeof doc.created_at !== 'string' || Number.isNaN(Date.parse(doc.created_at)))) return { error: 'created_at must be a timestamp.' };++ if (hashOf(doc) !== doc.integrity.hash) return { error: 'This voice was changed after it was written: the hash does not match.' };++ return { doc };++}++++/** Does this document still match its own hash? */++const verify = (doc) => { const r = read(doc); return r.error ? { ok: false, error: r.error } : { ok: true, doc: r.doc }; };++++// What an imported voice may bring with it, beyond its words (Opus's review of HO10):++// a face: only one of OpenVibe's preset marks. A picture at an address the file names would load from that server++// every time the person opens their agents (a tracking pixel), so it is not taken;++// a model: only OpenVibe's own runtime or its free GPU models. "own-keys" would spend the borrower's own API money,++// and "link" would run on the borrower's machine, at the say of a file somebody else wrote;++// apps: none. An app id binds to the borrower's own connections (their mail, their files), so the borrower adds++// each one themselves; the file's list is kept as a suggestion the agent's page shows.++const PRESET_FACE = /^preset:[a-z0-9-]{1,40}$/;++const IMPORT_ENGINES = ['runtime', 'open-gpu'];++const DEFAULT_MODEL = { engine: 'runtime', model: 'smart' };++++/**++ * The agent a document makes. The level is always ask, whatever the file says, and it keeps no memory.++ * `authorChecked` says who may be vouched for: Actor's own record of a shared voice says yes, a file says only what++ * it claims.++ */++function toContact(doc, { source = null, authorChecked = false } = {}) {++ return {++ name: doc.name.trim(),++ bio: doc.bio || null,++ face: doc.face && PRESET_FACE.test(doc.face) ? doc.face : 'preset:agent',++ instructions: doc.instructions,++ rules: doc.rules || [],++ level: 'ask',++ model: doc.model && IMPORT_ENGINES.includes(doc.model.engine) ? doc.model : DEFAULT_MODEL,++ apps: [],++ suggested_apps: doc.apps || [],++ memory: null,++ voice_author: doc.author ? doc.author.subject : null,++ voice_author_name: doc.author && doc.author.name ? doc.author.name : null,++ voice_author_checked: Boolean(authorChecked),++ voice_source: source,++ };++}++++module.exports = { CONTRACT, FIELDS, PRESET_FACE, IMPORT_ENGINES, canonical, hashOf, build, read, verify, toContact, findNever };@@@ -9,6 +9,8 @@ const personal = require('../agents/personal');const contacts = require('../agents/contacts');const ui = require('./agent-contacts-ui');const approvals = require('../agents/approvals');++const voice = require('../agents/voice');++const shares = require('../agents/voice-shares');const levels = require('../agents/levels');const allows = require('../agents/allows');const kinds = require('../agents/kinds');@@@ -47,7 +49,7 @@ function commandLine(p) {const memoryStore = require('../memory');function createAgentPages(ctx) {-- const { config, s } = ctx;++ const { config, s, limits } = ctx;const r = asyncRouter();r.use(express.urlencoded({ extended: false, limit: '64kb' }));const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;@@@ -125,7 +127,7 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}<section class="pa-panel" aria-labelledby="ac-add-h"><h2 id="ac-add-h">Add an agent</h2><p class="pa-hint">Each comes ready with its instructions; change anything after.</p>${ui.presetCards(contacts.presets().presets)}</section><section class="pa-panel" aria-labelledby="pa-templates-h"><h2 id="pa-templates-h">Or set up a standing job</h2><p class="pa-hint">An agent with a schedule: it runs on its own and keeps notes between runs.</p><ul class="pa-templates">${TEMPLATES.map((x) => html`<li><a class="pa-template${chosen && chosen.id === x.id ? ' is-chosen' : ''}" href="/me/agents?template=${x.id}#create"><strong>${x.name}</strong><span>${x.line}</span><small>${when({ schedule: x.schedule, schedule_at: x.schedule_at, schedule_dow: x.schedule_dow })}</small></a></li>`)}</ul></section>--<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${chosen.name}` : 'Create an agent'}</h2>${chosen ? html`<p class="pa-hint">Fill in the parts in <angle brackets>, then create it. You can change anything later.</p>` : ''}<form method="post" action="/me/agents">${form(chosen ? { ...chosen, id: undefined } : {})}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);++${importPanel()}<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${chosen.name}` : 'Create an agent'}</h2>${chosen ? html`<p class="pa-hint">Fill in the parts in <angle brackets>, then create it. You can change anything later.</p>` : ''}<form method="post" action="/me/agents">${form(chosen ? { ...chosen, id: undefined } : {})}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);});r.post('/me/agents', async (req, res) => {if (!write(req, res)) return;@@@ -209,6 +211,152 @@ ${buttons}`);res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);});++ // ── An agent's voice as a file (actor.voice@1) ──────────────────────────────────────────────────────++ // The panel on the agent's page: download its voice, share it as a public link, or import somebody else's.++ // Nothing here is a signature: the document's integrity block says the file was not edited, never who wrote it.++ const voicePanel = (a, doc, shared) => html`<section class="pa-panel" id="voice" aria-labelledby="ac-voice-h"><h2 id="ac-voice-h">Its voice</h2>++<p class="pa-hint">An agent's voice is a small file: its name, a short bio, the house voice it speaks in, its rules, its level, and the model and apps it prefers (ids, never a key). Nothing else — never its memory, never a connector's token, never a file. Whoever you send it to can make an agent that speaks in this voice; it starts at level ask, and they raise it themselves.</p>++${a.bio ? html`<p>${a.bio}</p>` : ''}++${a.rules && a.rules.length ? html`<h3>Its rules</h3><ul>${a.rules.map((r) => html`<li>${r}</li>`)}</ul>` : ''}++<p class="pa-actions"><a class="pa-btn" href="/me/agents/${a.id}/voice" download="voice.json">Download its voice (JSON)</a>++${shared++ ? html`<a class="pa-btn" href="/voices/${shared.id}">Its public link</a><form method="post" action="/me/agents/${a.id}/share/stop" class="pa-actions"><button type="submit" class="pa-btn">Stop sharing</button></form>`++ : html`<form method="post" action="/me/agents/${a.id}/share" class="pa-actions"><button type="submit" class="pa-btn">Share this voice</button></form>`}++<a class="pa-btn" href="/me/agents#import">Import a voice</a></p>++${provenance(a)}${suggestedApps(a)}</section>`;++++ // Where this agent's voice came from, and what may be said about the author.++ // a shared voice Actor shared it, so Actor knows who wrote it: the author is a link.++ // a file the file may name anyone and nothing checks it: the author is a claim, said plainly.++ const provenance = (a) => {++ if (a.voice_source && String(a.voice_source).startsWith('shared:')) {++ return html`<p class="pa-hint">Borrowed from a shared voice${a.voice_author_name ? html` by <a href="https://openvibe.network/@${a.voice_author_name}">${a.voice_author_name}</a>` : ''}.</p>`;++ }++ if (a.voice_source === 'file') {++ return html`<p class="pa-hint">Imported from a file.${a.voice_author_name ? html` It says it was made by ${a.voice_author_name}; that is not checked.` : ''}</p>`;++ }++ return '';++ };++ // The apps the voice it came from used: never connected for it, listed so the person can add the ones they want.++ const suggestedApps = (a) => {++ let list = a.voice_suggested_apps;++ if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = []; } }++ return Array.isArray(list) && list.length && a.voice_source++ ? html`<p class="pa-hint">The voice it came from used ${list.join(', ')}. None is connected for it: add the ones you want under its apps.</p>`++ : '';++ };++++ const importPanel = () => html`<section class="pa-panel" id="import" aria-labelledby="ac-import-h"><h2 id="ac-import-h">Import an agent's voice</h2>++<p class="pa-hint">A voice file is a small JSON document: an agent's name, a short bio, the house voice it speaks in, its rules, its level, and the model and apps it prefers. It never carries memory, a connector's token or a file. Paste one and it becomes one of your agents, at level ask — you raise it yourself.</p>++<p class="pa-hint">A file may name an author. Nothing checks that, so the agent will say so rather than claim it is that person's.</p>++<form method="post" action="/me/agents/import"><label class="pa-wide">Voice file (JSON) <textarea name="voice" rows="6" required placeholder='{"voice":"actor.voice@1","name":"A house voice"}'></textarea></label><button type="submit" class="pa-btn pa-primary">Import this voice</button></form></section>`;++++ // The agent's voice as a file: the owner downloads it, and another person's agent is the same 404 as one that does++ // not exist.++ r.get('/me/agents/:id/voice', async (req, res) => {++ if (!signed(req)) return login(req, res);++ const a = await find(req, res); if (!a) return;++ const doc = voice.build(a, { subject: req.viewer.subject, name: req.viewer.username || null });++ res.set('Content-Type', 'application/json; charset=utf-8');++ res.set('Content-Disposition', 'attachment; filename="voice.json"');++ res.send(JSON.stringify(doc, null, 2));++ });++++ // Import a voice from a file: a pasted actor.voice@1 document becomes a new agent, at level ask whatever the file++ // says. The file may name an author; nothing checks that, and the page says so.++ r.post('/me/agents/import', async (req, res) => {++ if (!write(req, res)) return;++ const text = String((req.body || {}).voice || '');++ let input;++ try { input = JSON.parse(text); } catch { return voicePage(req, res, 'That file is not JSON.', 422); }++ const out = await importVoice(req, input, { source: 'file' });++ if (out.error) return voicePage(req, res, out.error, out.limit ? 429 : 422);++ res.redirect(303, `/me/agents/${out.row.id}`);++ });++++ // Share this voice: a public link (/voices/<id>) that anybody may borrow from. Actor's own record of who shared++ // it — the only place a voice's author is vouched for.++ r.post('/me/agents/:id/share', async (req, res) => {++ if (!write(req, res)) return;++ const a = await find(req, res); if (!a) return;++ const doc = voice.build(a, { subject: req.viewer.subject, name: req.viewer.username || null });++ const shared = await shares.share(s, owner(req), a, doc, req.viewer.username || null);++ res.redirect(303, `/voices/${shared.id}`);++ });++ // Stop sharing: the link answers 410 from then on.++ r.post('/me/agents/:id/share/stop', async (req, res) => {++ if (!write(req, res)) return;++ const a = await find(req, res); if (!a) return;++ await shares.revoke(s, owner(req), a.id);++ res.redirect(303, `/me/agents/${a.id}#voice`);++ });++++ // The shared voice: anybody may look at it and borrow it. The author is Actor's own record, so it is a link.++ r.get('/voices/:id.json', async (req, res) => {++ const row = await shares.get(s, req.params.id);++ if (!row) return res.status(404).json({ error: 'No such voice.' });++ if (row.revoked_at) return res.status(410).json({ error: 'This voice is no longer shared.' });++ res.set('Content-Type', 'application/json; charset=utf-8');++ res.json(row.voice);++ });++ r.get('/voices/:id', async (req, res) => {++ const id = String(req.params.id || '').replace(/\.json$/, '');++ const row = await shares.get(s, id);++ if (!row) return page(req, res, 'Voice not found', html`<h1>Voice not found</h1><p>That link is not a voice.</p>`, 404);++ if (row.revoked_at) return page(req, res, 'Voice not shared', html`<h1>This voice is no longer shared</h1><p>Its owner stopped sharing it. Ask them for a new link.</p>`, 410);++ const doc = voice.read(row.voice).doc || row.voice;++ page(req, res, doc.name, html`<p class="crumbs"><a href="/">OpenVibe.Actor</a></p>++<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · a shared voice</p><h1>${doc.name}</h1>++${doc.bio ? html`<p class="pa-lede">${doc.bio}</p>` : ''}</header>++<section class="pa-panel"><h2>Its instructions</h2><p class="pa-hint">What it is told to do on every run. Borrowing it gives your agent exactly these words; read them first.</p><pre class="pa-pre">${doc.instructions}</pre></section>++<section class="pa-panel"><h2>Its rules</h2>${doc.rules && doc.rules.length ? html`<ul>${doc.rules.map((r) => html`<li>${r}</li>`)}</ul>` : html`<p>No rules: it speaks in one voice.</p>`}</section>++${doc.apps && doc.apps.length ? html`<section class="pa-panel"><h2>The apps it uses</h2><p>${doc.apps.join(', ')}</p><p class="pa-hint">A borrowed agent gets none of them: you connect each one yourself, with your own account, on its page.</p></section>` : ''}++<section class="pa-panel"><h2>Borrow this agent</h2>++<p class="pa-hint">It asks for level ${doc.level}. Borrowing it makes an agent of your own — at level ask, whatever the file asks for — and you raise it yourself. It keeps no memory and no token of the person who shared it, runs on OpenVibe's own models (never your own keys or your machine until you choose them), and gets none of your apps until you add them.</p>++${row.owner_name ? html`<p class="pa-hint">Shared by <a href="https://openvibe.network/@${row.owner_name}">${row.owner_name}</a>.</p>` : ''}++${signed(req) ? html`<form method="post" action="/voices/${row.id}/borrow"><button type="submit" class="pa-btn pa-primary">Borrow this agent</button></form>`++ : html`<p><a class="pa-btn pa-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in to borrow it</a></p>`}</section>`);++ });++ // Borrowing a shared voice: the author comes from Actor's record, not from the document.++ r.post('/voices/:id/borrow', limits.budget('actor.voice.borrow'), async (req, res) => {++ if (!signed(req)) return login(req, res);++ if (!write(req, res)) return;++ const row = await shares.get(s, req.params.id);++ if (!row) return page(req, res, 'Voice not found', html`<h1>Voice not found</h1>`, 404);++ if (row.revoked_at) return page(req, res, 'Voice not shared', html`<h1>This voice is no longer shared</h1>`, 410);++ const read = voice.read(row.voice);++ if (read.error) return voicePage(req, res, read.error, 422);++ const out = await importVoice(req, read.doc, {++ source: `shared:${row.id}`, authorChecked: true,++ author: { subject: String(row.owner || '').replace(/^user:/, ''), name: row.owner_name || null },++ });++ if (out.error) return voicePage(req, res, out.error, out.limit ? 429 : 422);++ res.redirect(303, `/me/agents/${out.row.id}`);++ });++++ // The page a refused or broken voice ends on: what went wrong, in plain words.++ function voicePage(req, res, message, status = 422) {++ return page(req, res, 'Voice not taken', html`<h1>Voice not taken</h1><p>${message}</p><p><a href="/me/agents">Your agents</a></p>`, status);++ }++++ // A voice document becomes an agent: the new agent keeps the voice, starts at level ask and keeps no memory.++ async function importVoice(req, input, { source = 'file', author = null, authorChecked = false } = {}) {++ const read = voice.read(input);++ if (read.error) return { error: read.error };++ const fields = voice.toContact(read.doc, { source, authorChecked });++ if (author) {++ fields.voice_author = author.subject;++ fields.voice_author_name = author.name || null;++ }++ const out = await contacts.create(s, config, owner(req), {++ name: fields.name, face: fields.face, model: fields.model, apps: fields.apps, memory: null,++ level: fields.level, instructions: fields.instructions,++ }, { bio: fields.bio, rules: fields.rules, suggested_apps: fields.suggested_apps, voice_author: fields.voice_author, voice_author_name: fields.voice_author_name,++ voice_author_checked: fields.voice_author_checked, voice_source: fields.voice_source });++ if (!out.row) return { error: out.error || 'Agent limit reached.', limit: out.limit };++ return { row: out.row };++ }++r.get('/me/agents/:id', async (req, res) => {if (!signed(req)) return login(req, res);const a = await find(req, res); if (!a) return;@@@ -234,6 +382,10 @@ ${buttons}`);const usd = (v) => (v == null || !Number.isFinite(Number(v)) ? '' : ` · $${Number(v) < 0.01 ? Number(v).toFixed(4) : Number(v).toFixed(2)}`);const pill = (state) => state === 'succeeded' ? 'pill-succeeded' : state === 'failed' || state === 'cancelled' ? 'pill-failed' : 'pill-running';const contact = contacts.wire(a);++ // The agent's voice as a file (actor.voice@1): what it is, in a form somebody else can speak in.++ const doc = voice.build(a, { subject: req.viewer.subject, name: req.viewer.username || null });++ // The voice's public link, when the person shares it.++ const shared = await shares.current(s, a.owner, a.id);page(req, res, a.name, html`<p class="crumbs"><a href="/me/agents">Your agents</a></p><header class="pa-head ac-sheet-head">${ui.face(contact, 64)}<div><h1>${a.name}</h1><p class="ac-sheet-uses">${ui.usesLine(contact)}</p><p class="pa-lede">${ui.levelChip(contact.level)} <span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span> ${when(a)}${a.next_run_at && a.status !== 'paused' && a.schedule !== 'off' ? html` · next ${time(a.next_run_at)}` : ''}</p></div></header>@@@ -258,7 +410,7 @@ ${pendingSuggestions.length ? html`<ul class="pa-notes">${pendingSuggestions.map<form method="post" action="/me/agents/${a.id}/suggestions/${p.id}" class="pa-actions"><button type="submit" name="decision" value="apply" class="pa-btn pa-primary">Add to its instructions</button> <button type="submit" name="decision" value="dismiss" class="pa-btn">Dismiss</button></form></li>`)}</ul>` : ''}${applied.length ? html`<details><summary>Added lately</summary><ul class="pa-notes">${applied.map((p) => html`<li><p class="pa-note-text">${p.text}</p><p class="pa-note-meta">Added ${time(p.decided_at)}</p><form method="post" action="/me/agents/${a.id}/suggestions/${p.id}"><button type="submit" name="decision" value="undo" class="pa-danger-link">Undo</button></form></li>`)}</ul></details>` : ''}</section>` : ''}--<section class="pa-panel"><h2>Memory</h2><p class="pa-hint">What this agent remembers, newest first. Only active notes go into a run. Notes you write are never dropped; when memory is full (50 notes) the oldest note the agent wrote goes first. An agent adds at most one note per run, and only when its answer passed the check; its note is a suggestion until you accept it on <a href="/memory">Memory</a>.</p>++${voicePanel(a, doc, shared)}<section class="pa-panel"><h2>Memory</h2><p class="pa-hint">What this agent remembers, newest first. Only active notes go into a run. Notes you write are never dropped; when memory is full (50 notes) the oldest note the agent wrote goes first. An agent adds at most one note per run, and only when its answer passed the check; its note is a suggestion until you accept it on <a href="/memory">Memory</a>.</p>${notes.length ? html`<ul class="pa-notes">${notes.map((n) => html`<li><p class="pa-note-text">${n.text}</p><p class="pa-note-meta"><span class="pill ${n.status === 'suggested' ? 'pill-running' : n.status === 'archived' ? 'pill-failed' : 'pill-succeeded'}">${n.status === 'suggested' ? 'Suggested' : n.status === 'archived' ? 'Archived' : 'Active'}</span> ${n.source === 'agent' ? 'Written by the agent' : 'Written by you'} · ${n.kind} · ${time(n.created_at)}${n.last_used_at ? html` · last used ${time(n.last_used_at)}` : ''}</p>${n.status === 'suggested' ? html`<form method="post" action="/memory/${n.id}/accept" class="pa-actions"><button type="submit" class="pa-btn pa-primary">Accept</button> <button type="submit" class="pa-btn" formaction="/memory/${n.id}/decline">Decline</button></form>` : ''}<details><summary>Edit or delete</summary><form method="post" action="/me/agents/${a.id}/memory/${n.id}/edit"><label>Note <input name="text" maxlength="${require('../memory').MAX_TEXT}" value="${n.text}"></label><button type="submit" class="pa-btn">Save</button></form>@@@ -37,6 +37,7 @@ const { findArtifact, sendArtifact } = require('../runtime/artifacts');const trace = require('../runtime/trace');const personal = require('../agents/personal');const contacts = require('../agents/contacts');++const voice = require('../agents/voice');const memoryStore = require('../memory');const approvals = require('../agents/approvals');const items = require('../actions/items');@@@ -278,6 +279,29 @@ function createApi(ctx) {r.get('/me/agents/:id', principal.requireCapability('actor.agent.manage'), owner, limits.reads('actor.agent.manage'), async (req, res) => {const row = await loadAgent(req, res); if (row) res.json(row);});++ // The agent's voice as a file (actor.voice@1). Another person's agent is the same 404 as one that does not exist.++ r.get('/me/agents/:id/voice', principal.requireCapability('actor.agent.manage'), owner, limits.reads('actor.agent.manage'), async (req, res) => {++ const row = await loadAgent(req, res); if (!row) return;++ const subject = (req.principal && req.principal.subject) || String((req.principal && req.principal.requester) || '').replace(/^user:/, '');++ const doc = voice.build(row, { subject });++ res.set('Content-Disposition', 'attachment; filename="voice.json"');++ res.json(doc);++ });++ // Import a voice: an actor.voice@1 document becomes a new agent, at level ask whatever the file says.++ r.post('/me/agents/import', principal.requireCapability('actor.agent.manage'), owner, limits.budget('actor.agent.manage'), async (req, res) => {++ const body = req.body || {};++ const read = voice.read(body.voice || body.document || null);++ if (read.error) return problem(req, res, 422, 'actor.voice.invalid', read.error);++ const fields = voice.toContact(read.doc, { source: 'file' });++ const out = await contacts.create(s, config, req.principal.requester, {++ name: fields.name, face: fields.face, model: fields.model, apps: fields.apps, memory: null,++ level: fields.level, instructions: fields.instructions,++ }, { bio: fields.bio, rules: fields.rules, suggested_apps: fields.suggested_apps, voice_author: fields.voice_author, voice_author_name: fields.voice_author_name,++ voice_author_checked: fields.voice_author_checked, voice_source: fields.voice_source });++ if (!out.row) return failed(req, res, out);++ res.set('Location', `/api/v1/me/agents/${out.row.id}`);++ res.status(201).json(out.row);++ });r.patch('/me/agents/:id', principal.requireCapability('actor.agent.manage'), owner, limits.budget('actor.agent.manage'), async (req, res) => {const out = await personal.update(s, config, req.principal.requester, req.params.id, req.body);if (!out) return problem(req, res, 404, 'actor.agent.not_found', 'No such agent.');@@@ -39,6 +39,8 @@ const BUDGETS = {'actor.assignment.cancel': { minute: 30, hour: 600 },// The kill switch and the other controls of the agent (design 2026-11-07).'actor.resource.control': { minute: 30, hour: 600 },++ // Borrowing a shared voice: a person may take a few voices an hour, not a few a minute.++ 'actor.voice.borrow': { minute: 6, hour: 60 },};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {@@@ -68,6 +68,10 @@ const TABLES = [// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },++ // 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',++ columns: ['id', 'owner', 'owner_name', 'agent_id', 'voice', 'created_at', 'revoked_at'] },// What the person's agents asked them to approve (agents/approvals.js), and what they decided.{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },@@@ -31,6 +31,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'actor.approval.decide': { minute: 30, hour: 600 },'actor.resource.control': { minute: 30, hour: 600 },'actor.assignment.cancel': { minute: 30, hour: 600 },++ 'actor.voice.borrow': { minute: 6, hour: 60 },});});@@@ -38,6 +38,11 @@ const ROUTE_EXTENSIONS = new Map([['POST /api/v1/triggers/:id/resume', 'actor.task.create'],['POST /api/v1/triggers/:id/run', 'actor.task.create'],['DELETE /api/v1/triggers/:id', 'actor.task.create'],++ // An agent's voice as a file (actor.voice@1): the person's own agent and the voice they import into a new++ // one. The routes exist before Contracts lists a voice capability; the agent capability they already hold++ // guards them.++ ['GET /api/v1/me/agents/:id/voice', 'actor.agent.manage'],++ ['POST /api/v1/me/agents/import', 'actor.agent.manage'],]);const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');// The A2A surface (stage V2-11): POST /a2a/v1/tasks and GET /a2a/v1/tasks/:id, guarded by actor.a2a.task.nnew file mode 100644@@@ -0,0 +1,166 @@++'use strict';++/**++ * An agent's voice as a file (actor.voice@1), and a voice shared by its owner.++ *++ * The document carries an integrity hash, not a signature: it shows the file was not edited after it was written, and++ * says nothing about who wrote it. So the author in a file is only a claim, and the page says so; the only author++ * Actor vouches for is the one in its own record of a shared voice.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const voice = require('../server/agents/voice');++const { BUDGETS } = require('../server/http/caller-limits');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++(async () => {++ const t = await boot();++ try {++ const alice = t.network.addUser('voice-alice');++ const bob = t.network.addUser('voice-bob');++ let agentId = null;++ let doc = null;++ let shareId = null;++++ await check('the owner exports an actor.voice@1 document, and it carries no memory, token or file', async () => {++ const created = await t.get('/api/v1/agents', { as: alice, json: { name: 'Researcher', instructions: 'Research carefully.', model: { engine: 'runtime', model: 'smart' } }, headers: SAME });++ assert.strictEqual(created.status, 201, created.text);++ agentId = created.json().id;++ const res = await t.get(`/api/v1/me/agents/${agentId}/voice`, { as: alice });++ assert.strictEqual(res.status, 200, res.text);++ doc = res.json();++ assert.strictEqual(doc.voice, 'actor.voice@1');++ assert.strictEqual(doc.name, 'Researcher');++ assert.strictEqual(doc.instructions, 'Research carefully.');++ assert.strictEqual(doc.level, 'ask');++ assert.strictEqual(doc.author.subject, alice.subject);++ assert.ok(doc.integrity && doc.integrity.alg === 'sha256', 'the integrity block');++ assert.match(doc.integrity.hash, /^[0-9a-f]{64}$/);++ assert.ok(!doc.signature, 'there is no signature block');++ const text = JSON.stringify(doc);++ for (const word of ['memory', 'token', 'mems_', 'sk-']) assert.ok(!text.includes(word), `the document carries no ${word}`);++ });++++ await check('another person cannot export it: the same 404 as an agent that does not exist', async () => {++ const res = await t.get(`/api/v1/me/agents/${agentId}/voice`, { as: bob });++ assert.strictEqual(res.status, 404, res.text);++ });++++ await check('a voice imports as a new agent: the voice round-trips, and no memory or token comes with it', async () => {++ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: doc }, headers: SAME });++ assert.strictEqual(res.status, 201, res.text);++ const agent = res.json();++ assert.strictEqual(agent.name, doc.name);++ assert.strictEqual(agent.instructions, doc.instructions);++ assert.deepStrictEqual(agent.apps, doc.apps || []);++ assert.strictEqual(agent.voice_author, alice.subject);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM actor_agent_memory WHERE agent_id = $1', [agent.id])), 0, 'no memory');++ });++++ await check('an imported agent starts at Ask, whatever the file says', async () => {++ const bold = voice.build({ name: 'Bold', instructions: 'Act.', level: 'full', apps: [], rules: [], model_engine: 'runtime', model_id: 'smart' }, { subject: alice.subject });++ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: bold }, headers: SAME });++ assert.strictEqual(res.status, 201, res.text);++ assert.strictEqual(res.json().level, 'ask');++ });++++ await check('a file brings only words: no outside face, no own keys or machine, no apps (they are suggestions)', async () => {++ const sly = voice.build({ name: 'Sly', instructions: 'Help.', level: 'ask', face: 'https://tracker.example/p.png', apps: ['gmail', 'drive'], rules: [],++ model_engine: 'own-keys', model_id: 'gpt-5-pro' }, { subject: alice.subject });++ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: sly }, headers: SAME });++ assert.strictEqual(res.status, 201, res.text);++ const row = res.json();++ assert.strictEqual(row.face, 'preset:agent', 'a picture at an outside address is not taken');++ assert.strictEqual(row.model_engine, 'runtime', 'never the borrower\'s own keys');++ assert.deepStrictEqual(typeof row.apps === 'string' ? JSON.parse(row.apps) : row.apps, [], 'no app is connected for it');++ const page = await t.get(`/me/agents/${row.id}`, { as: bob });++ assert.match(page.text, /The voice it came from used gmail, drive\. None is connected for it/);++ assert.ok(!page.text.includes('tracker.example'), 'the outside picture is nowhere on the page');++ });++++ await check('a field the contract does not know is stripped, not kept', async () => {++ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: { ...doc, extra_field: 'hello' } }, headers: SAME });++ assert.strictEqual(res.status, 201, res.text);++ const again = await t.get(`/api/v1/me/agents/${res.json().id}/voice`, { as: bob });++ assert.strictEqual(again.json().extra_field, undefined);++ });++++ await check('a file that carries a memory or a token is refused', async () => {++ for (const extra of [{ memory: 'mems_01M4C49CW7R8S9T0A1B2C3D4E5' }, { tokens: { connect: { email: 'sk-live-1' } } }]) {++ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: { ...doc, ...extra } }, headers: SAME });++ assert.strictEqual(res.status, 422, res.text);++ assert.match(res.text, /never carries/);++ }++ });++++ await check('a file changed after it was written is refused', async () => {++ const tampered = { ...doc, instructions: 'Research carefully, and take what you find.' };++ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: tampered }, headers: SAME });++ assert.strictEqual(res.status, 422, res.text);++ assert.match(res.text, /changed after it was written/);++ });++++ await check('a file that claims an author says so, and does not link to them', async () => {++ const claimed = voice.build({ name: 'Claimed', instructions: 'Speak plainly.', level: 'ask', apps: [], rules: [], model_engine: 'runtime', model_id: 'smart' }, { subject: 'usr_01M4C49CW34NGX7TR2TNRVFWNY', name: 'Ada' });++ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: claimed }, headers: SAME });++ assert.strictEqual(res.status, 201, res.text);++ const page = await t.get(`/me/agents/${res.json().id}`, { as: bob });++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes('Imported from a file'), 'it says where it came from');++ assert.ok(page.text.includes('that is not checked'), 'the author it names is not checked');++ assert.ok(!page.text.includes('openvibe.network/@'), 'no link to the person the file names');++ assert.ok(!/signature|was signed|signed by/i.test(page.text), 'the page never calls it signed');++ });++++ await check('a shared voice: the author is the owner, and it is a link to them', async () => {++ const shared = await t.get(`/me/agents/${agentId}/share`, { as: alice, method: 'POST', headers: SAME });++ assert.strictEqual(shared.status, 303, shared.text);++ shareId = String(shared.headers.get('location') || '').split('/').pop();++ assert.match(shareId, /^shv_/);++ const page = await t.get(`/voices/${shareId}`);++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes('Borrow this agent'), 'the borrow button');++ assert.ok(page.text.includes('Its instructions') && page.text.includes('Research carefully.'), 'the borrower reads the instructions first');++ assert.ok(page.text.includes('openvibe.network/@'), 'the author is a link');++ assert.ok(!/signature|was signed|signed by/i.test(page.text), 'the page never calls it signed');++ const json = await t.get(`/voices/${shareId}.json`);++ assert.strictEqual(json.status, 200, json.text);++ assert.strictEqual(json.json().author.subject, alice.subject, 'the record names the owner');++ assert.strictEqual(voice.read(json.json()).error, undefined, 'the shared document matches its own hash');++ // Borrowing it: the agent knows who wrote it, and starts at Ask.++ const borrowed = await t.get(`/voices/${shareId}/borrow`, { as: bob, method: 'POST', headers: SAME });++ assert.strictEqual(borrowed.status, 303, borrowed.text);++ const newId = String(borrowed.headers.get('location') || '').split('/').pop();++ const agent = await t.get(`/api/v1/me/agents/${newId}`, { as: bob });++ assert.strictEqual(agent.json().level, 'ask', 'a borrowed agent starts at Ask');++ assert.strictEqual(agent.json().voice_author, alice.subject);++ assert.strictEqual(agent.json().voice_author_checked, true, 'Actor vouches for this author');++ const agentPage = await t.get(`/me/agents/${newId}`, { as: bob });++ assert.ok(agentPage.text.includes('Borrowed from a shared voice'), 'the source');++ assert.ok(agentPage.text.includes('openvibe.network/@'), 'the author is a link');++ });++++ await check('stopping the share makes the link answer 410, and the page says so', async () => {++ const stop = await t.get(`/me/agents/${agentId}/share/stop`, { as: alice, method: 'POST', headers: SAME });++ assert.strictEqual(stop.status, 303, stop.text);++ const page = await t.get(`/voices/${shareId}`);++ assert.strictEqual(page.status, 410, page.text);++ assert.ok(page.text.includes('no longer shared'), 'the page says so');++ const json = await t.get(`/voices/${shareId}.json`);++ assert.strictEqual(json.status, 410, json.text);++ });++++ await check('another person cannot share somebody else\'s agent', async () => {++ const res = await t.get(`/me/agents/${agentId}/share`, { as: bob, method: 'POST', headers: SAME });++ assert.strictEqual(res.status, 404, res.text);++ });++++ await check('borrowing is rate-limited per person', async () => {++ assert.ok(BUDGETS['actor.voice.borrow'], 'the borrow has its own budget');++ assert.ok(BUDGETS['actor.voice.borrow'].hour <= 120, 'and it is not generous');++ });++ } finally {++ await t.close();++ }++ done();++})();
@@ -100,6 +100,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -100,6 +100,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- Tasks, their event logs and daily spend (`migrations/0001_tasks.sql`). Tasks are kept 30 days, then pruned with their events. Nothing stored is a credential, a key or a token.
- Tasks, their event logs and daily spend (`migrations/0001_tasks.sql`). Tasks are kept 30 days, then pruned with their events. Nothing stored is a credential, a key or a token.
- Request telemetry (`migrations/0010_telemetry.sql`): each HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` builds the collector (`createHttpTelemetry`) and mounts its middleware; `server/index.js` flushes it every minute and on stop, and runs the 14-day retention.
- Request telemetry (`migrations/0010_telemetry.sql`): each HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` builds the collector (`createHttpTelemetry`) and mounts its middleware; `server/index.js` flushes it every minute and on stop, and runs the 14-day retention.
- The agent catalog and its rate cards; the routing policy over them (placement itself is `openvibe-sdk/placement`).
- The agent catalog and its rate cards; the routing policy over them (placement itself is `openvibe-sdk/placement`).
+- An agent's voice as a file (`actor.voice@1`, [server/agents/voice.js](server/agents/voice.js)): its name, a short bio, the house voice it speaks in, its rules, its level, and the model and apps it prefers (ids, never a key). The document's integrity hash shows the file was not edited after it was written and says nothing about who wrote it, so the author in a file is only a claim. A shared voice ([server/agents/voice-shares.js](server/agents/voice-shares.js), `migrations/0040_agent_shared_voices.sql`) is the public link `/voices/<id>`: the owner may stop sharing it, the document is served as JSON at `/voices/<id>.json`, and Actor's own record of who shared it is the only place a voice's author is vouched for.
- The home page (`server/http/pages.js`, `public/css/home.css`, `test/home.test.js`) is a taste and a door, about five phone screens, built from the shared kit (`kit.css`, `room.css`, `showcase.css`): the task box, numbers read from the catalog, the running config and OpenVibe.Tools' catalog (the runtime's own cached copy, waited for at most a second) as one `kit.stats` strip, task templates as a rail of cards that fill the task box (`/?task=…&mode=…`, kept by no shared cache; signed out, the task waits for sign-in; what a robot, computer, desk or private task needs is said in the box), the agents as rows that open `/agents`, the parts read once as folds (how a task finds its agent, the comparison, what works today, the Tools tools the runtime can run, the API samples; a link to `#tools` or `#api` opens its fold), the questions people ask first and the rest of OpenVibe as a launcher, with JSON-LD WebSite, WebApplication, WebPage, ItemList (the agents) and FAQPage (exactly the questions shown).
- The home page (`server/http/pages.js`, `public/css/home.css`, `test/home.test.js`) is a taste and a door, about five phone screens, built from the shared kit (`kit.css`, `room.css`, `showcase.css`): the task box, numbers read from the catalog, the running config and OpenVibe.Tools' catalog (the runtime's own cached copy, waited for at most a second) as one `kit.stats` strip, task templates as a rail of cards that fill the task box (`/?task=…&mode=…`, kept by no shared cache; signed out, the task waits for sign-in; what a robot, computer, desk or private task needs is said in the box), the agents as rows that open `/agents`, the parts read once as folds (how a task finds its agent, the comparison, what works today, the Tools tools the runtime can run, the API samples; a link to `#tools` or `#api` opens its fold), the questions people ask first and the rest of OpenVibe as a launcher, with JSON-LD WebSite, WebApplication, WebPage, ItemList (the agents) and FAQPage (exactly the questions shown).
## Does not own
## Does not own
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- An agent's voice as a file (actor.voice@1): the parts of an agent that make it the same voice in somebody
+-- else's hands — a short bio, the rules it keeps, and where its voice came from (the author's subject and the
+-- link it was imported from). Never memory, never a connector's token, never a file: a voice is a voice.
+ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS bio text;
+ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS rules jsonb NOT NULL DEFAULT '[]'::jsonb;
+ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_author text;
+ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_author_name text;
+ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_source text;
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- A voice shared by its owner: the public link (/voices/<id>) that lets somebody else borrow it. Actor's own record
+-- of who shared it — the only place a voice's author is vouched for, because the author in a file is only a claim.
+CREATE TABLE IF NOT EXISTS actor_shared_voices (
+ id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ owner_name text,
+ agent_id text COLLATE "C" NOT NULL,
+ voice jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ revoked_at timestamptz
+);
+CREATE INDEX IF NOT EXISTS actor_shared_voices_owner_idx ON actor_shared_voices (owner, created_at DESC);
+-- Whether the author of an imported voice may be vouched for: Actor's own record of a shared voice says yes; a file
+-- says only what it claims, and the page says so.
+ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_author_checked boolean NOT NULL DEFAULT false;
+-- The apps the voice an agent was imported from used: never connected for it (an app binds to the borrower's own
+-- connections), shown on its page so the person adds the ones they want.
+ALTER TABLE actor_agents ADD COLUMN IF NOT EXISTS voice_suggested_apps jsonb NOT NULL DEFAULT '[]'::jsonb;
@@ -115,7 +115,11 @@ async function list(s, config, owner) {
@@ -115,7 +115,11 @@ async function list(s, config, owner) {
const get = (s, owner, id) => (ID.test(String(id || '')) ? s.db.maybe('SELECT * FROM actor_agents WHERE id = $1 AND owner = $2', [id, owner]) : null);
const get = (s, owner, id) => (ID.test(String(id || '')) ? s.db.maybe('SELECT * FROM actor_agents WHERE id = $1 AND owner = $2', [id, owner]) : null);
-async function create(s, config, owner, input) {
+/**
+ * `extra` carries the voice parts of an agent (its bio, its rules and where its voice came from). They are not
+ * part of the public create body (actor.agent-manage-request@1 does not know them): only the voice import sets them.
+ */
+async function create(s, config, owner, input, extra = null) {
const body = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const body = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
if (Object.keys(body).some((k) => !ALLOWED.includes(k))) return { error: 'Unknown agent setting.' };
if (Object.keys(body).some((k) => !ALLOWED.includes(k))) return { error: 'Unknown agent setting.' };
const p = body.preset != null ? preset(body.preset) : null;
const p = body.preset != null ? preset(body.preset) : null;
@@ -146,11 +150,16 @@ async function create(s, config, owner, input) {
@@ -146,11 +150,16 @@ async function create(s, config, owner, input) {
const now = s.iso();
const now = s.iso();
const row = await s.db.maybe(`INSERT INTO actor_agents
const row = await s.db.maybe(`INSERT INTO actor_agents
(id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, status, created_at, updated_at,
(id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, status, created_at, updated_at,
- face, voice, model_engine, model_id, computer, apps, memory_scope, level, is_vibe, preset)
- VALUES ($1,$2,$3,$4,'balanced',$5,1,'off','active',$6,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,false,$15) RETURNING *`,
+ face, voice, model_engine, model_id, computer, apps, memory_scope, level, is_vibe, preset, bio, rules, voice_author, voice_author_name, voice_author_checked, voice_source, voice_suggested_apps)
+ VALUES ($1,$2,$3,$4,'balanced',$5,1,'off','active',$6,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,false,$15,$16,$17::jsonb,$18,$19,$20,$21,$22::jsonb) RETURNING *`,
[s.newId('agt'), owner, next.name, instructions, Math.max(0.000001, Number(config.allowance.perTaskUsd) || 0.05), now,
[s.newId('agt'), owner, next.name, instructions, Math.max(0.000001, Number(config.allowance.perTaskUsd) || 0.05), now,
next.face, next.voice, next.model.engine, next.model.model, next.computer ? JSON.stringify(next.computer) : null,
next.face, next.voice, next.model.engine, next.model.model, next.computer ? JSON.stringify(next.computer) : null,
- JSON.stringify(next.apps), next.memory, next.level, p ? p.id : null]);
+ JSON.stringify(next.apps), next.memory, next.level, p ? p.id : null,
+ extra && extra.bio ? extra.bio : null, JSON.stringify((extra && extra.rules) || []),
+ extra && extra.voice_author ? extra.voice_author : null, extra && extra.voice_author_name ? extra.voice_author_name : null,
+ extra && extra.voice_author_checked ? true : false,
+ extra && extra.voice_source ? extra.voice_source : null,
+ JSON.stringify((extra && Array.isArray(extra.suggested_apps) ? extra.suggested_apps : []).slice(0, 50))]);
return { row };
return { row };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+/**
+ * A voice shared by its owner: the public link (/voices/<id>) that lets somebody else borrow it.
+ *
+ * Actor's own record of who shared it — the only place a voice's author is vouched for, because the author in a
+ * file is only a claim. One row per share: the owner, the agent it is, the document as it was shared, and when it
+ * was stopped. Stopping a share does not delete the row: the link answers 410 from then on, and the owner may
+ * share the voice again.
+ *
+ * share(s, owner, agent, doc, name) the public link for this agent's voice
+ * get(s, id) the share behind a link (a stopped one is still there)
+ * current(s, owner, agentId) the share that is live for this agent
+ * revoke(s, owner, agentId) stop sharing it
+ */
+const ID = /^shv_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** The share for this agent's voice: the live one, or a new row. Sharing again after a stop makes a new row. */
+async function share(s, owner, agent, doc, name = null) {
+ const existing = await current(s, owner, agent.id);
+ if (existing) return existing;
+ return s.db.maybe("INSERT INTO actor_shared_voices (id, owner, owner_name, agent_id, voice, created_at) VALUES ($1,$2,$3,$4,$5::jsonb,now()) RETURNING *",
+ [s.newId('shv'), owner, name || null, agent.id, JSON.stringify(doc)]);
+}
+
+/** The share behind a public link. A stopped share is still here: the link answers 410, not 404. */
+const get = (s, id) => (ID.test(String(id || '')) ? s.db.maybe('SELECT * FROM actor_shared_voices WHERE id = $1', [id]) : null);
+
+/** The live share of one agent, if it has one. */
+const current = (s, owner, agentId) => s.db.maybe('SELECT * FROM actor_shared_voices WHERE owner = $1 AND agent_id = $2 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', [owner, agentId]);
+
+/** Every voice the person shares, newest first. */
+const list = (s, owner) => s.db.many('SELECT * FROM actor_shared_voices WHERE owner = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 100', [owner]);
+
+/** Stop sharing: the link answers 410 from now on. */
+const revoke = (s, owner, agentId) => s.db.maybe('UPDATE actor_shared_voices SET revoked_at = now() WHERE owner = $1 AND agent_id = $2 AND revoked_at IS NULL RETURNING *', [owner, agentId]);
+
+module.exports = { ID, share, get, current, list, revoke };
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+
+/**
+ * An agent's voice as a file: actor.voice@1 (OpenVibe.Contracts, contracts/actor/voice.v1.json).
+ *
+ * A voice is a voice and nothing else. It carries the agent's name, a short bio, its instructions (the house voice it
+ * speaks in), the rules it keeps, its default level, the model and the apps it prefers (ids, never a key) and an
+ * optional face. It never carries memory, a connector's token or a file: a file that does is refused, not stripped.
+ *
+ * The document carries an INTEGRITY block, not a signature: a sha256 over the canonical JSON, which shows the file
+ * was not edited after it was written. It says nothing about who wrote it — anyone can write a file that names an
+ * author and compute its hash — so the author in a document is a claim, and whoever reads a file must say so. Only
+ * Actor, which owns the agent, may vouch for an author, and it does so in its own record of a shared voice
+ * (server/agents/voice-shares.js).
+ *
+ * Importing a voice makes a new agent at level ask, whatever the file says: a borrowed agent starts cautious, and
+ * the person raises it themselves.
+ *
+ * build(row, author) the document for one of the person's agents
+ * read(input) a document in: the fields it keeps, or the reason it is refused
+ * toContact(doc, source) the new agent a document makes
+ */
+const crypto = require('crypto');
+const levels = require('./levels');
+
+const CONTRACT = 'actor.voice@1';
+const FIELDS = ['voice', 'name', 'bio', 'face', 'instructions', 'rules', 'level', 'model', 'apps', 'author', 'integrity', 'created_at'];
+const APP = /^[a-z][a-z0-9._-]{0,63}$/;
+const ENGINE = ['runtime', 'own-keys', 'open-gpu', 'link'];
+const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const HASH = /^[0-9a-f]{64}$/;
+// A voice never carries these. They are refused, not stripped: a file that smuggles a memory or a token must not
+// become an agent by being imported.
+const NEVER = /^(memory|memories|notes?|tokens?|secret|secrets|keys?|credentials?|files?|attachments?|connectors?)$/i;
+
+const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v);
+const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
+
+/** The canonical JSON of a document: the fields the contract names, in the order it names them. */
+function canonical(doc) {
+ const o = { voice: doc.voice, name: doc.name };
+ if (doc.bio) o.bio = doc.bio;
+ if (doc.face) o.face = doc.face;
+ o.instructions = doc.instructions;
+ if (doc.rules && doc.rules.length) o.rules = doc.rules;
+ o.level = doc.level;
+ if (doc.model) o.model = { engine: doc.model.engine, model: doc.model.model };
+ if (doc.apps && doc.apps.length) o.apps = doc.apps;
+ if (doc.author) o.author = doc.author.name ? { subject: doc.author.subject, name: doc.author.name } : { subject: doc.author.subject };
+ return JSON.stringify(o);
+}
+
+/** The sha256 of the canonical JSON: the document's integrity hash. It says nothing about who wrote the file. */
+const hashOf = (doc) => crypto.createHash('sha256').update(canonical(doc), 'utf8').digest('hex');
+
+const rulesOf = (row) => { const r = parse(row && row.rules); return Array.isArray(r) ? r.filter((x) => typeof x === 'string') : []; };
+const appsOf = (row) => { const a = parse(row && row.apps); return Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []; };
+const modelOf = (row) => (row && row.model_engine && row.model_id ? { engine: row.model_engine, model: row.model_id } : null);
+
+/** The document for one of the person's agents. It never carries the agent's memory, its connectors or any file. */
+function build(row, { subject, name = null, at = null } = {}) {
+ const created_at = at || new Date().toISOString();
+ const doc = {
+ voice: CONTRACT,
+ name: String((row && row.name) || '').trim(),
+ instructions: String((row && row.instructions) || ''),
+ level: levels.LEVELS.includes(row && row.level) ? row.level : 'ask',
+ created_at,
+ integrity: { alg: 'sha256', hash: '' },
+ };
+ if (subject) {
+ const who = { subject: String(subject).replace(/^user:/, '') };
+ if (name) who.name = String(name).slice(0, 80);
+ doc.author = who;
+ }
+ if (row && row.bio) doc.bio = String(row.bio);
+ if (row && row.face) doc.face = String(row.face);
+ const rules = rulesOf(row);
+ if (rules.length) doc.rules = rules;
+ const model = modelOf(row);
+ if (model) doc.model = model;
+ const apps = appsOf(row);
+ if (apps.length) doc.apps = apps;
+ doc.integrity.hash = hashOf(doc);
+ return doc;
+}
+
+/** The key of a forbidden field anywhere in a document, or null. */
+function findNever(value, depth = 0) {
+ if (depth > 4 || !value || typeof value !== 'object') return null;
+ if (Array.isArray(value)) {
+ for (const v of value) { const k = findNever(v, depth + 1); if (k) return k; }
+ return null;
+ }
+ for (const [k, v] of Object.entries(value)) {
+ if (NEVER.test(k)) return k;
+ const found = findNever(v, depth + 1);
+ if (found) return found;
+ }
+ return null;
+}
+
+/**
+ * A document in: the fields it keeps (anything the contract does not know is stripped) or the reason it is refused.
+ * The hash is checked last, so a document edited after it was written does not match.
+ */
+function read(input) {
+ if (!isPlain(input)) return { error: 'A voice file must be a JSON object.' };
+ if (input.voice !== CONTRACT) return { error: 'This is not a voice file: it says ' + JSON.stringify(input.voice === undefined ? null : input.voice) + '.' };
+ const smuggled = findNever(input);
+ if (smuggled) return { error: 'A voice never carries ' + smuggled + '.' };
+ const doc = {};
+ for (const k of FIELDS) if (input[k] !== undefined) doc[k] = input[k];
+ if (typeof doc.name !== 'string' || !doc.name.trim() || doc.name.length > 60) return { error: 'The name must be 1–60 characters.' };
+ if (doc.bio !== undefined && (typeof doc.bio !== 'string' || doc.bio.length > 280)) return { error: 'The bio must be at most 280 characters.' };
+ if (doc.face !== undefined && (typeof doc.face !== 'string' || !doc.face.trim() || doc.face.length > 500)) return { error: 'The face must be an avatar ref of 1–500 characters.' };
+ if (typeof doc.instructions !== 'string' || !doc.instructions.trim() || doc.instructions.length > 4000) return { error: 'The instructions must be 1–4000 characters.' };
+ if (doc.rules !== undefined) {
+ if (!Array.isArray(doc.rules) || doc.rules.length > 20) return { error: 'The rules must be at most 20 lines.' };
+ for (const r of doc.rules) if (typeof r !== 'string' || !r.trim() || r.length > 200) return { error: 'Each rule must be 1–200 characters.' };
+ }
+ if (!levels.LEVELS.includes(doc.level)) return { error: 'The level must be one of ' + levels.LEVELS.join(', ') + '.' };
+ if (doc.model !== undefined) {
+ if (!isPlain(doc.model) || !ENGINE.includes(doc.model.engine)) return { error: 'The model engine must be one of ' + ENGINE.join(', ') + '.' };
+ if (typeof doc.model.model !== 'string' || !doc.model.model.trim() || doc.model.model.length > 120) return { error: 'The model id must be 1–120 characters.' };
+ }
+ if (doc.apps !== undefined) {
+ if (!Array.isArray(doc.apps) || doc.apps.length > 50) return { error: 'The apps must be at most 50 ids.' };
+ for (const a of doc.apps) if (typeof a !== 'string' || !APP.test(a)) return { error: 'Each app id must be a connect slug.' };
+ }
+ // The author is a claim: the document may name nobody, and when it names somebody, nothing here checks it.
+ if (doc.author !== undefined) {
+ if (!isPlain(doc.author)) return { error: 'The author must be a Network subject.' };
+ const author = { subject: String(doc.author.subject || '').replace(/^user:/, '') };
+ if (!SUBJECT.test(author.subject)) return { error: 'The author must be a Network subject (usr_…).' };
+ if (doc.author.name !== undefined) {
+ if (typeof doc.author.name !== 'string' || doc.author.name.length > 80) return { error: 'The author name must be at most 80 characters.' };
+ author.name = doc.author.name;
+ }
+ doc.author = author;
+ }
+ if (!isPlain(doc.integrity) || doc.integrity.alg !== 'sha256' || !HASH.test(String(doc.integrity.hash || ''))) return { error: 'The integrity block must be a sha256 hash.' };
+ if (doc.created_at !== undefined && (typeof doc.created_at !== 'string' || Number.isNaN(Date.parse(doc.created_at)))) return { error: 'created_at must be a timestamp.' };
+ if (hashOf(doc) !== doc.integrity.hash) return { error: 'This voice was changed after it was written: the hash does not match.' };
+ return { doc };
+}
+
+/** Does this document still match its own hash? */
+const verify = (doc) => { const r = read(doc); return r.error ? { ok: false, error: r.error } : { ok: true, doc: r.doc }; };
+
+// What an imported voice may bring with it, beyond its words (Opus's review of HO10):
+// a face: only one of OpenVibe's preset marks. A picture at an address the file names would load from that server
+// every time the person opens their agents (a tracking pixel), so it is not taken;
+// a model: only OpenVibe's own runtime or its free GPU models. "own-keys" would spend the borrower's own API money,
+// and "link" would run on the borrower's machine, at the say of a file somebody else wrote;
+// apps: none. An app id binds to the borrower's own connections (their mail, their files), so the borrower adds
+// each one themselves; the file's list is kept as a suggestion the agent's page shows.
+const PRESET_FACE = /^preset:[a-z0-9-]{1,40}$/;
+const IMPORT_ENGINES = ['runtime', 'open-gpu'];
+const DEFAULT_MODEL = { engine: 'runtime', model: 'smart' };
+
+/**
+ * The agent a document makes. The level is always ask, whatever the file says, and it keeps no memory.
+ * `authorChecked` says who may be vouched for: Actor's own record of a shared voice says yes, a file says only what
+ * it claims.
+ */
+function toContact(doc, { source = null, authorChecked = false } = {}) {
+ return {
+ name: doc.name.trim(),
+ bio: doc.bio || null,
+ face: doc.face && PRESET_FACE.test(doc.face) ? doc.face : 'preset:agent',
+ instructions: doc.instructions,
+ rules: doc.rules || [],
+ level: 'ask',
+ model: doc.model && IMPORT_ENGINES.includes(doc.model.engine) ? doc.model : DEFAULT_MODEL,
+ apps: [],
+ suggested_apps: doc.apps || [],
+ memory: null,
+ voice_author: doc.author ? doc.author.subject : null,
+ voice_author_name: doc.author && doc.author.name ? doc.author.name : null,
+ voice_author_checked: Boolean(authorChecked),
+ voice_source: source,
+ };
+}
+
+module.exports = { CONTRACT, FIELDS, PRESET_FACE, IMPORT_ENGINES, canonical, hashOf, build, read, verify, toContact, findNever };
@@ -9,6 +9,8 @@ const personal = require('../agents/personal');
@@ -9,6 +9,8 @@ const personal = require('../agents/personal');
const contacts = require('../agents/contacts');
const contacts = require('../agents/contacts');
const ui = require('./agent-contacts-ui');
const ui = require('./agent-contacts-ui');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
+const voice = require('../agents/voice');
+const shares = require('../agents/voice-shares');
const levels = require('../agents/levels');
const levels = require('../agents/levels');
const allows = require('../agents/allows');
const allows = require('../agents/allows');
const kinds = require('../agents/kinds');
const kinds = require('../agents/kinds');
@@ -47,7 +49,7 @@ function commandLine(p) {
@@ -47,7 +49,7 @@ function commandLine(p) {
const memoryStore = require('../memory');
const memoryStore = require('../memory');
function createAgentPages(ctx) {
function createAgentPages(ctx) {
- const { config, s } = ctx;
+ const { config, s, limits } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
@@ -125,7 +127,7 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}
@@ -125,7 +127,7 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}
<section class="pa-panel" aria-labelledby="ac-add-h"><h2 id="ac-add-h">Add an agent</h2><p class="pa-hint">Each comes ready with its instructions; change anything after.</p>${ui.presetCards(contacts.presets().presets)}</section>
<section class="pa-panel" aria-labelledby="ac-add-h"><h2 id="ac-add-h">Add an agent</h2><p class="pa-hint">Each comes ready with its instructions; change anything after.</p>${ui.presetCards(contacts.presets().presets)}</section>
<section class="pa-panel" aria-labelledby="pa-templates-h"><h2 id="pa-templates-h">Or set up a standing job</h2><p class="pa-hint">An agent with a schedule: it runs on its own and keeps notes between runs.</p>
<section class="pa-panel" aria-labelledby="pa-templates-h"><h2 id="pa-templates-h">Or set up a standing job</h2><p class="pa-hint">An agent with a schedule: it runs on its own and keeps notes between runs.</p>
<ul class="pa-templates">${TEMPLATES.map((x) => html`<li><a class="pa-template${chosen && chosen.id === x.id ? ' is-chosen' : ''}" href="/me/agents?template=${x.id}#create"><strong>${x.name}</strong><span>${x.line}</span><small>${when({ schedule: x.schedule, schedule_at: x.schedule_at, schedule_dow: x.schedule_dow })}</small></a></li>`)}</ul></section>
<ul class="pa-templates">${TEMPLATES.map((x) => html`<li><a class="pa-template${chosen && chosen.id === x.id ? ' is-chosen' : ''}" href="/me/agents?template=${x.id}#create"><strong>${x.name}</strong><span>${x.line}</span><small>${when({ schedule: x.schedule, schedule_at: x.schedule_at, schedule_dow: x.schedule_dow })}</small></a></li>`)}</ul></section>
-<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${chosen.name}` : 'Create an agent'}</h2>${chosen ? html`<p class="pa-hint">Fill in the parts in <angle brackets>, then create it. You can change anything later.</p>` : ''}<form method="post" action="/me/agents">${form(chosen ? { ...chosen, id: undefined } : {})}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);
+${importPanel()}<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${chosen.name}` : 'Create an agent'}</h2>${chosen ? html`<p class="pa-hint">Fill in the parts in <angle brackets>, then create it. You can change anything later.</p>` : ''}<form method="post" action="/me/agents">${form(chosen ? { ...chosen, id: undefined } : {})}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);
});
});
r.post('/me/agents', async (req, res) => {
r.post('/me/agents', async (req, res) => {
if (!write(req, res)) return;
if (!write(req, res)) return;
@@ -209,6 +211,152 @@ ${buttons}`);
@@ -209,6 +211,152 @@ ${buttons}`);
res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);
res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);
});
});
+ // ── An agent's voice as a file (actor.voice@1) ──────────────────────────────────────────────────────
+ // The panel on the agent's page: download its voice, share it as a public link, or import somebody else's.
+ // Nothing here is a signature: the document's integrity block says the file was not edited, never who wrote it.
+ const voicePanel = (a, doc, shared) => html`<section class="pa-panel" id="voice" aria-labelledby="ac-voice-h"><h2 id="ac-voice-h">Its voice</h2>
+<p class="pa-hint">An agent's voice is a small file: its name, a short bio, the house voice it speaks in, its rules, its level, and the model and apps it prefers (ids, never a key). Nothing else — never its memory, never a connector's token, never a file. Whoever you send it to can make an agent that speaks in this voice; it starts at level ask, and they raise it themselves.</p>
+${a.bio ? html`<p>${a.bio}</p>` : ''}
+${a.rules && a.rules.length ? html`<h3>Its rules</h3><ul>${a.rules.map((r) => html`<li>${r}</li>`)}</ul>` : ''}
+<p class="pa-actions"><a class="pa-btn" href="/me/agents/${a.id}/voice" download="voice.json">Download its voice (JSON)</a>
+${shared
+ ? html`<a class="pa-btn" href="/voices/${shared.id}">Its public link</a><form method="post" action="/me/agents/${a.id}/share/stop" class="pa-actions"><button type="submit" class="pa-btn">Stop sharing</button></form>`
+ : html`<form method="post" action="/me/agents/${a.id}/share" class="pa-actions"><button type="submit" class="pa-btn">Share this voice</button></form>`}
+<a class="pa-btn" href="/me/agents#import">Import a voice</a></p>
+${provenance(a)}${suggestedApps(a)}</section>`;
+
+ // Where this agent's voice came from, and what may be said about the author.
+ // a shared voice Actor shared it, so Actor knows who wrote it: the author is a link.
+ // a file the file may name anyone and nothing checks it: the author is a claim, said plainly.
+ const provenance = (a) => {
+ if (a.voice_source && String(a.voice_source).startsWith('shared:')) {
+ return html`<p class="pa-hint">Borrowed from a shared voice${a.voice_author_name ? html` by <a href="https://openvibe.network/@${a.voice_author_name}">${a.voice_author_name}</a>` : ''}.</p>`;
+ }
+ if (a.voice_source === 'file') {
+ return html`<p class="pa-hint">Imported from a file.${a.voice_author_name ? html` It says it was made by ${a.voice_author_name}; that is not checked.` : ''}</p>`;
+ }
+ return '';
+ };
+ // The apps the voice it came from used: never connected for it, listed so the person can add the ones they want.
+ const suggestedApps = (a) => {
+ let list = a.voice_suggested_apps;
+ if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = []; } }
+ return Array.isArray(list) && list.length && a.voice_source
+ ? html`<p class="pa-hint">The voice it came from used ${list.join(', ')}. None is connected for it: add the ones you want under its apps.</p>`
+ : '';
+ };
+
+ const importPanel = () => html`<section class="pa-panel" id="import" aria-labelledby="ac-import-h"><h2 id="ac-import-h">Import an agent's voice</h2>
+<p class="pa-hint">A voice file is a small JSON document: an agent's name, a short bio, the house voice it speaks in, its rules, its level, and the model and apps it prefers. It never carries memory, a connector's token or a file. Paste one and it becomes one of your agents, at level ask — you raise it yourself.</p>
+<p class="pa-hint">A file may name an author. Nothing checks that, so the agent will say so rather than claim it is that person's.</p>
+<form method="post" action="/me/agents/import"><label class="pa-wide">Voice file (JSON) <textarea name="voice" rows="6" required placeholder='{"voice":"actor.voice@1","name":"A house voice"}'></textarea></label><button type="submit" class="pa-btn pa-primary">Import this voice</button></form></section>`;
+
+ // The agent's voice as a file: the owner downloads it, and another person's agent is the same 404 as one that does
+ // not exist.
+ r.get('/me/agents/:id/voice', async (req, res) => {
+ if (!signed(req)) return login(req, res);
+ const a = await find(req, res); if (!a) return;
+ const doc = voice.build(a, { subject: req.viewer.subject, name: req.viewer.username || null });
+ res.set('Content-Type', 'application/json; charset=utf-8');
+ res.set('Content-Disposition', 'attachment; filename="voice.json"');
+ res.send(JSON.stringify(doc, null, 2));
+ });
+
+ // Import a voice from a file: a pasted actor.voice@1 document becomes a new agent, at level ask whatever the file
+ // says. The file may name an author; nothing checks that, and the page says so.
+ r.post('/me/agents/import', async (req, res) => {
+ if (!write(req, res)) return;
+ const text = String((req.body || {}).voice || '');
+ let input;
+ try { input = JSON.parse(text); } catch { return voicePage(req, res, 'That file is not JSON.', 422); }
+ const out = await importVoice(req, input, { source: 'file' });
+ if (out.error) return voicePage(req, res, out.error, out.limit ? 429 : 422);
+ res.redirect(303, `/me/agents/${out.row.id}`);
+ });
+
+ // Share this voice: a public link (/voices/<id>) that anybody may borrow from. Actor's own record of who shared
+ // it — the only place a voice's author is vouched for.
+ r.post('/me/agents/:id/share', async (req, res) => {
+ if (!write(req, res)) return;
+ const a = await find(req, res); if (!a) return;
+ const doc = voice.build(a, { subject: req.viewer.subject, name: req.viewer.username || null });
+ const shared = await shares.share(s, owner(req), a, doc, req.viewer.username || null);
+ res.redirect(303, `/voices/${shared.id}`);
+ });
+ // Stop sharing: the link answers 410 from then on.
+ r.post('/me/agents/:id/share/stop', async (req, res) => {
+ if (!write(req, res)) return;
+ const a = await find(req, res); if (!a) return;
+ await shares.revoke(s, owner(req), a.id);
+ res.redirect(303, `/me/agents/${a.id}#voice`);
+ });
+
+ // The shared voice: anybody may look at it and borrow it. The author is Actor's own record, so it is a link.
+ r.get('/voices/:id.json', async (req, res) => {
+ const row = await shares.get(s, req.params.id);
+ if (!row) return res.status(404).json({ error: 'No such voice.' });
+ if (row.revoked_at) return res.status(410).json({ error: 'This voice is no longer shared.' });
+ res.set('Content-Type', 'application/json; charset=utf-8');
+ res.json(row.voice);
+ });
+ r.get('/voices/:id', async (req, res) => {
+ const id = String(req.params.id || '').replace(/\.json$/, '');
+ const row = await shares.get(s, id);
+ if (!row) return page(req, res, 'Voice not found', html`<h1>Voice not found</h1><p>That link is not a voice.</p>`, 404);
+ if (row.revoked_at) return page(req, res, 'Voice not shared', html`<h1>This voice is no longer shared</h1><p>Its owner stopped sharing it. Ask them for a new link.</p>`, 410);
+ const doc = voice.read(row.voice).doc || row.voice;
+ page(req, res, doc.name, html`<p class="crumbs"><a href="/">OpenVibe.Actor</a></p>
+<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · a shared voice</p><h1>${doc.name}</h1>
+${doc.bio ? html`<p class="pa-lede">${doc.bio}</p>` : ''}</header>
+<section class="pa-panel"><h2>Its instructions</h2><p class="pa-hint">What it is told to do on every run. Borrowing it gives your agent exactly these words; read them first.</p><pre class="pa-pre">${doc.instructions}</pre></section>
+<section class="pa-panel"><h2>Its rules</h2>${doc.rules && doc.rules.length ? html`<ul>${doc.rules.map((r) => html`<li>${r}</li>`)}</ul>` : html`<p>No rules: it speaks in one voice.</p>`}</section>
+${doc.apps && doc.apps.length ? html`<section class="pa-panel"><h2>The apps it uses</h2><p>${doc.apps.join(', ')}</p><p class="pa-hint">A borrowed agent gets none of them: you connect each one yourself, with your own account, on its page.</p></section>` : ''}
+<section class="pa-panel"><h2>Borrow this agent</h2>
+<p class="pa-hint">It asks for level ${doc.level}. Borrowing it makes an agent of your own — at level ask, whatever the file asks for — and you raise it yourself. It keeps no memory and no token of the person who shared it, runs on OpenVibe's own models (never your own keys or your machine until you choose them), and gets none of your apps until you add them.</p>
+${row.owner_name ? html`<p class="pa-hint">Shared by <a href="https://openvibe.network/@${row.owner_name}">${row.owner_name}</a>.</p>` : ''}
+${signed(req) ? html`<form method="post" action="/voices/${row.id}/borrow"><button type="submit" class="pa-btn pa-primary">Borrow this agent</button></form>`
+ : html`<p><a class="pa-btn pa-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in to borrow it</a></p>`}</section>`);
+ });
+ // Borrowing a shared voice: the author comes from Actor's record, not from the document.
+ r.post('/voices/:id/borrow', limits.budget('actor.voice.borrow'), async (req, res) => {
+ if (!signed(req)) return login(req, res);
+ if (!write(req, res)) return;
+ const row = await shares.get(s, req.params.id);
+ if (!row) return page(req, res, 'Voice not found', html`<h1>Voice not found</h1>`, 404);
+ if (row.revoked_at) return page(req, res, 'Voice not shared', html`<h1>This voice is no longer shared</h1>`, 410);
+ const read = voice.read(row.voice);
+ if (read.error) return voicePage(req, res, read.error, 422);
+ const out = await importVoice(req, read.doc, {
+ source: `shared:${row.id}`, authorChecked: true,
+ author: { subject: String(row.owner || '').replace(/^user:/, ''), name: row.owner_name || null },
+ });
+ if (out.error) return voicePage(req, res, out.error, out.limit ? 429 : 422);
+ res.redirect(303, `/me/agents/${out.row.id}`);
+ });
+
+ // The page a refused or broken voice ends on: what went wrong, in plain words.
+ function voicePage(req, res, message, status = 422) {
+ return page(req, res, 'Voice not taken', html`<h1>Voice not taken</h1><p>${message}</p><p><a href="/me/agents">Your agents</a></p>`, status);
+ }
+
+ // A voice document becomes an agent: the new agent keeps the voice, starts at level ask and keeps no memory.
+ async function importVoice(req, input, { source = 'file', author = null, authorChecked = false } = {}) {
+ const read = voice.read(input);
+ if (read.error) return { error: read.error };
+ const fields = voice.toContact(read.doc, { source, authorChecked });
+ if (author) {
+ fields.voice_author = author.subject;
+ fields.voice_author_name = author.name || null;
+ }
+ const out = await contacts.create(s, config, owner(req), {
+ name: fields.name, face: fields.face, model: fields.model, apps: fields.apps, memory: null,
+ level: fields.level, instructions: fields.instructions,
+ }, { bio: fields.bio, rules: fields.rules, suggested_apps: fields.suggested_apps, voice_author: fields.voice_author, voice_author_name: fields.voice_author_name,
+ voice_author_checked: fields.voice_author_checked, voice_source: fields.voice_source });
+ if (!out.row) return { error: out.error || 'Agent limit reached.', limit: out.limit };
+ return { row: out.row };
+ }
+
r.get('/me/agents/:id', async (req, res) => {
r.get('/me/agents/:id', async (req, res) => {
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
const a = await find(req, res); if (!a) return;
const a = await find(req, res); if (!a) return;
@@ -234,6 +382,10 @@ ${buttons}`);
@@ -234,6 +382,10 @@ ${buttons}`);
const usd = (v) => (v == null || !Number.isFinite(Number(v)) ? '' : ` · $${Number(v) < 0.01 ? Number(v).toFixed(4) : Number(v).toFixed(2)}`);
const usd = (v) => (v == null || !Number.isFinite(Number(v)) ? '' : ` · $${Number(v) < 0.01 ? Number(v).toFixed(4) : Number(v).toFixed(2)}`);
const pill = (state) => state === 'succeeded' ? 'pill-succeeded' : state === 'failed' || state === 'cancelled' ? 'pill-failed' : 'pill-running';
const pill = (state) => state === 'succeeded' ? 'pill-succeeded' : state === 'failed' || state === 'cancelled' ? 'pill-failed' : 'pill-running';
const contact = contacts.wire(a);
const contact = contacts.wire(a);
+ // The agent's voice as a file (actor.voice@1): what it is, in a form somebody else can speak in.
+ const doc = voice.build(a, { subject: req.viewer.subject, name: req.viewer.username || null });
+ // The voice's public link, when the person shares it.
+ const shared = await shares.current(s, a.owner, a.id);
page(req, res, a.name, html`<p class="crumbs"><a href="/me/agents">Your agents</a></p>
page(req, res, a.name, html`<p class="crumbs"><a href="/me/agents">Your agents</a></p>
<header class="pa-head ac-sheet-head">${ui.face(contact, 64)}<div><h1>${a.name}</h1><p class="ac-sheet-uses">${ui.usesLine(contact)}</p>
<header class="pa-head ac-sheet-head">${ui.face(contact, 64)}<div><h1>${a.name}</h1><p class="ac-sheet-uses">${ui.usesLine(contact)}</p>
<p class="pa-lede">${ui.levelChip(contact.level)} <span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span> ${when(a)}${a.next_run_at && a.status !== 'paused' && a.schedule !== 'off' ? html` · next ${time(a.next_run_at)}` : ''}</p></div></header>
<p class="pa-lede">${ui.levelChip(contact.level)} <span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span> ${when(a)}${a.next_run_at && a.status !== 'paused' && a.schedule !== 'off' ? html` · next ${time(a.next_run_at)}` : ''}</p></div></header>
@@ -258,7 +410,7 @@ ${pendingSuggestions.length ? html`<ul class="pa-notes">${pendingSuggestions.map
@@ -258,7 +410,7 @@ ${pendingSuggestions.length ? html`<ul class="pa-notes">${pendingSuggestions.map
<form method="post" action="/me/agents/${a.id}/suggestions/${p.id}" class="pa-actions"><button type="submit" name="decision" value="apply" class="pa-btn pa-primary">Add to its instructions</button> <button type="submit" name="decision" value="dismiss" class="pa-btn">Dismiss</button></form></li>`)}</ul>` : ''}
<form method="post" action="/me/agents/${a.id}/suggestions/${p.id}" class="pa-actions"><button type="submit" name="decision" value="apply" class="pa-btn pa-primary">Add to its instructions</button> <button type="submit" name="decision" value="dismiss" class="pa-btn">Dismiss</button></form></li>`)}</ul>` : ''}
${applied.length ? html`<details><summary>Added lately</summary><ul class="pa-notes">${applied.map((p) => html`<li><p class="pa-note-text">${p.text}</p><p class="pa-note-meta">Added ${time(p.decided_at)}</p>
${applied.length ? html`<details><summary>Added lately</summary><ul class="pa-notes">${applied.map((p) => html`<li><p class="pa-note-text">${p.text}</p><p class="pa-note-meta">Added ${time(p.decided_at)}</p>
<form method="post" action="/me/agents/${a.id}/suggestions/${p.id}"><button type="submit" name="decision" value="undo" class="pa-danger-link">Undo</button></form></li>`)}</ul></details>` : ''}</section>` : ''}
<form method="post" action="/me/agents/${a.id}/suggestions/${p.id}"><button type="submit" name="decision" value="undo" class="pa-danger-link">Undo</button></form></li>`)}</ul></details>` : ''}</section>` : ''}
-<section class="pa-panel"><h2>Memory</h2><p class="pa-hint">What this agent remembers, newest first. Only active notes go into a run. Notes you write are never dropped; when memory is full (50 notes) the oldest note the agent wrote goes first. An agent adds at most one note per run, and only when its answer passed the check; its note is a suggestion until you accept it on <a href="/memory">Memory</a>.</p>
+${voicePanel(a, doc, shared)}<section class="pa-panel"><h2>Memory</h2><p class="pa-hint">What this agent remembers, newest first. Only active notes go into a run. Notes you write are never dropped; when memory is full (50 notes) the oldest note the agent wrote goes first. An agent adds at most one note per run, and only when its answer passed the check; its note is a suggestion until you accept it on <a href="/memory">Memory</a>.</p>
${notes.length ? html`<ul class="pa-notes">${notes.map((n) => html`<li><p class="pa-note-text">${n.text}</p><p class="pa-note-meta"><span class="pill ${n.status === 'suggested' ? 'pill-running' : n.status === 'archived' ? 'pill-failed' : 'pill-succeeded'}">${n.status === 'suggested' ? 'Suggested' : n.status === 'archived' ? 'Archived' : 'Active'}</span> ${n.source === 'agent' ? 'Written by the agent' : 'Written by you'} · ${n.kind} · ${time(n.created_at)}${n.last_used_at ? html` · last used ${time(n.last_used_at)}` : ''}</p>
${notes.length ? html`<ul class="pa-notes">${notes.map((n) => html`<li><p class="pa-note-text">${n.text}</p><p class="pa-note-meta"><span class="pill ${n.status === 'suggested' ? 'pill-running' : n.status === 'archived' ? 'pill-failed' : 'pill-succeeded'}">${n.status === 'suggested' ? 'Suggested' : n.status === 'archived' ? 'Archived' : 'Active'}</span> ${n.source === 'agent' ? 'Written by the agent' : 'Written by you'} · ${n.kind} · ${time(n.created_at)}${n.last_used_at ? html` · last used ${time(n.last_used_at)}` : ''}</p>
${n.status === 'suggested' ? html`<form method="post" action="/memory/${n.id}/accept" class="pa-actions"><button type="submit" class="pa-btn pa-primary">Accept</button> <button type="submit" class="pa-btn" formaction="/memory/${n.id}/decline">Decline</button></form>` : ''}
${n.status === 'suggested' ? html`<form method="post" action="/memory/${n.id}/accept" class="pa-actions"><button type="submit" class="pa-btn pa-primary">Accept</button> <button type="submit" class="pa-btn" formaction="/memory/${n.id}/decline">Decline</button></form>` : ''}
<details><summary>Edit or delete</summary><form method="post" action="/me/agents/${a.id}/memory/${n.id}/edit"><label>Note <input name="text" maxlength="${require('../memory').MAX_TEXT}" value="${n.text}"></label><button type="submit" class="pa-btn">Save</button></form>
<details><summary>Edit or delete</summary><form method="post" action="/me/agents/${a.id}/memory/${n.id}/edit"><label>Note <input name="text" maxlength="${require('../memory').MAX_TEXT}" value="${n.text}"></label><button type="submit" class="pa-btn">Save</button></form>
@@ -37,6 +37,7 @@ const { findArtifact, sendArtifact } = require('../runtime/artifacts');
@@ -37,6 +37,7 @@ const { findArtifact, sendArtifact } = require('../runtime/artifacts');
const trace = require('../runtime/trace');
const trace = require('../runtime/trace');
const personal = require('../agents/personal');
const personal = require('../agents/personal');
const contacts = require('../agents/contacts');
const contacts = require('../agents/contacts');
+const voice = require('../agents/voice');
const memoryStore = require('../memory');
const memoryStore = require('../memory');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
const items = require('../actions/items');
const items = require('../actions/items');
@@ -278,6 +279,29 @@ function createApi(ctx) {
@@ -278,6 +279,29 @@ function createApi(ctx) {
r.get('/me/agents/:id', principal.requireCapability('actor.agent.manage'), owner, limits.reads('actor.agent.manage'), async (req, res) => {
r.get('/me/agents/:id', principal.requireCapability('actor.agent.manage'), owner, limits.reads('actor.agent.manage'), async (req, res) => {
const row = await loadAgent(req, res); if (row) res.json(row);
const row = await loadAgent(req, res); if (row) res.json(row);
});
});
+ // The agent's voice as a file (actor.voice@1). Another person's agent is the same 404 as one that does not exist.
+ r.get('/me/agents/:id/voice', principal.requireCapability('actor.agent.manage'), owner, limits.reads('actor.agent.manage'), async (req, res) => {
+ const row = await loadAgent(req, res); if (!row) return;
+ const subject = (req.principal && req.principal.subject) || String((req.principal && req.principal.requester) || '').replace(/^user:/, '');
+ const doc = voice.build(row, { subject });
+ res.set('Content-Disposition', 'attachment; filename="voice.json"');
+ res.json(doc);
+ });
+ // Import a voice: an actor.voice@1 document becomes a new agent, at level ask whatever the file says.
+ r.post('/me/agents/import', principal.requireCapability('actor.agent.manage'), owner, limits.budget('actor.agent.manage'), async (req, res) => {
+ const body = req.body || {};
+ const read = voice.read(body.voice || body.document || null);
+ if (read.error) return problem(req, res, 422, 'actor.voice.invalid', read.error);
+ const fields = voice.toContact(read.doc, { source: 'file' });
+ const out = await contacts.create(s, config, req.principal.requester, {
+ name: fields.name, face: fields.face, model: fields.model, apps: fields.apps, memory: null,
+ level: fields.level, instructions: fields.instructions,
+ }, { bio: fields.bio, rules: fields.rules, suggested_apps: fields.suggested_apps, voice_author: fields.voice_author, voice_author_name: fields.voice_author_name,
+ voice_author_checked: fields.voice_author_checked, voice_source: fields.voice_source });
+ if (!out.row) return failed(req, res, out);
+ res.set('Location', `/api/v1/me/agents/${out.row.id}`);
+ res.status(201).json(out.row);
+ });
r.patch('/me/agents/:id', principal.requireCapability('actor.agent.manage'), owner, limits.budget('actor.agent.manage'), async (req, res) => {
r.patch('/me/agents/:id', principal.requireCapability('actor.agent.manage'), owner, limits.budget('actor.agent.manage'), async (req, res) => {
const out = await personal.update(s, config, req.principal.requester, req.params.id, req.body);
const out = await personal.update(s, config, req.principal.requester, req.params.id, req.body);
if (!out) return problem(req, res, 404, 'actor.agent.not_found', 'No such agent.');
if (!out) return problem(req, res, 404, 'actor.agent.not_found', 'No such agent.');
@@ -39,6 +39,8 @@ const BUDGETS = {
@@ -39,6 +39,8 @@ const BUDGETS = {
'actor.assignment.cancel': { minute: 30, hour: 600 },
'actor.assignment.cancel': { minute: 30, hour: 600 },
// The kill switch and the other controls of the agent (design 2026-11-07).
// The kill switch and the other controls of the agent (design 2026-11-07).
'actor.resource.control': { minute: 30, hour: 600 },
'actor.resource.control': { minute: 30, hour: 600 },
+ // Borrowing a shared voice: a person may take a few voices an hour, not a few a minute.
+ 'actor.voice.borrow': { minute: 6, hour: 60 },
};
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
@@ -68,6 +68,10 @@ const TABLES = [
@@ -68,6 +68,10 @@ const TABLES = [
// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).
// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).
{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },
{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
+ // 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',
+ columns: ['id', 'owner', 'owner_name', 'agent_id', 'voice', 'created_at', 'revoked_at'] },
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
@@ -31,6 +31,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -31,6 +31,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.resource.control': { minute: 30, hour: 600 },
'actor.resource.control': { minute: 30, hour: 600 },
'actor.assignment.cancel': { minute: 30, hour: 600 },
'actor.assignment.cancel': { minute: 30, hour: 600 },
+ 'actor.voice.borrow': { minute: 6, hour: 60 },
});
});
});
});
@@ -38,6 +38,11 @@ const ROUTE_EXTENSIONS = new Map([
@@ -38,6 +38,11 @@ const ROUTE_EXTENSIONS = new Map([
['POST /api/v1/triggers/:id/resume', 'actor.task.create'],
['POST /api/v1/triggers/:id/resume', 'actor.task.create'],
['POST /api/v1/triggers/:id/run', 'actor.task.create'],
['POST /api/v1/triggers/:id/run', 'actor.task.create'],
['DELETE /api/v1/triggers/:id', 'actor.task.create'],
['DELETE /api/v1/triggers/:id', 'actor.task.create'],
+ // An agent's voice as a file (actor.voice@1): the person's own agent and the voice they import into a new
+ // one. The routes exist before Contracts lists a voice capability; the agent capability they already hold
+ // guards them.
+ ['GET /api/v1/me/agents/:id/voice', 'actor.agent.manage'],
+ ['POST /api/v1/me/agents/import', 'actor.agent.manage'],
]);
]);
const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
// The A2A surface (stage V2-11): POST /a2a/v1/tasks and GET /a2a/v1/tasks/:id, guarded by actor.a2a.task.
// The A2A surface (stage V2-11): POST /a2a/v1/tasks and GET /a2a/v1/tasks/:id, guarded by actor.a2a.task.
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
+'use strict';
+/**
+ * An agent's voice as a file (actor.voice@1), and a voice shared by its owner.
+ *
+ * The document carries an integrity hash, not a signature: it shows the file was not edited after it was written, and
+ * says nothing about who wrote it. So the author in a file is only a claim, and the page says so; the only author
+ * Actor vouches for is the one in its own record of a shared voice.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const voice = require('../server/agents/voice');
+const { BUDGETS } = require('../server/http/caller-limits');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+(async () => {
+ const t = await boot();
+ try {
+ const alice = t.network.addUser('voice-alice');
+ const bob = t.network.addUser('voice-bob');
+ let agentId = null;
+ let doc = null;
+ let shareId = null;
+
+ await check('the owner exports an actor.voice@1 document, and it carries no memory, token or file', async () => {
+ const created = await t.get('/api/v1/agents', { as: alice, json: { name: 'Researcher', instructions: 'Research carefully.', model: { engine: 'runtime', model: 'smart' } }, headers: SAME });
+ assert.strictEqual(created.status, 201, created.text);
+ agentId = created.json().id;
+ const res = await t.get(`/api/v1/me/agents/${agentId}/voice`, { as: alice });
+ assert.strictEqual(res.status, 200, res.text);
+ doc = res.json();
+ assert.strictEqual(doc.voice, 'actor.voice@1');
+ assert.strictEqual(doc.name, 'Researcher');
+ assert.strictEqual(doc.instructions, 'Research carefully.');
+ assert.strictEqual(doc.level, 'ask');
+ assert.strictEqual(doc.author.subject, alice.subject);
+ assert.ok(doc.integrity && doc.integrity.alg === 'sha256', 'the integrity block');
+ assert.match(doc.integrity.hash, /^[0-9a-f]{64}$/);
+ assert.ok(!doc.signature, 'there is no signature block');
+ const text = JSON.stringify(doc);
+ for (const word of ['memory', 'token', 'mems_', 'sk-']) assert.ok(!text.includes(word), `the document carries no ${word}`);
+ });
+
+ await check('another person cannot export it: the same 404 as an agent that does not exist', async () => {
+ const res = await t.get(`/api/v1/me/agents/${agentId}/voice`, { as: bob });
+ assert.strictEqual(res.status, 404, res.text);
+ });
+
+ await check('a voice imports as a new agent: the voice round-trips, and no memory or token comes with it', async () => {
+ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: doc }, headers: SAME });
+ assert.strictEqual(res.status, 201, res.text);
+ const agent = res.json();
+ assert.strictEqual(agent.name, doc.name);
+ assert.strictEqual(agent.instructions, doc.instructions);
+ assert.deepStrictEqual(agent.apps, doc.apps || []);
+ assert.strictEqual(agent.voice_author, alice.subject);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM actor_agent_memory WHERE agent_id = $1', [agent.id])), 0, 'no memory');
+ });
+
+ await check('an imported agent starts at Ask, whatever the file says', async () => {
+ const bold = voice.build({ name: 'Bold', instructions: 'Act.', level: 'full', apps: [], rules: [], model_engine: 'runtime', model_id: 'smart' }, { subject: alice.subject });
+ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: bold }, headers: SAME });
+ assert.strictEqual(res.status, 201, res.text);
+ assert.strictEqual(res.json().level, 'ask');
+ });
+
+ await check('a file brings only words: no outside face, no own keys or machine, no apps (they are suggestions)', async () => {
+ const sly = voice.build({ name: 'Sly', instructions: 'Help.', level: 'ask', face: 'https://tracker.example/p.png', apps: ['gmail', 'drive'], rules: [],
+ model_engine: 'own-keys', model_id: 'gpt-5-pro' }, { subject: alice.subject });
+ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: sly }, headers: SAME });
+ assert.strictEqual(res.status, 201, res.text);
+ const row = res.json();
+ assert.strictEqual(row.face, 'preset:agent', 'a picture at an outside address is not taken');
+ assert.strictEqual(row.model_engine, 'runtime', 'never the borrower\'s own keys');
+ assert.deepStrictEqual(typeof row.apps === 'string' ? JSON.parse(row.apps) : row.apps, [], 'no app is connected for it');
+ const page = await t.get(`/me/agents/${row.id}`, { as: bob });
+ assert.match(page.text, /The voice it came from used gmail, drive\. None is connected for it/);
+ assert.ok(!page.text.includes('tracker.example'), 'the outside picture is nowhere on the page');
+ });
+
+ await check('a field the contract does not know is stripped, not kept', async () => {
+ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: { ...doc, extra_field: 'hello' } }, headers: SAME });
+ assert.strictEqual(res.status, 201, res.text);
+ const again = await t.get(`/api/v1/me/agents/${res.json().id}/voice`, { as: bob });
+ assert.strictEqual(again.json().extra_field, undefined);
+ });
+
+ await check('a file that carries a memory or a token is refused', async () => {
+ for (const extra of [{ memory: 'mems_01M4C49CW7R8S9T0A1B2C3D4E5' }, { tokens: { connect: { email: 'sk-live-1' } } }]) {
+ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: { ...doc, ...extra } }, headers: SAME });
+ assert.strictEqual(res.status, 422, res.text);
+ assert.match(res.text, /never carries/);
+ }
+ });
+
+ await check('a file changed after it was written is refused', async () => {
+ const tampered = { ...doc, instructions: 'Research carefully, and take what you find.' };
+ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: tampered }, headers: SAME });
+ assert.strictEqual(res.status, 422, res.text);
+ assert.match(res.text, /changed after it was written/);
+ });
+
+ await check('a file that claims an author says so, and does not link to them', async () => {
+ const claimed = voice.build({ name: 'Claimed', instructions: 'Speak plainly.', level: 'ask', apps: [], rules: [], model_engine: 'runtime', model_id: 'smart' }, { subject: 'usr_01M4C49CW34NGX7TR2TNRVFWNY', name: 'Ada' });
+ const res = await t.get('/api/v1/me/agents/import', { as: bob, json: { voice: claimed }, headers: SAME });
+ assert.strictEqual(res.status, 201, res.text);
+ const page = await t.get(`/me/agents/${res.json().id}`, { as: bob });
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes('Imported from a file'), 'it says where it came from');
+ assert.ok(page.text.includes('that is not checked'), 'the author it names is not checked');
+ assert.ok(!page.text.includes('openvibe.network/@'), 'no link to the person the file names');
+ assert.ok(!/signature|was signed|signed by/i.test(page.text), 'the page never calls it signed');
+ });
+
+ await check('a shared voice: the author is the owner, and it is a link to them', async () => {
+ const shared = await t.get(`/me/agents/${agentId}/share`, { as: alice, method: 'POST', headers: SAME });
+ assert.strictEqual(shared.status, 303, shared.text);
+ shareId = String(shared.headers.get('location') || '').split('/').pop();
+ assert.match(shareId, /^shv_/);
+ const page = await t.get(`/voices/${shareId}`);
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes('Borrow this agent'), 'the borrow button');
+ assert.ok(page.text.includes('Its instructions') && page.text.includes('Research carefully.'), 'the borrower reads the instructions first');
+ assert.ok(page.text.includes('openvibe.network/@'), 'the author is a link');
+ assert.ok(!/signature|was signed|signed by/i.test(page.text), 'the page never calls it signed');
+ const json = await t.get(`/voices/${shareId}.json`);
+ assert.strictEqual(json.status, 200, json.text);
+ assert.strictEqual(json.json().author.subject, alice.subject, 'the record names the owner');
+ assert.strictEqual(voice.read(json.json()).error, undefined, 'the shared document matches its own hash');
+ // Borrowing it: the agent knows who wrote it, and starts at Ask.
+ const borrowed = await t.get(`/voices/${shareId}/borrow`, { as: bob, method: 'POST', headers: SAME });
+ assert.strictEqual(borrowed.status, 303, borrowed.text);
+ const newId = String(borrowed.headers.get('location') || '').split('/').pop();
+ const agent = await t.get(`/api/v1/me/agents/${newId}`, { as: bob });
+ assert.strictEqual(agent.json().level, 'ask', 'a borrowed agent starts at Ask');
+ assert.strictEqual(agent.json().voice_author, alice.subject);
+ assert.strictEqual(agent.json().voice_author_checked, true, 'Actor vouches for this author');
+ const agentPage = await t.get(`/me/agents/${newId}`, { as: bob });
+ assert.ok(agentPage.text.includes('Borrowed from a shared voice'), 'the source');
+ assert.ok(agentPage.text.includes('openvibe.network/@'), 'the author is a link');
+ });
+
+ await check('stopping the share makes the link answer 410, and the page says so', async () => {
+ const stop = await t.get(`/me/agents/${agentId}/share/stop`, { as: alice, method: 'POST', headers: SAME });
+ assert.strictEqual(stop.status, 303, stop.text);
+ const page = await t.get(`/voices/${shareId}`);
+ assert.strictEqual(page.status, 410, page.text);
+ assert.ok(page.text.includes('no longer shared'), 'the page says so');
+ const json = await t.get(`/voices/${shareId}.json`);
+ assert.strictEqual(json.status, 410, json.text);
+ });
+
+ await check('another person cannot share somebody else\'s agent', async () => {
+ const res = await t.get(`/me/agents/${agentId}/share`, { as: bob, method: 'POST', headers: SAME });
+ assert.strictEqual(res.status, 404, res.text);
+ });
+
+ await check('borrowing is rate-limited per person', async () => {
+ assert.ok(BUDGETS['actor.voice.borrow'], 'the borrow has its own budget');
+ assert.ok(BUDGETS['actor.voice.borrow'].hour <= 120, 'and it is not generous');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();