openvibers/openvibe.exchange Commits

Companions S5 and S6: group chats, and the answers read aloud in each character's own voice (#101)

OP OpenVibers committed 6 days ago · 787044d69e · 13 files, +994 −12

* Companions S5 and S6: group chats, and the answers read aloud in each character's own voice

Step 5: two to five characters and the person in one chat. The characters take turns by a simple rule - the one the person named, one after another, or let them talk for a few turns each - and each of them keeps its own memory (the notes of its own M2 scope, project:companions-<character id>) and its own voice. The conversation is shared; the memory is not.\n\nStep 6, the voice: an answer is read aloud in the voice the card names, once, through OpenVibe.Chat's TTS, called with the person's own Network token, so the read-aloud is on the person's own allowance and on Chat's per-person rate limit. The sound is kept here and played from this site's own origin, so a strict CSP needs no exception. The site's own voices only; a cloned voice is never asked for.\n\nStep 6, the images: switched off. This build has no image provider key, so the route refuses with the reason and no provider is ever called.

* The voice is named once: the sound and the chip say it the same way

* Merge remote-tracking branch 'origin/main' into feature/companions-s5-s6

* The account export and deletion carry the group chats and the sound

The three new tables name a person, so they belong in the account map like the chats above: the group chats, the messages in them and the sound of the answers read aloud are exported and erased with the account. The members of a group go with the group itself.

Changes

ddiff --git a/.env.example b/.env.example
iindex 360e8ae..592dd23 100644
---- a/.env.example
++++ b/.env.example
@@@ -54,5 +54,16 @@ EXCHANGE_EVENTS_URL=
# A restore drill starts no subscriptions and creates no Events subscription.
# EXCHANGE_DRILL=0
++# OpenVibe.Chat: the companions' answers are read aloud in each character's own voice. The call carries
++# the person's own Network token, so the read-aloud is on their own allowance and on Chat's per-person rate
++# limit. The site's own voices only; a cloned voice is never asked for here.
++#OV_CHAT_URL=https://openvibe.chat
++#EXCHANGE_TTS_TIMEOUT_MS=20000
++
++# The companions' images (step 6 of the design): OFF until an image provider is configured. While the switch is
++# off no provider is ever called and the route refuses with the reason.
++#EXCHANGE_COMPANIONS_IMAGES_ENABLED=false
++#EXCHANGE_COMPANIONS_IMAGES_PROVIDER=
++
# optional: defaults to true in production
# COOKIE_SECURE=true
ddiff --git a/migrations/0013_companion_groups.sql b/migrations/0013_companion_groups.sql
nnew file mode 100644
iindex 0000000..e2ec6d0
---- /dev/null
++++ b/migrations/0013_companion_groups.sql
@@@ -0,0 +1,76 @@
++-- phase: expand
++-- Group chats and the voices (Opus's design, 2026-11-06, steps 5 and 6). Expand-only: nothing here changes an
++-- existing table.
++--
++-- A group chat is two to five characters and the person. The characters take turns by a simple rule: the one
++-- the person named, round-robin, or "let them talk" for a number of turns. Every character keeps its own
++-- memory (the notes of its own M2 scope, project:companions-<character id>) and its own voice.
++--
++-- The voice clips are the sound of an answer, read aloud in the character's own voice by OpenVibe.Chat. The
++-- bytes are kept here so the browser can play them from this site's own origin (a strict CSP needs no
++-- exception for the media).
++
++CREATE TABLE IF NOT EXISTS exchange_group_chats (
++ id text COLLATE "C" PRIMARY KEY, -- exc_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL, -- usr_...
++ title text NOT NULL DEFAULT 'Group chat',
++ mode text COLLATE "C" NOT NULL DEFAULT 'round_robin', -- named | round_robin | free
++ turns integer NOT NULL DEFAULT 1, -- "let them talk": how many times each character answers
++ model text,
++ preset text,
++ temperature real,
++ context_size integer,
++ persona text,
++ speak boolean NOT NULL DEFAULT true, -- read the answers aloud
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ ended_at timestamptz,
++ CONSTRAINT exchange_group_chats_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_group_chats_mode CHECK (mode IN ('named', 'round_robin', 'free'))
++);
++
++-- The characters of a group, in the order they joined. Two to five of them.
++CREATE TABLE IF NOT EXISTS exchange_group_members (
++ group_id text COLLATE "C" NOT NULL REFERENCES exchange_group_chats (id) ON DELETE CASCADE,
++ character_id text COLLATE "C" NOT NULL, -- exc_...
++ position integer NOT NULL DEFAULT 0,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (group_id, character_id)
++);
++CREATE INDEX IF NOT EXISTS exchange_group_members_group ON exchange_group_members (group_id, position);
++
++-- One message of a group chat: the person's own words, or a character's answer.
++CREATE TABLE IF NOT EXISTS exchange_group_messages (
++ id text COLLATE "C" PRIMARY KEY, -- exg_ + 26 Crockford base32 characters (ULID)
++ group_id text COLLATE "C" NOT NULL REFERENCES exchange_group_chats (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL,
++ role text COLLATE "C" NOT NULL, -- user | assistant
++ character_id text, -- exc_... when the role is 'assistant'
++ content text,
++ turn_id text, -- the person's message the answers belong to
++ seq integer NOT NULL DEFAULT 0, -- the order inside the turn
++ model text,
++ meta text NOT NULL DEFAULT '{}', -- the JSON meta: the run, the receipt, the voice clip
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_group_messages_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_group_messages_role CHECK (role IN ('user', 'assistant'))
++);
++CREATE INDEX IF NOT EXISTS exchange_group_messages_group ON exchange_group_messages (group_id, seq, id);
++
++-- The sound of an answer, read aloud in the character's own voice. The bytes are base64 so they travel
++-- through every database this service runs on (PostgreSQL and the embedded one) the same way.
++CREATE TABLE IF NOT EXISTS exchange_voice_clips (
++ id text COLLATE "C" PRIMARY KEY, -- exv_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL,
++ message_id text, -- the message the clip is the voice of
++ character_id text, -- exc_...: the character it was read in
++ voice text, -- the voice id the card names
++ mime_type text NOT NULL DEFAULT 'audio/mpeg',
++ audio text NOT NULL, -- the base64 audio
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_voice_clips_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
++);
++CREATE INDEX IF NOT EXISTS exchange_voice_clips_message ON exchange_voice_clips (message_id);
++
++-- The one-on-one chats can be read aloud too, and the person can say no.
++ALTER TABLE exchange_companion_chats ADD COLUMN IF NOT EXISTS speak boolean NOT NULL DEFAULT true;
ddiff --git a/server/app.js b/server/app.js
iindex 6029e38..3089c13 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -34,6 +34,8 @@ const { createArenaStore } = require('./arena/store');
const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
++const { createGroupStore } = require('./companions/group');
++const { createVoiceClient } = require('./companions/voice');
const { createSharing } = require('./sharing');
const { createAdultAccount } = require('./companions/adult');
const { createMemoryClient, createCompanionNotes } = require('./companions/memory');
@@@ -80,6 +82,10 @@ async function createApp(opts = {}) {
// Companions (2026-11-06): the cards, their chats, and the one sharing system they use.
const characters = createCharacterStore(s);
const companionChats = createCompanionChatStore(s);
++ // Group chats (step 5) and the voice (step 6): the groups of the person, and the client that asks
++ // OpenVibe.Chat for the sound of an answer in the character's own voice.
++ const groups = createGroupStore(s);
++ const voice = createVoiceClient({ config, fetchImpl, log });
const sharing = createSharing({ db: s.db, config, fetchImpl, log });
// The 18+ confirmation, read from the account on Network (Network PR #587).
const adultAccount = createAdultAccount({ config, fetchImpl, log, now: s.now });
@@@ -100,7 +106,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
-- const ctx = { config, s, keys, sso, principal, ai, bench, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
++ const ctx = { config, s, keys, sso, principal, ai, bench, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/companions/group.js b/server/companions/group.js
nnew file mode 100644
iindex 0000000..617714f
---- /dev/null
++++ b/server/companions/group.js
@@@ -0,0 +1,170 @@
++'use strict';
++/**
++ * Group chats (Opus's design, 2026-11-06, step 5): two to five characters and the person.
++ *
++ * The characters take turns by a simple rule, and every character stays itself:
++ * - the one named: the character the person named answers (their name, with or without an @);
++ * - round-robin: every character answers once, in the order they joined;
++ * - "let them talk": every character answers a number of times, one round after another.
++ *
++ * What stays per character: the memory (the notes of that character's own M2 scope, so Ada's notes are
++ * Ada's and nobody else's) and the voice (the card's own voice, used when the answer is read aloud).
++ * What is shared: the conversation itself - every character hears what the others said.
++ */
++
++const MODES = ['named', 'round_robin', 'free'];
++const MIN_MEMBERS = 2;
++const MAX_MEMBERS = 5;
++const MAX_TURNS = 5;
++
++/** The mode's own name, in the words the page uses. */
++const MODE_LABEL = {
++ named: 'The one named',
++ round_robin: 'In turn',
++ free: 'Let them talk',
++};
++
++/** Does the text name this character? Their name, with or without an @, on a word boundary. */
++function mentioned(text, name) {
++ const t = String(text == null ? '' : text).toLowerCase();
++ const n = String(name == null ? '' : name).toLowerCase().trim();
++ if (!n || n.length < 2) return false;
++ const safe = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++ return new RegExp('(^|[^a-z0-9])@?' + safe + '([^a-z0-9]|$)', 'i').test(t);
++}
++
++/**
++ * Who answers, and in what order: a list of { character_id, name }.
++ * The plan is the design's simple rule, and nothing else.
++ */
++function planAnswers({ mode = 'round_robin', turns = 1, members = [], text = '' } = {}) {
++ const list = (members || []).map((m) => ({ character_id: String(m.character_id), name: String(m.name || '') }));
++ if (!list.length) return [];
++ if (mode === 'named') {
++ const named = list.filter((m) => mentioned(text, m.name));
++ // Nobody named: the group answers in turn, so the person is never left in silence.
++ return named.length ? named : list.slice();
++ }
++ if (mode === 'free') {
++ const rounds = Math.max(1, Math.min(MAX_TURNS, Number(turns) || 1));
++ const out = [];
++ for (let r = 0; r < rounds; r++) for (const m of list) out.push(m);
++ return out;
++ }
++ return list.slice();
++}
++
++/**
++ * The system prompt of one character in a group: the card, the group's roster, and that character's own
++ * notes about the person. The notes of the other characters never join it.
++ */
++function groupPromptFor(card, group, { notes = [], others = [] } = {}) {
++ const lines = ['You are ' + card.name + '.'];
++ if (card.description) lines.push(card.description);
++ if (card.personality) lines.push('Personality: ' + card.personality);
++ if (card.scenario) lines.push('Scenario: ' + card.scenario);
++ if (group && group.persona) lines.push('The person you talk with plays: ' + group.persona);
++ if (card.first_message) lines.push('Begin with: ' + card.first_message);
++ if (others.length) {
++ lines.push('You are in a group chat with ' + others.join(', ') + '. Answer in your own voice, in your own words: never write the lines of the others, and never speak for the person.');
++ }
++ if (notes.length) {
++ lines.push('What you remember about the person (your own notes about them, which they can read and edit):');
++ for (const note of notes) lines.push('- (' + note.kind + ') ' + note.text);
++ }
++ lines.push('Stay in character and answer as the character would. The hard floor holds on every model: no sexual content involving minors, no non-consensual sexual content (fiction included) or incest, no help with weapons of mass harm or attacks on others.');
++ return lines.join(String.fromCharCode(10));
++}
++
++/**
++ * The group chats: the rows, their members and their messages.
++ */
++function createGroupStore(s) {
++ const db = s.db;
++
++ async function create({ owner, title = 'Group chat', mode = 'round_robin', turns = 1, members = [], settings = {} }) {
++ const id = s.newId('exc');
++ const how = MODES.includes(mode) ? mode : 'round_robin';
++ const rounds = Math.max(1, Math.min(MAX_TURNS, Number(turns) || 1));
++ await db.prepare('INSERT INTO exchange_group_chats (id, owner, title, mode, turns, model, preset, temperature, context_size, persona) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(id, owner, String(title || 'Group chat').slice(0, 120), how, rounds,
++ settings.model ? String(settings.model).slice(0, 120) : null,
++ settings.preset ? String(settings.preset).slice(0, 120) : null,
++ settings.temperature == null ? null : Number(settings.temperature),
++ settings.context_size == null ? null : Number(settings.context_size),
++ settings.persona ? String(settings.persona).slice(0, 2000) : null);
++ let position = 0;
++ for (const characterId of members.slice(0, MAX_MEMBERS)) {
++ await db.prepare('INSERT INTO exchange_group_members (group_id, character_id, position) VALUES (?, ?, ?) ON CONFLICT DO NOTHING')
++ .run(id, String(characterId), position++);
++ }
++ return get(owner, id);
++ }
++
++ async function get(owner, id) {
++ const row = await db.prepare('SELECT * FROM exchange_group_chats WHERE id = ? AND owner = ?').get(String(id || ''), owner);
++ return row || null;
++ }
++
++ async function list(owner, limit = 60) {
++ const rows = await db.prepare('SELECT g.*, (SELECT COUNT(*) FROM exchange_group_members m WHERE m.group_id = g.id) AS member_count FROM exchange_group_chats g WHERE g.owner = ? ORDER BY g.updated_at DESC LIMIT ?')
++ .all(owner, Math.max(1, Math.min(200, limit)));
++ return rows;
++ }
++
++ async function members(groupId) {
++ const rows = await db.prepare('SELECT m.character_id, m.position FROM exchange_group_members m WHERE m.group_id = ? ORDER BY m.position, m.character_id')
++ .all(String(groupId));
++ return rows;
++ }
++
++ async function messages(groupId) {
++ const rows = await db.prepare('SELECT * FROM exchange_group_messages WHERE group_id = ? ORDER BY seq, id').all(String(groupId));
++ return rows.map((r) => ({ ...r, meta: parseJson(r.meta) }));
++ }
++
++ async function append({ owner, groupId, role, characterId = null, content = null, turnId = null, seq = 0, model = null, meta = {} }) {
++ const id = s.newId('exg');
++ await db.prepare('INSERT INTO exchange_group_messages (id, group_id, owner, role, character_id, content, turn_id, seq, model, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(id, String(groupId), owner, role, characterId ? String(characterId) : null,
++ content == null ? null : String(content), turnId ? String(turnId) : null, Number(seq) || 0,
++ model ? String(model) : null, JSON.stringify(meta || {}));
++ await db.prepare('UPDATE exchange_group_chats SET updated_at = now() WHERE id = ? AND owner = ?').run(String(groupId), owner);
++ return id;
++ }
++
++ async function settings(owner, groupId, next) {
++ const sets = [];
++ const values = [];
++ for (const f of ['model', 'preset', 'temperature', 'context_size', 'persona', 'title', 'mode', 'turns', 'speak']) {
++ if (!(f in next)) continue;
++ sets.push(f + ' = ?');
++ if (f === 'temperature' || f === 'turns') values.push(next[f] == null || next[f] === '' ? null : Number(next[f]));
++ else if (f === 'context_size') values.push(next[f] == null || next[f] === '' ? null : Number(next[f]));
++ else if (f === 'speak') values.push(next[f] === false || next[f] === '0' ? false : true);
++ else if (f === 'mode') values.push(MODES.includes(next[f]) ? next[f] : 'round_robin');
++ else values.push(next[f] == null ? null : String(next[f]).slice(0, 2000));
++ }
++ if (sets.length) {
++ await db.prepare('UPDATE exchange_group_chats SET ' + sets.join(', ') + ', updated_at = now() WHERE id = ? AND owner = ?')
++ .run(...values, String(groupId), owner);
++ }
++ return get(owner, groupId);
++ }
++
++ async function remove(owner, groupId) {
++ await db.prepare('DELETE FROM exchange_group_chats WHERE id = ? AND owner = ?').run(String(groupId), owner);
++ }
++
++ /** The answers a person's groups gave today: the daily allowance's counter. */
++ async function countToday(owner) {
++ const row = await db.prepare('SELECT COUNT(*) AS n FROM exchange_group_messages WHERE owner = ? AND role = \'assistant\' AND created_at >= date_trunc(\'day\', now())').get(owner);
++ return Number(row && row.n) || 0;
++ }
++
++ return { create, get, list, members, messages, append, settings, remove, countToday, MODES, MIN_MEMBERS, MAX_MEMBERS };
++}
++
++const parseJson = (v) => { try { return v ? JSON.parse(v) : {}; } catch (e) { return {}; } };
++
++module.exports = { createGroupStore, planAnswers, groupPromptFor, mentioned, MODES, MIN_MEMBERS, MAX_MEMBERS, MAX_TURNS, MODE_LABEL };
ddiff --git a/server/companions/store.js b/server/companions/store.js
iindex 0402858..f562111 100644
---- a/server/companions/store.js
++++ b/server/companions/store.js
@@@ -195,11 +195,12 @@ function createCompanionChatStore(s) {
async function settings(owner, chatId, next) {
const sets = [];
const values = [];
-- for (const f of ['model', 'preset', 'temperature', 'context_size', 'persona', 'title']) {
++ for (const f of ['model', 'preset', 'temperature', 'context_size', 'persona', 'title', 'speak']) {
if (!(f in next)) continue;
sets.push(f + ' = ?');
if (f === 'temperature') values.push(next[f] == null || next[f] === '' ? null : Number(next[f]));
else if (f === 'context_size') values.push(next[f] == null || next[f] === '' ? null : Number(next[f]));
++ else if (f === 'speak') values.push(next[f] === false || next[f] === '0' ? false : true);
else values.push(next[f] == null || next[f] === '' ? null : String(next[f]).slice(0, 2000));
}
if (sets.length) {
ddiff --git a/server/companions/voice.js b/server/companions/voice.js
nnew file mode 100644
iindex 0000000..646c769
---- /dev/null
++++ b/server/companions/voice.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * The character's voice (Opus's design, 2026-11-06, step 6): an answer is read aloud in the voice the card
++ * names. The voice field on a card was there from the start and was never used; this is where it is used.
++ *
++ * The sound comes from OpenVibe.Chat's TTS - the same engine the agent voice uses - called with the
++ * person's own Network token. That is the token Exchange already holds for that person (the same one the
++ * M2 memory notes and the 18+ flag are read with), so the read-aloud spends the person's own allowance
++ * and counts on Chat's per-person rate limit. No service token of ours is involved, and Chat's service
++ * token never speaks for a person.
++ *
++ * The site's own voices only: a cloned voice is never asked for here (a cloned voice needs its owner's
++ * opt-in, which is a separate thing and is not this route's business).
++ */
++
++// The contract's chat.tts-speak-request@1 caps the text at 220 characters, and Chat's route takes the same
++// cap, so a long answer is spoken as its first line.
++const MAX_CHARS = 220;
++
++function createVoiceClient({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String((config && config.tts && config.tts.url) || '').replace(/\/+$/, '');
++ const timeoutMs = (config && config.tts && config.tts.timeoutMs) || 20_000;
++
++ /**
++ * The line that is spoken: the first 220 characters, cut at a sentence when there is one inside them.
++ */
++ function line(text) {
++ const body = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
++ if (body.length <= MAX_CHARS) return body;
++ const cut = body.slice(0, MAX_CHARS);
++ const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
++ return (stop > 40 ? cut.slice(0, stop + 1) : cut).trim();
++ }
++
++ /**
++ * Speak one line in one voice.
++ * → { ok: true, audio, mimeType, rate, voiceId, text }
++ * → { ok: false, code, error, rateLimited } (never throws: a silent answer is better than a broken chat)
++ */
++ async function speak(token, text, voice) {
++ const spoken = line(text);
++ if (!spoken) return { ok: false, code: 'voice.nothing_to_say', error: 'There is nothing to read aloud.' };
++ if (!token) {
++ return { ok: false, code: 'voice.no_token', error: 'The person has to sign in to hear the answer.' };
++ }
++ try {
++ const res = await fetchImpl(base + '/api/tts/speak', {
++ method: 'POST',
++ headers: {
++ Authorization: 'Bearer ' + token,
++ Accept: 'application/json',
++ 'Content-Type': 'application/json',
++ },
++ body: JSON.stringify({ text: spoken, voice: voice ? String(voice).slice(0, 120) : '' }),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ const raw = await res.text();
++ let out = null;
++ try { out = raw ? JSON.parse(raw) : null; } catch (e) { out = null; }
++ if (!res.ok) {
++ const err = new Error((out && (out.error || out.detail)) || ('the voice answered ' + res.status));
++ err.code = (out && out.code) || 'voice.failed';
++ err.status = res.status;
++ err.rateLimited = res.status === 429;
++ throw err;
++ }
++ if (!out || !out.audio) {
++ const err = new Error('the voice answered without audio');
++ err.code = 'voice.no_audio';
++ err.status = 502;
++ throw err;
++ }
++ return {
++ ok: true,
++ audio: String(out.audio),
++ mimeType: String(out.mime_type || 'audio/mpeg'),
++ rate: Number(out.rate) || 48000,
++ voiceId: out.voice_id || voice || null,
++ text: spoken,
++ };
++ } catch (err) {
++ log.warn('[Companions] the answer could not be read aloud:', String((err && err.message) || err).slice(0, 200));
++ return {
++ ok: false,
++ code: (err && err.code) || 'voice.failed',
++ error: String((err && err.message) || 'the voice is unavailable').slice(0, 200),
++ rateLimited: Boolean(err && err.rateLimited),
++ };
++ }
++ }
++
++ return { speak, line, MAX_CHARS };
++}
++
++module.exports = { createVoiceClient, MAX_CHARS };
ddiff --git a/server/config.js b/server/config.js
iindex fdd0102..fafcce6 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -110,6 +110,20 @@ function load(env = process.env) {
noteModel: String(env.EXCHANGE_COMPANIONS_NOTE_MODEL || 'openvibe/economy').slice(0, 120),
// The lorebook entries (stage 4): how many tokens of entries may join a chat's context.
lorebookBudgetTokens: Math.max(100, int(env.EXCHANGE_COMPANIONS_LOREBOOK_BUDGET_TOKENS, 1200)),
++ // The images (step 6): off until an image provider is configured for this service. While the switch
++ // is off no provider is ever called, and the route refuses with the reason.
++ images: {
++ enabled: String(env.EXCHANGE_COMPANIONS_IMAGES_ENABLED || '').toLowerCase() === 'true',
++ provider: String(env.EXCHANGE_COMPANIONS_IMAGES_PROVIDER || '').slice(0, 60),
++ },
++ },
++
++ // OpenVibe.Chat (server/companions/voice.js): the answers are read aloud in the character's own
++ // voice. The call carries the person's own Network token, so the read-aloud is on their own allowance
++ // and on Chat's per-person rate limit; no service token of ours is involved.
++ tts: {
++ url: trim(env.OV_CHAT_URL || 'https://openvibe.chat'),
++ timeoutMs: Math.max(2000, int(env.EXCHANGE_TTS_TIMEOUT_MS, 20_000)),
},
// /chat: how many messages a signed-in person may send a day (each one may run up to three models).
ddiff --git a/server/http/companions.js b/server/http/companions.js
iindex 9ad87c2..203aa4e 100644
---- a/server/http/companions.js
++++ b/server/http/companions.js
@@@ -32,13 +32,15 @@ const floor = require('../companions/floor');
const codec = require('../companions/codec');
const { tokensOf, KINDS: NOTE_KINDS } = require('../companions/memory');
const { readTurns } = require('../companions/store');
++const groupLib = require('../companions/group');
++const groupViews = require('../views/companion-groups');
const multer = require('multer');
const handleOf = (name) => String(name || 'someone').toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'someone';
const tagsOf = (s) => String(s || '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean).slice(0, 5);
function createCompanionRoutes(ctx) {
-- const { config, ai, adult, adultAccount, characters, companionChats, sharing, memory, notes, lorebooks, locker, noteTaker, log } = ctx;
++ const { config, ai, adult, adultAccount, characters, companionChats, groups, voice, sharing, memory, notes, lorebooks, locker, noteTaker, log } = ctx;
const db = ctx.s && ctx.s.db;
const r = asyncRouter();
// A card may arrive as a file (a PNG with the card inside, or plain JSON).
@@@ -221,6 +223,8 @@ function createCompanionRoutes(ctx) {
rating: (imported ? (src.rating === 'adult' ? 'adult' : 'everyone') : (b.rating === 'adult' ? 'adult' : 'everyone')),
age: b.age != null && String(b.age).trim() !== '' ? Number(b.age) : (imported && src.age != null ? Number(src.age) : null),
model: String(b.model || '').slice(0, 120),
++ // The voice the card names: the answer is read aloud in it (step 6).
++ voice: pick('voice', 'voice').slice(0, 120),
tags,
};
}
@@@ -847,6 +851,8 @@ function createCompanionRoutes(ctx) {
meta: [], copy: false,
})
+ receiptFor(turn.answer)
++ + (turn.answer.meta && turn.answer.meta.clip ? groupViews.clipAudio(turn.answer.meta.clip) : '')
++ + (turn.answer.meta && turn.answer.meta.voice ? '<p class="ex-voice-name">voice: ' + esc(turn.answer.meta.voice) + '</p>' : '')
+ '</article>'
: '';
const acts = turn.answer
@@@ -936,6 +942,11 @@ function createCompanionRoutes(ctx) {
+ '<input id="ex-context" name="context_size" form="ovt-composer" type="number" min="2000" max="200000" step="1000" value="' + (chat.context_size == null ? '' : esc(chat.context_size)) + '">'
+ '<label class="ex-label" for="ex-persona">The persona you play</label>'
+ '<textarea id="ex-persona" name="persona" form="ovt-composer" rows="2" maxlength="2000">' + esc(chat.persona || '') + '</textarea>'
++ + '<label class="ex-label" for="ex-speak">The voice</label>'
++ + '<select id="ex-speak" name="speak" form="ovt-composer">'
++ + '<option value="1"' + (chat.speak === false ? '' : ' selected') + '>Read the answers aloud</option>'
++ + '<option value="0"' + (chat.speak === false ? ' selected' : '') + '>Keep them written</option>'
++ + '</select>'
+ '<div class="ex-chat-links">'
+ '<a class="ex-chip" href="' + esc(base + '/memory') + '">Memory' + (chat.locked ? ' · locked' : '') + '</a>'
+ '<a class="ex-chip" href="' + esc(base + '/lorebook?chat=' + encodeURIComponent(chat.id)) + '">Lorebook</a>'
@@@ -955,6 +966,7 @@ function createCompanionRoutes(ctx) {
+ chatSettings(chat, models, card)
+ '<a class="ovt-btn ex-chat-new" href="/companions" aria-label="Characters">' + thread.icon('plus') + '</a>'
+ '<button type="button" class="ovt-btn ovt-panel-open" data-ovt-panel-open aria-controls="ovt-panel" aria-expanded="false" aria-label="What the model saw" title="What the model saw">' + thread.icon('panel') + '</button>'
++ + '<form class="ex-act-form" method="post" action="/talk/' + encodeURIComponent(chat.id) + '/image"><button type="submit" class="ex-chip" title="Make an image of this chat">Image</button></form>'
+ '</header>';
}
@@@ -1010,6 +1022,39 @@ function createCompanionRoutes(ctx) {
}
/** Save the settings the composer carries, and answer. */
++ /** The meta of a row, as an object: the run, the receipt, the voice clip. */
++ const parseMeta = (v) => { try { return v ? JSON.parse(v) : {}; } catch (e) { return {}; } };
++
++ /**
++ * Read one answer aloud in the card's own voice, once, and keep the sound with the message.
++ *
++ * The call goes to OpenVibe.Chat with the person's own token, so it is on the person's own allowance and
++ * on Chat's per-person rate limit. When the rate limit says no, the answer stays a written one: the chat
++ * never breaks because the voice was busy.
++ */
++ async function readAloud(subject, token, messageId, card, text, kind) {
++ const table = kind === 'group' ? 'exchange_group_messages' : 'exchange_companion_messages';
++ try {
++ const said = await voice.speak(token, text, card.voice);
++ if (!said.ok) {
++ log.warn('[Companions] the answer was not read aloud:', String(said.error || said.code).slice(0, 200));
++ return null;
++ }
++ const clipId = ctx.s.newId('exv');
++ await db.prepare('INSERT INTO exchange_voice_clips (id, owner, message_id, character_id, voice, mime_type, audio) VALUES (?, ?, ?, ?, ?, ?, ?)')
++ .run(clipId, subject, String(messageId), String(card.id), String(card.voice), said.mimeType, said.audio);
++ const row = await db.prepare('SELECT meta FROM ' + table + ' WHERE id = ? AND owner = ?').get(String(messageId), subject);
++ const meta = parseMeta(row && row.meta);
++ meta.voice = card.voice;
++ meta.clip = { id: clipId, voice: card.voice, mime_type: said.mimeType };
++ await db.prepare('UPDATE ' + table + ' SET meta = ? WHERE id = ? AND owner = ?').run(JSON.stringify(meta), String(messageId), subject);
++ return clipId;
++ } catch (err) {
++ log.warn('[Companions] the answer was not read aloud:', String((err && err.message) || err).slice(0, 200));
++ return null;
++ }
++ }
++
/** Turn a flat list of messages into the pairs the note taker reads. */
function pairsOf(flat) {
const out = [];
@@@ -1034,6 +1079,8 @@ function createCompanionRoutes(ctx) {
temperature: req.body && req.body.temperature !== undefined ? (req.body.temperature === '' ? null : Number(req.body.temperature)) : chat.temperature,
context_size: req.body && req.body.context_size !== undefined ? (req.body.context_size === '' ? null : Number(req.body.context_size)) : chat.context_size,
persona: req.body && req.body.persona !== undefined ? String(req.body.persona || '').slice(0, 2000) : chat.persona,
++ // Read the answers aloud (step 6): on by default, and the person can say no in the header.
++ speak: req.body && req.body.speak !== undefined ? (req.body.speak === '1' || req.body.speak === 'true') : chat.speak,
});
const userText = plainText != null ? String(plainText) : String(userMessage.content == null ? '' : userMessage.content);
const messages = await companionChats.messages(chat.id);
@@@ -1100,16 +1147,22 @@ function createCompanionRoutes(ctx) {
return renderChat(req, res, chat, card, { problem: 'That answer broke the hard floor, so it was not delivered. The chat has ended and the account is reviewed.', status: 403 });
}
const stored = locked ? await locker.seal(answer, keys) : answer;
-- await companionChats.append({
++ const answerMeta = {
++ run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
++ tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,
++ sealed: locked,
++ saw: { ...sawBase, model: out.model || model, system: locked ? null : system, context: locked ? [] : asked },
++ };
++ const answerId = await companionChats.append({
owner: subject, chatId: chat.id, role: 'assistant', content: stored,
turnId, variant, parentId: userMessage.id, model: settings.model || null,
-- meta: {
-- run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
-- tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,
-- sealed: locked,
-- saw: { ...sawBase, model: out.model || model, system: locked ? null : system, context: locked ? [] : asked },
-- },
++ meta: answerMeta,
});
++ // The voice (step 6): the answer is read aloud once, in the card's own voice, on the person's own
++ // allowance (Chat's per-person rate limit). A sealed answer is never read aloud.
++ if (!locked && card.voice && settings.speak !== false) {
++ await readAloud(subject, token, answerId, card, answer, 'chat');
++ }
// After every few turns, the cheap model writes short notes into the person's own memory.
try {
const after = await companionChats.messages(chat.id);
@@@ -1241,7 +1294,236 @@ function createCompanionRoutes(ctx) {
return res.redirect(303, '/companions');
});
++ // ── Group chats (step 5) ─────────────────────────────────────────────────
++ /** The characters of the person: the ones a group may be built from. */
++ async function ownCards(req) {
++ return await characters.listByOwner(owner(req));
++ }
++
++ /** The cards of a group, in the order the characters joined. */
++ async function groupCards(req, group) {
++ const rows = await groups.members(group.id);
++ const cards = [];
++ for (const row of rows) {
++ const card = await characters.get(owner(req), row.character_id);
++ if (card) cards.push(card);
++ }
++ return cards;
++ }
++
++ /** The shared conversation, as one character sees it: its own answers are its own, the others are named. */
++ function groupContext(messages, cardId, names) {
++ const out = [];
++ for (const m of messages) {
++ const text = String(m.content == null ? '' : m.content);
++ if (m.role === 'user') { out.push({ role: 'user', content: text }); continue; }
++ if (!text) continue;
++ if (m.character_id === cardId) out.push({ role: 'assistant', content: text });
++ else out.push({ role: 'user', content: '[' + ((names && names[m.character_id]) || 'Someone') + '] ' + text });
++ }
++ return out;
++ }
++
++ r.get('/groups', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in for group chats', text: 'Your group chats stay in your account.', next: req.originalUrl });
++ const list = await groups.list(owner(req));
++ const mine = await ownCards(req);
++ return page(req, res, { title: 'Group chats', index: false, body: raw(groupViews.groupsPage({ groups: list, characters: mine })) });
++ });
++
++ r.post('/groups/new', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in for group chats', text: 'Your group chats stay in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const mine = await ownCards(req);
++ const allowed = new Set(mine.map((c) => c.id));
++ const chosen = []
++ .concat(req.body && req.body.members ? [].concat(req.body.members) : [])
++ .map((m) => String(m))
++ .filter((id) => allowed.has(id));
++ const members = [...new Set(chosen)].slice(0, groupLib.MAX_MEMBERS);
++ if (members.length < groupLib.MIN_MEMBERS) {
++ const list = await groups.list(owner(req));
++ return page(req, res, { title: 'Group chats', index: false, body: raw(groupViews.groupsPage({ groups: list, characters: mine, problem: 'A group chat needs at least two of your own characters.' })) }, 422);
++ }
++ const group = await groups.create({
++ owner: owner(req),
++ title: String((req.body && req.body.title) || '').slice(0, 120) || 'Group chat',
++ mode: String((req.body && req.body.mode) || 'round_robin'),
++ turns: Number((req.body && req.body.turns) || 1),
++ members,
++ });
++ return res.redirect(303, '/talk/group/' + group.id);
++ });
++
++ /** The group chat page: everybody's words, each character in its own face and its own voice. */
++ async function renderGroup(req, res, group, { problem = null, status = 200 } = {}) {
++ const cards = await groupCards(req, group);
++ const map = {};
++ for (const c of cards) map[c.id] = c;
++ const messages = await groups.messages(group.id);
++ const list = await groups.list(owner(req));
++ const items = list.map((g) => ({ href: '/talk/group/' + g.id, title: g.title, current: g.id === group.id, at: g.updated_at }));
++ const sidebar = thread.sidebar({
++ newHref: '/groups', newLabel: 'Group chats', findAction: '/companions', findPlaceholder: 'Find a character',
++ groups: thread.group(items, (g) => g.at || Date.now()),
++ nav: [{ href: '/companions', label: 'Characters', icon: 'person' }, { href: '/groups', label: 'Group chats', icon: 'person' }],
++ });
++ const empty = '<div class="ovt-welcome"><h2>' + esc(group.title || 'Group chat') + '</h2><p>'
++ + esc(cards.map((c) => c.name).join(', ')) + ' are here. Say something.</p></div>';
++ const body = thread.page({
++ sidebar,
++ head: groupViews.groupHead(group, cards),
++ turns: groupViews.groupTurns(messages, map),
++ empty,
++ composer: group.ended_at ? '' : thread.composer({
++ action: '/talk/group/' + encodeURIComponent(group.id) + '/send',
++ name: 'text',
++ placeholder: 'Say something…',
++ id: 'ovt-composer',
++ top: problem ? kit.notice({ kind: 'error', text: problem }) : '',
++ maxlength: config.chat.maxChars,
++ note: 'Everyone hears what the others say. Each character keeps its own memory and its own voice.',
++ }),
++ panel: '',
++ });
++ return page(req, res, {
++ title: group.title || 'Group chat', index: false, class: 'ex-chat ex-companion-chat', app: true,
++ bodyClass: 'ex-app ex-companion-shell',
++ head: '<link rel="stylesheet" href="' + require('openvibe-shared/serve').url('thread.css') + '"><script src="' + require('openvibe-shared/serve').url('ov-thread.js') + '" defer></script>',
++ body: raw(body),
++ }, status);
++ }
++
++ /** The group's own settings: who answers, how many times, and whether the answers are read aloud. */
++ r.post('/talk/group/:id/settings', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const group = await groups.get(owner(req), req.params.id);
++ if (!group) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such group chat' }) + '</div>') }, 404);
++ await groups.settings(owner(req), group.id, {
++ mode: String((req.body && req.body.mode) || group.mode),
++ turns: Number((req.body && req.body.turns) || group.turns),
++ speak: req.body && req.body.speak !== undefined ? (req.body.speak === '1' || req.body.speak === 'true') : group.speak,
++ title: req.body && req.body.title !== undefined ? String(req.body.title || '').slice(0, 120) : group.title,
++ });
++ return res.redirect(303, '/talk/group/' + group.id);
++ });
++
++ r.get('/talk/group/:id', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ const group = await groups.get(owner(req), req.params.id);
++ if (!group) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such group chat' }) + '</div>') }, 404);
++ return renderGroup(req, res, group);
++ });
++
++ /** The person speaks; the characters answer by the group's rule, each in its own voice and memory. */
++ r.post('/talk/group/:id/send', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const group = await groups.get(owner(req), req.params.id);
++ if (!group) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such group chat' }) + '</div>') }, 404);
++ if (group.ended_at) return renderGroup(req, res, group, { problem: 'This group chat ended.', status: 403 });
++ const text = String((req.body && req.body.text) || '').slice(0, config.chat.maxChars).trim();
++ if (!text) return renderGroup(req, res, group, { problem: 'Write something to send.', status: 422 });
++ const input = floor.check(text);
++ if (!input.ok) return renderGroup(req, res, group, { problem: 'That request is refused: ' + input.detail + ' (' + input.label + ').', status: 422 });
++ const used = await companionChats.countToday(owner(req)) + await groups.countToday(owner(req));
++ if (used >= config.companions.perDay) {
++ return renderGroup(req, res, group, { problem: 'That is today&#39;s ' + config.companions.perDay + ' answers. The allowance resets at midnight UTC.', status: 429 });
++ }
++ const cards = await groupCards(req, group);
++ const members = cards.map((c) => ({ character_id: c.id, name: c.name }));
++ const plan = groupLib.planAnswers({ mode: group.mode, turns: group.turns, members, text });
++ if (!plan.length) return renderGroup(req, res, group, { problem: 'This group has no characters left.', status: 422 });
++ const names = {};
++ for (const c of cards) names[c.id] = c.name;
++ const token = tokenOf(req);
++ const adultOk = await isAdult(req);
++ const model = await modelIdOf(group.model || (cards[0] && cards[0].model));
++ const turnId = ctx.s.newId('ext');
++ const userMessageId = await groups.append({ owner: owner(req), groupId: group.id, role: 'user', content: text, turnId, seq: 0 });
++ let seq = 1;
++ for (const who of plan) {
++ const card = cards.find((c) => c.id === who.character_id);
++ if (!card) continue;
++ // This character's own notes, from its own memory scope: nobody else's notes join its answer.
++ const noteResult = await contextNotes(req, card);
++ const others = members.filter((m) => m.character_id !== card.id).map((m) => m.name);
++ const system = groupLib.groupPromptFor(card, group, { notes: noteResult.notes, others });
++ const history = (await groups.messages(group.id)).filter((m) => m.id !== userMessageId);
++ const asked = groupContext(history, card.id, names).concat([{ role: 'user', content: text }]);
++ let out = null;
++ try {
++ out = await ai.chat({
++ model,
++ messages: [{ role: 'system', content: system }].concat(asked),
++ temperature: group.temperature,
++ subject: owner(req),
++ policy: card.rating === 'adult' && adultOk ? 'uncensored' : 'standard',
++ preset: group.preset || null,
++ max_cost_usd: config.companions.maxCostUsd,
++ });
++ } catch (err) {
++ log.warn('[Companions] the group answer failed:', String((err && err.message) || err).slice(0, 200));
++ }
++ const answer = (out && out.content) || '(no text)';
++ const check = floor.check(answer);
++ if (!check.ok) {
++ await groups.append({ owner: owner(req), groupId: group.id, role: 'assistant', characterId: card.id, content: null, turnId, seq: seq++, meta: { error: 'floor', rule: check.rule, label: check.label, review: true } });
++ await db.prepare('UPDATE exchange_group_chats SET ended_at = now() WHERE id = ? AND owner = ?').run(group.id, owner(req));
++ log.warn('[Companions] a group answer broke the hard floor (' + check.rule + '); the chat ended and the account is reviewed.');
++ return renderGroup(req, res, group, { problem: 'That answer broke the hard floor, so it was not delivered. The chat has ended and the account is reviewed.', status: 403 });
++ }
++ const meta = {
++ run_id: out && out.run_id, answered_model: out && out.model, cost_usd: out && out.cost_usd,
++ tokens_in: out && out.tokens_in, tokens_out: out && out.tokens_out, latency_ms: out && out.latency_ms,
++ voice: card.voice || null,
++ // The notes this character answered with: its own, never another's.
++ notes: noteResult.notes.map((n) => ({ kind: n.kind, text: n.text })),
++ };
++ const messageId = await groups.append({ owner: owner(req), groupId: group.id, role: 'assistant', characterId: card.id, content: answer, turnId, seq: seq++, model, meta });
++ if (card.voice && group.speak !== false) await readAloud(owner(req), token, messageId, card, answer, 'group');
++ }
++ return res.redirect(303, '/talk/group/' + group.id);
++ });
++
++ /** The sound of an answer, read aloud in a character's own voice. */
++ r.get('/voice/:id', async (req, res) => {
++ if (!signedIn(req)) return res.status(401).send('Sign in to hear the answer.');
++ const row = await db.prepare('SELECT * FROM exchange_voice_clips WHERE id = ? AND owner = ?').get(String(req.params.id), owner(req));
++ if (!row) return res.status(404).send('No such sound.');
++ res.set('Cache-Control', 'private, no-store');
++ res.type(String(row.mime_type || 'audio/mpeg'));
++ return res.send(Buffer.from(String(row.audio || ''), 'base64'));
++ });
++
++ /**
++ * The images (step 6) are switched off: this build has no image provider key, so the route refuses and
++ * says why. Nothing is ever sent to a provider while the switch is off.
++ */
++ const IMAGE_OFF = 'Images are switched off on OpenVibe.Exchange: no image provider is configured '
++ + '(EXCHANGE_COMPANIONS_IMAGES_ENABLED). Nothing was sent to any provider and no image was made.';
++
++ r.post('/talk/:id/image', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const chat = await companionChats.get(owner(req), req.params.id);
++ if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such chat' }) + '</div>') }, 404);
++ const card = await characters.get(owner(req), chat.character_id);
++ if (!card) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such character' }) + '</div>') }, 404);
++ return renderChat(req, res, chat, card, { problem: IMAGE_OFF, status: 422 });
++ });
++
++ r.post('/talk/group/:id/image', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const group = await groups.get(owner(req), req.params.id);
++ if (!group) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such group chat' }) + '</div>') }, 404);
++ return renderGroup(req, res, group, { problem: IMAGE_OFF, status: 422 });
++ });
++
return r;
++
}
module.exports = { createCompanionRoutes };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 9f78f56..695dfc6 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -42,6 +42,12 @@ const TABLES = [
{ table: 'exchange_lorebook_entries', subject: 'owner', value: (usr) => usr, file: 'lorebook-entries.json', erase: 'delete' },
{ table: 'exchange_lorebooks', subject: 'owner', value: (usr) => usr, file: 'lorebooks.json', erase: 'delete' },
{ table: 'exchange_companion_locks', subject: 'owner', value: (usr) => usr, file: 'companion-locks.json', erase: 'delete' },
++ // The group chats (step 5, 2026-11-06): a person's groups, the messages in them and the sound of their
++ // answers read aloud are theirs, like the chats above. The messages go first, then the sound, then the
++ // groups: the members of a group go with the group itself.
++ { table: 'exchange_group_messages', subject: 'owner', value: (usr) => usr, file: 'group-messages.json', erase: 'delete' },
++ { table: 'exchange_voice_clips', subject: 'owner', value: (usr) => usr, file: 'voice-clips.json', erase: 'delete' },
++ { table: 'exchange_group_chats', subject: 'owner', value: (usr) => usr, file: 'group-chats.json', erase: 'delete' },
// The person's default audience for new items (design 2026-11-06): what their next chat, card or round starts as.
{ table: 'exchange_share_defaults', subject: 'owner', value: (usr) => usr, file: 'share-defaults.json', erase: 'delete' },
];
@@@ -94,7 +100,7 @@ function create({ db, log = console } = {}) {
service: 'exchange',
tables: TABLES,
extraErase,
-- note: 'Chats, messages, the characters and their companion chats and messages, the memory-note index, the lorebooks and the Locker rows, and the 18+ confirmation are erased; v1 listings are anonymised and closed (or deleted when still drafts). The text of a memory note is in the person’s own OpenVibe memory and is exported and erased there.',
++ note: 'Chats, messages, the characters and their companion chats and messages, the group chats with their messages and the sound of their answers read aloud, the memory-note index, the lorebooks and the Locker rows, and the 18+ confirmation are erased; v1 listings are anonymised and closed (or deleted when still drafts). The text of a memory note is in the person’s own OpenVibe memory and is exported and erased there.',
log,
});
}
ddiff --git a/server/views/companion-groups.js b/server/views/companion-groups.js
nnew file mode 100644
iindex 0000000..49bde37
---- /dev/null
++++ b/server/views/companion-groups.js
@@@ -0,0 +1,122 @@
++'use strict';
++/**
++ * The group chat pages (Opus's design, 2026-11-06, step 5): the list of the person's groups, the form that
++ * starts one, and the chat itself. Plain HTML, real links, plain POST forms: the site works without
++ * JavaScript.
++ */
++const kit = require('openvibe-shared/kit');
++const thread = require('openvibe-shared/thread');
++const { esc } = require('../render/html');
++const { MODE_LABEL } = require('../companions/group');
++
++/** The characters of the person, as the checkboxes of the form. */
++function memberChoices(characters, chosen = []) {
++ if (!characters.length) return '<p class="ex-muted">You have no characters yet. <a href="/companions/new">Make one first</a>.</p>';
++ return '<ul class="ex-member-choices">' + characters.map((c) => '<li><label class="ex-check">'
++ + '<input type="checkbox" name="members" value="' + esc(c.id) + '"' + (chosen.includes(c.id) ? ' checked' : '') + '> '
++ + esc(c.name) + ' <span class="ex-muted">(' + esc(c.handle) + ')</span></label></li>').join('') + '</ul>';
++}
++
++/** The list of the person's groups, and the form that starts one. */
++function groupsPage({ groups = [], characters = [], problem = null, notice = null } = {}) {
++ const rows = groups.map((g) => '<li class="ex-group-row"><a class="ex-group-link" href="/talk/group/' + encodeURIComponent(g.id) + '">'
++ + '<b>' + esc(g.title || 'Group chat') + '</b></a>'
++ + '<span class="ex-chip">' + esc(MODE_LABEL[g.mode] || g.mode) + '</span>'
++ + '<span class="ex-muted">' + Number(g.member_count || 0) + ' characters</span></li>').join('');
++ const form = '<section class="ex-group-new"><h2>Start a group chat</h2>'
++ + '<p class="ex-lede">Two to five characters and you. They answer by a simple rule: the one you name, one after another, or let them talk for a few turns each.</p>'
++ + '<form class="ex-form" method="post" action="/groups/new">'
++ + '<label class="ex-label" for="ex-group-title">Name the group (optional)</label>'
++ + '<input id="ex-group-title" name="title" type="text" maxlength="120" placeholder="The morning crew">'
++ + '<label class="ex-label" for="ex-group-mode">Who answers</label>'
++ + '<select id="ex-group-mode" name="mode">'
++ + '<option value="round_robin">' + esc(MODE_LABEL.round_robin) + '</option>'
++ + '<option value="named">' + esc(MODE_LABEL.named) + '</option>'
++ + '<option value="free">' + esc(MODE_LABEL.free) + '</option>'
++ + '</select>'
++ + '<label class="ex-label" for="ex-group-turns">How many times each, when they talk</label>'
++ + '<input id="ex-group-turns" name="turns" type="number" min="1" max="5" value="1">'
++ + '<fieldset class="ex-fieldset"><legend>The characters (two to five)</legend>' + memberChoices(characters) + '</fieldset>'
++ + '<button type="submit" class="ex-btn ex-btn-primary">Start the group chat</button>'
++ + '</form></section>';
++ return '<div class="ex-wrap ex-narrow">'
++ + '<h1>Group chats</h1>'
++ + (problem ? kit.notice({ kind: 'error', text: problem }) : '')
++ + (notice ? kit.notice({ kind: 'info', text: notice }) : '')
++ + (groups.length ? '<section class="ex-groups"><h2>Your groups</h2><ul class="ex-group-list">' + rows + '</ul></section>' : '')
++ + form
++ + '</div>';
++}
++
++/** The header of a group chat: the name, and the faces of the characters in it. */
++function groupHead(group, cards) {
++ const faces = cards.map((c) => thread.face({ name: c.name, color: 'var(' + monoToken(c.name) + ')' })).join('');
++ return '<header class="ovt-head ex-chat-head" data-ovt-head>'
++ + kit.sidebarButton({ id: 'ovt-chats', label: 'Your chats' })
++ + '<div class="ex-chat-id"><div class="ex-group-faces">' + faces + '</div>'
++ + '<h1 class="ovt-title ex-chat-title">' + esc(group.title || 'Group chat') + '</h1></div>'
++ + '<a class="ovt-btn ex-chat-new" href="/groups" aria-label="Group chats">'
++ + thread.icon('plus') + '</a>'
++ + '<details class="ex-chat-settings"><summary class="ex-chip ex-chat-chip">Settings</summary>'
++ + '<div class="ex-chat-settings-body">'
++ + '<form class="ex-form" method="post" action="/talk/group/' + encodeURIComponent(group.id) + '/settings">'
++ + '<label class="ex-label" for="ex-g-mode">Who answers</label>'
++ + '<select id="ex-g-mode" name="mode">'
++ + ['named', 'round_robin', 'free'].map((m) => '<option value="' + m + '"' + (group.mode === m ? ' selected' : '') + '>' + esc(MODE_LABEL[m] || m) + '</option>').join('')
++ + '</select>'
++ + '<label class="ex-label" for="ex-g-turns">How many times each, when they talk</label>'
++ + '<input id="ex-g-turns" name="turns" type="number" min="1" max="5" value="' + esc(group.turns || 1) + '">'
++ + '<label class="ex-label" for="ex-g-speak">The voice</label>'
++ + '<select id="ex-g-speak" name="speak">'
++ + '<option value="1"' + (group.speak === false ? '' : ' selected') + '>Read the answers aloud</option>'
++ + '<option value="0"' + (group.speak === false ? ' selected' : '') + '>Keep them written</option>'
++ + '</select>'
++ + '<button type="submit" class="ex-btn">Save</button>'
++ + '</form></div></details>'
++ + '<form class="ex-act-form" method="post" action="/talk/group/' + encodeURIComponent(group.id) + '/image"><button type="submit" class="ex-chip" title="Make an image of this group chat">Image</button></form>'
++ + '</header>';
++}
++
++/** The monogram's colour token, the same rule as a card's own page. */
++const MONO_TOKENS = ['--ovk-accent', '--ovk-ok', '--ovk-warn', '--ovk-bad'];
++function monoToken(name) {
++ const s = String(name || '').trim().toLowerCase();
++ let h = 2166136261;
++ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
++ return MONO_TOKENS[(h >>> 0) % MONO_TOKENS.length];
++}
++
++/** The sound of an answer, read aloud in the character's own voice. */
++function clipAudio(clip) {
++ if (!clip || !clip.id) return '';
++ return '<p class="ex-voice"><audio class="ex-voice-audio" controls preload="none" src="/voice/' + encodeURIComponent(clip.id) + '">'
++ + 'Your browser cannot play this sound. <a href="/voice/' + encodeURIComponent(clip.id) + '">Download it</a>.</audio>'
++ + '</p>';
++}
++
++/** One turn of a group chat: the person's words, then what every character said. */
++function groupTurns(messages, cards) {
++ return messages.map((m) => {
++ if (m.role === 'user') {
++ return '<li class="ovt-turn ex-turn" data-turn="' + esc(m.turn_id || m.id) + '">'
++ + '<div class="ovt-you"><p class="ovt-bubble">' + esc(m.content || '') + '</p></div></li>';
++ }
++ const card = cards[m.character_id] || { name: 'Someone' };
++ const face = thread.face({ name: card.name, color: 'var(' + monoToken(card.name) + ')' });
++ const answer = thread.answer({
++ html: esc(m.content || '').split(String.fromCharCode(10)).join('<br>'),
++ meta: [], copy: false,
++ });
++ const clip = m.meta && m.meta.clip ? m.meta.clip : null;
++ const voice = m.meta && m.meta.voice ? m.meta.voice : null;
++ return '<li class="ovt-turn ex-turn" data-turn="' + esc(m.turn_id || m.id) + '">'
++ + '<div class="ovt-bot">' + face + '<div class="ovt-reply">'
++ + '<p class="ex-who">' + esc(card.name) + '</p>'
++ + answer
++ + (voice ? ' <span class="ex-chip">voice: ' + esc(voice) + '</span>' : '')
++ + clipAudio(clip)
++ + '</div></div></li>';
++ });
++}
++
++module.exports = { groupsPage, groupHead, groupTurns, memberChoices, clipAudio, monoToken };
ddiff --git a/server/views/companions.js b/server/views/companions.js
iindex cceca9a..0270711 100644
---- a/server/views/companions.js
++++ b/server/views/companions.js
@@@ -122,6 +122,7 @@ function characterPage({ card, tags = [], viewer = null, mine = false, problem =
+ section('First message', card.first_message)
+ section('Example dialogue', card.example_dialogue)
+ (card.model ? '<p class="ex-companion-model">Works best on <b>' + esc(card.model) + '</b>. You can pick any model or preset when you talk.</p>' : '')
++ + (card.voice ? '<p class="ex-companion-voice">Speaks in the voice <b>' + esc(card.voice) + '</b>: its answers are read aloud in it.</p>' : '')
+ '<p class="ex-companion-meta">' + remixes + ' remix' + (remixes === 1 ? '' : 'es')
+ ' · <a class="ex-companion-export" href="/companions/' + esc(card.id) + '/export?format=json">Export JSON</a>'
+ ' · <a class="ex-companion-export" href="/companions/' + esc(card.id) + '/export?format=png">Export PNG</a></p>'
@@@ -166,6 +167,8 @@ function cardForm({ card = null, action = '/companions/new', title = 'Make a cha
+ '<p class="ex-hint">A card rated 18+ whose stated age is under 18 is refused at save. The hard floor applies to every card: no sexual content involving minors, no non-consensual sexual content (fiction included) or incest, no help with weapons of mass harm or attacks on others.</p>'
+ field('avatar_url', 'Avatar image URL (optional)', c.avatar_url, 'maxlength="400"')
+ field('model', 'Model this card recommends (optional)', c.model, 'maxlength="120"')
++ + field('voice', 'Voice (optional)', c.voice, 'maxlength="120" placeholder="the voice this character speaks in"')
++ + '<p class="ex-hint">A voice is a voice id of OpenVibe.Chat (for example <b>gary</b>). When the card names one, its answers are read aloud in it.</p>'
+ '<label class="ex-label" for="ex-import">Import a card instead (JSON or PNG)</label>'
+ '<input id="ex-import" type="file" name="import" accept=".json,.png,application/json,image/png">'
+ '<button type="submit" class="ex-btn ex-btn-primary">Save the card</button>'
ddiff --git a/test/companions-group.test.js b/test/companions-group.test.js
nnew file mode 100644
iindex 0000000..92c2b7c
---- /dev/null
++++ b/test/companions-group.test.js
@@@ -0,0 +1,196 @@
++'use strict';
++/**
++ * Steps 5 and 6 of Opus's design (2026-11-06): group chats, the voice, and the images.
++ *
++ * - a group chat keeps each character's own memory and its own voice;
++ * - an answer is read aloud once, in the right voice, and the rate limit is the person's own;
++ * - the image switch is off, and it refuses with a clear reason (no provider is ever called).
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++
++(async () => {
++ const ai = createFakeAi();
++ const ttsCalls = [];
++ const otherCalls = [];
++ let ttsStatus = 200;
++
++ // The M2 memory stand-in: the notes of every character, in that character's own scope.
++ const memItems = new Map();
++ const memoryFetch = async (url, opts = {}) => {
++ const u = new URL(String(url));
++ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body), json: async () => body });
++ if (u.pathname === '/api/v1/memory/items' && (opts.method || 'GET') === 'GET') {
++ const scope = u.searchParams.get('scope');
++ return ok({ items: [...memItems.values()].filter((i) => i.scope === scope) });
++ }
++ if (u.pathname === '/api/v1/memory/items:batch') {
++ const body = JSON.parse(opts.body || '{}');
++ const written = [];
++ for (const it of (body.items || [])) {
++ const id = 'mem_' + (memItems.size + 1);
++ const item = Object.assign({}, it, { id, status: 'active' });
++ memItems.set(id, item);
++ written.push(item);
++ }
++ return ok({ items: written });
++ }
++ return ok({ items: [] });
++ };
++
++ // OpenVibe.Chat, the voice: the sound of an answer in the character's own voice.
++ const fetchImpl = async (url, opts = {}) => {
++ const u = String(url);
++ if (u.includes('/api/tts/speak')) {
++ const body = JSON.parse(opts.body || '{}');
++ ttsCalls.push({ url: u, body, auth: String((opts.headers && opts.headers.Authorization) || '') });
++ const answer = (code) => ({ ok: false, status: code, text: async () => JSON.stringify({ error: 'Too many lines spoken right now', code: 'voice_rate_limited' }), json: async () => ({ error: 'Too many lines spoken right now', code: 'voice_rate_limited' }) });
++ if (ttsStatus === 429) return answer(429);
++ const good = { audio: 'QUFB', mime_type: 'audio/mpeg', rate: 48000, voice_id: body.voice || 'default' };
++ return { ok: true, status: 200, text: async () => JSON.stringify(good), json: async () => good };
++ }
++ otherCalls.push(u);
++ return await globalThis.fetch(url, opts);
++ };
++
++ const t = await boot({ ai, aiFetchImpl: memoryFetch, fetchImpl });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++
++ try {
++ let ada = null;
++ let brenda = null;
++ let groupId = null;
++
++ await check('two cards, each with its own voice', async () => {
++ await t.get('/companions/new', { as: alice, form: { name: 'Ada', description: 'A keeper of clocks', personality: 'precise, warm', scenario: 'a workshop', rating: 'everyone', voice: 'gary' } });
++ await t.get('/companions/new', { as: alice, form: { name: 'Brenda', description: 'A boatbuilder', personality: 'blunt', scenario: 'a yard', rating: 'everyone', voice: 'brenda' } });
++ const mine = await t.ctx.characters.listByOwner(alice.subject);
++ ada = mine.find((c) => c.name === 'Ada');
++ brenda = mine.find((c) => c.name === 'Brenda');
++ assert.ok(ada && brenda, 'both cards are made');
++ assert.strictEqual(ada.voice, 'gary');
++ assert.strictEqual(brenda.voice, 'brenda');
++ });
++
++ await check('a group chat needs two of the person\'s own characters', async () => {
++ const one = await t.get('/groups/new', { as: alice, form: { members: ada.id } });
++ assert.strictEqual(one.status, 422);
++ assert.ok(one.text.includes('at least two'), one.text.slice(0, 300));
++ });
++
++ await check('each character keeps its own memory: the notes are in its own scope', async () => {
++ const token = t.network.userToken(alice);
++ await t.ctx.notes.add(alice.subject, ada.id, { kind: 'fact', text: 'Ada knows the person keeps clocks' }, token);
++ await t.ctx.notes.add(alice.subject, brenda.id, { kind: 'fact', text: 'Brenda knows the person likes sailing' }, token);
++ const adaNotes = await t.ctx.notes.list(alice.subject, ada.id, token);
++ assert.strictEqual(adaNotes.length, 1);
++ assert.ok(adaNotes[0].text.includes('keeps clocks'));
++ });
++
++ await check('a group chat is made, and it is not another person\'s', async () => {
++ const body = 'title=The+morning+crew&mode=round_robin&turns=1'
++ + '&members=' + encodeURIComponent(ada.id) + '&members=' + encodeURIComponent(brenda.id);
++ const made = await t.get('/groups/new', { as: alice, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
++ assert.strictEqual(made.status, 303, made.text.slice(0, 300));
++ groupId = String(made.headers.get('location')).split('/').pop();
++ const page = await t.get('/talk/group/' + groupId, { as: alice });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
++ assert.ok(page.text.includes('The morning crew'));
++ const other = await t.get('/talk/group/' + groupId, { as: bob });
++ assert.strictEqual(other.status, 404, 'another account finds no such group chat');
++ });
++
++ await check('the characters take their turns, each in its own voice and its own memory', async () => {
++ ai.calls.length = 0;
++ ttsCalls.length = 0;
++ const sent = await t.get('/talk/group/' + groupId + '/send', { as: alice, form: { text: 'Good morning, everyone' } });
++ assert.strictEqual(sent.status, 303, sent.text.slice(0, 300));
++ const messages = await t.ctx.groups.messages(groupId);
++ const answers = messages.filter((m) => m.role === 'assistant');
++ assert.strictEqual(answers.length, 2, 'both characters answered, once each');
++ const adaAnswer = answers.find((m) => m.character_id === ada.id);
++ const brendaAnswer = answers.find((m) => m.character_id === brenda.id);
++ assert.ok(adaAnswer && brendaAnswer);
++ assert.strictEqual(adaAnswer.meta.voice, 'gary', 'Ada answers in her own voice');
++ assert.strictEqual(brendaAnswer.meta.voice, 'brenda', 'Brenda answers in her own voice');
++ // The notes that joined each answer are that character's own, and nobody else's.
++ assert.ok(adaAnswer.meta.notes.some((n) => n.text.includes('keeps clocks')), 'Ada answers with her own note');
++ assert.ok(!adaAnswer.meta.notes.some((n) => n.text.includes('sailing')), 'Brenda\'s note never joins Ada\'s answer');
++ assert.ok(brendaAnswer.meta.notes.some((n) => n.text.includes('sailing')), 'Brenda answers with her own note');
++ assert.ok(!brendaAnswer.meta.notes.some((n) => n.text.includes('keeps clocks')), 'Ada\'s note never joins Brenda\'s answer');
++ // And the same, in the system prompt that was sent.
++ const adaCall = ai.calls.find((c) => String(c.messages[0].content).includes('You are Ada.'));
++ const brendaCall = ai.calls.find((c) => String(c.messages[0].content).includes('You are Brenda.'));
++ assert.ok(adaCall && brendaCall);
++ assert.ok(String(adaCall.messages[0].content).includes('keeps clocks'));
++ assert.ok(!String(adaCall.messages[0].content).includes('sailing'));
++ assert.ok(String(brendaCall.messages[0].content).includes('sailing'));
++ assert.ok(!String(brendaCall.messages[0].content).includes('keeps clocks'));
++ });
++
++ await check('the group page shows every character and every voice', async () => {
++ const page = await t.get('/talk/group/' + groupId, { as: alice });
++ assert.ok(page.text.includes('Ada') && page.text.includes('Brenda'));
++ assert.ok(page.text.includes('voice: gary') && page.text.includes('voice: brenda'));
++ assert.ok(page.text.includes('Good morning, everyone'));
++ });
++
++ await check('the one named answers, and only the one named', async () => {
++ await t.get('/talk/group/' + groupId + '/settings', { as: alice, form: { mode: 'named', turns: '1', speak: '1' } });
++ ai.calls.length = 0;
++ const sent = await t.get('/talk/group/' + groupId + '/send', { as: alice, form: { text: 'Brenda, what do you think?' } });
++ assert.strictEqual(sent.status, 303);
++ const messages = await t.ctx.groups.messages(groupId);
++ const last = messages.filter((m) => m.role === 'assistant').slice(-1)[0];
++ assert.strictEqual(last.character_id, brenda.id, 'the character that was named answers');
++ });
++
++ let chatId = null;
++ await check('an answer is read aloud once, in the card\'s own voice', async () => {
++ const talk = await t.get('/c/alice/ada/talk', { as: alice, method: 'POST' });
++ assert.strictEqual(talk.status, 303, talk.text.slice(0, 200));
++ chatId = String(talk.headers.get('location')).split('/').pop();
++ ttsCalls.length = 0;
++ const sent = await t.get('/talk/' + chatId + '/send', { as: alice, form: { text: 'Hello Ada' } });
++ assert.strictEqual(sent.status, 303);
++ assert.strictEqual(ttsCalls.length, 1, 'the answer is read aloud exactly once');
++ assert.strictEqual(ttsCalls[0].body.voice, 'gary', 'in the voice the card names');
++ assert.strictEqual(ttsCalls[0].auth, 'Bearer ' + t.network.userToken(alice), 'the call carries the person\'s own token');
++ const page = await t.get('/talk/' + chatId, { as: alice });
++ assert.strictEqual((page.text.match(/<audio/g) || []).length, 1, 'one sound on the page');
++ assert.ok(page.text.includes('voice: gary'));
++ });
++
++ await check('the rate limit is the person\'s own, and the answer is still written', async () => {
++ ttsStatus = 429;
++ ttsCalls.length = 0;
++ const sent = await t.get('/talk/' + chatId + '/send', { as: alice, form: { text: 'And again' } });
++ assert.strictEqual(sent.status, 303);
++ assert.strictEqual(ttsCalls.length, 1, 'the voice is asked once, and not again after the limit');
++ const page = await t.get('/talk/' + chatId, { as: alice });
++ assert.ok(page.text.includes('And again'), 'the answer is written');
++ assert.strictEqual((page.text.match(/<audio/g) || []).length, 1, 'no second sound');
++ ttsStatus = 200;
++ });
++
++ await check('the image switch is off, and it refuses with a clear reason', async () => {
++ const before = otherCalls.length;
++ const img = await t.get('/talk/' + chatId + '/image', { as: alice, method: 'POST' });
++ assert.strictEqual(img.status, 422, img.text.slice(0, 200));
++ assert.ok(img.text.includes('Images are switched off'), 'the reason is on the page');
++ assert.ok(img.text.includes('no image provider'), 'the reason names the missing provider');
++ assert.strictEqual(otherCalls.length, before, 'no provider was called');
++ });
++
++ await check('the group image switch is off too', async () => {
++ const img = await t.get('/talk/group/' + groupId + '/image', { as: alice, method: 'POST' });
++ assert.strictEqual(img.status, 422);
++ assert.ok(img.text.includes('Images are switched off'));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex d1ed455..a132f94 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
// The M2 memory client goes out with this fetch. A test that does not stand one up gets an empty store, so
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl, openapiFetch: opts.openapiFetch });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl, openapiFetch: opts.openapiFetch, fetchImpl: opts.fetchImpl });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;