openvibers/openvibe.actor Commits

An agent's voice as a file: export, import, borrow (#330)

OP OpenVibers committed 5 days ago · 925297227b · 13 files, +623 −7

* An agent's voice as a file: export, import, borrow

The agent's page exports its voice as an actor.voice@1 JSON document, imports one (a pasted
file or a document at a link somebody shared), and borrows a voice from a link: the new agent
keeps the voice, starts at level ask whatever the file says, and keeps no memory.

Validation refuses anything outside the contract and strips what it does not know; a file that
carries memory, a connector's token or a file is refused, and a file changed after it was
signed no longer matches its hash.

The signature is the author's Network subject, a sha256 over the canonical JSON of the document
and the timestamp it was written at: Network has no per-subject signing key today, so the content
hash is the signature.

* Keep the author's name in a voice document: the hash covers it

The reader rebuilt the author without its name before the hash was checked, so a document
signed by a named author no longer matched itself and was refused.

* A voice is shared by its owner, and its author is vouched for only there

A content hash is not a signature: it shows the file was not edited, never who wrote it. So the
document carries an integrity block, and the author in a file is only a claim — an agent imported
from a file says so and links to nobody.

Sharing lives here, in the service that owns the agent: Share this voice makes a public link
(/voices/<id>) with the bio, the rules, the level it asks for and Borrow. The author on that page
is Actor's own record of who shared it, so it is a link to them. The owner can stop sharing: the
link answers 410 and the page says so. Borrowing is rate-limited per person.

* The shared voices go with the person: they are in their account export

A shared voice is their own text and their own link, so it is exported and erased with the rest
of their rows.

* The README says what a voice is, and what its hash is not

* The budgets are pinned, and the voice's routes are listed

The borrow budget joins the pinned list, and the two voice routes are listed as extensions
until Contracts names a voice capability: they are the person's own agent, guarded by the
capability they already hold.

* HO10 review: a borrowed voice brings words, never a picture from outside, your keys, your machine or your apps

- face: only OpenVibe's preset marks (an outside picture loads from that server each time: a tracking pixel);
- model: OpenVibe's runtime or free GPU models only (own-keys spends the borrower's money, link runs on their machine);
- apps: none connected; the file's list is kept as a suggestion the agent's page shows;
- the public voice page shows the full instructions and the apps before Borrow.

* HO10's migrations go after HO1's 0038: 0039 the voice columns, 0040 the shared voices

Changes

ddiff --git a/README.md b/README.md
iindex 477325d..5da90d7 100644
---- a/README.md
++++ b/README.md
@@@ -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 own
ddiff --git a/migrations/0039_actor_voice.sql b/migrations/0039_actor_voice.sql
nnew file mode 100644
iindex 0000000..eb9edc2
---- /dev/null
++++ b/migrations/0039_actor_voice.sql
@@@ -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;
ddiff --git a/migrations/0040_agent_shared_voices.sql b/migrations/0040_agent_shared_voices.sql
nnew file mode 100644
iindex 0000000..d779db0
---- /dev/null
++++ b/migrations/0040_agent_shared_voices.sql
@@@ -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;
ddiff --git a/server/agents/contacts.js b/server/agents/contacts.js
iindex ffbb2ed..d539daf 100644
---- a/server/agents/contacts.js
++++ b/server/agents/contacts.js
@@@ -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 };
}
ddiff --git a/server/agents/voice-shares.js b/server/agents/voice-shares.js
nnew file mode 100644
iindex 0000000..cb85163
---- /dev/null
++++ b/server/agents/voice-shares.js
@@@ -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 };
ddiff --git a/server/agents/voice.js b/server/agents/voice.js
nnew file mode 100644
iindex 0000000..2276752
---- /dev/null
++++ b/server/agents/voice.js
@@@ -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 };
ddiff --git a/server/http/agent-pages.js b/server/http/agent-pages.js
iindex 5c45a51..42499f2 100644
---- a/server/http/agent-pages.js
++++ b/server/http/agent-pages.js
@@@ -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 &lt;angle brackets&gt;, 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 &lt;angle brackets&gt;, 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>
ddiff --git a/server/http/api.js b/server/http/api.js
iindex df7dba0..c11d5bb 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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.');
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex f6f074c..b836693 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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 }) {
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex cbde119..1d31bae 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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' },
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex a327af1..450c244 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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 },
});
});
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex a88c425..97123ef 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -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.
ddiff --git a/test/voice.test.js b/test/voice.test.js
nnew file mode 100644
iindex 0000000..b5af04f
---- /dev/null
++++ b/test/voice.test.js
@@@ -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();
++})();