openvibers/openvibe.exchange Commits
Companions S5 and S6: group chats, and the answers read aloud in each character's own voice (#101)
Changes
@@@ -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=truennew file mode 100644@@@ -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;@@@ -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');nnew file mode 100644@@@ -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 };@@@ -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) {nnew file mode 100644@@@ -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 };@@@ -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).@@@ -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'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 };@@@ -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,});}nnew file mode 100644@@@ -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 };@@@ -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>'nnew file mode 100644@@@ -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); });@@@ -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}`;
@@ -54,5 +54,16 @@ EXCHANGE_EVENTS_URL=
@@ -54,5 +54,16 @@ EXCHANGE_EVENTS_URL=
# A restore drill starts no subscriptions and creates no Events subscription.
# A restore drill starts no subscriptions and creates no Events subscription.
# EXCHANGE_DRILL=0
# 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
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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;
@@ -34,6 +34,8 @@ const { createArenaStore } = require('./arena/store');
@@ -34,6 +34,8 @@ const { createArenaStore } = require('./arena/store');
const { createCatalogHistory } = require('./catalog-history');
const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
+const { createGroupStore } = require('./companions/group');
+const { createVoiceClient } = require('./companions/voice');
const { createSharing } = require('./sharing');
const { createSharing } = require('./sharing');
const { createAdultAccount } = require('./companions/adult');
const { createAdultAccount } = require('./companions/adult');
const { createMemoryClient, createCompanionNotes } = require('./companions/memory');
const { createMemoryClient, createCompanionNotes } = require('./companions/memory');
@@ -80,6 +82,10 @@ async function createApp(opts = {}) {
@@ -80,6 +82,10 @@ async function createApp(opts = {}) {
// Companions (2026-11-06): the cards, their chats, and the one sharing system they use.
// Companions (2026-11-06): the cards, their chats, and the one sharing system they use.
const characters = createCharacterStore(s);
const characters = createCharacterStore(s);
const companionChats = createCompanionChatStore(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 });
const sharing = createSharing({ db: s.db, config, fetchImpl, log });
// The 18+ confirmation, read from the account on Network (Network PR #587).
// The 18+ confirmation, read from the account on Network (Network PR #587).
const adultAccount = createAdultAccount({ config, fetchImpl, log, now: s.now });
const adultAccount = createAdultAccount({ config, fetchImpl, log, now: s.now });
@@ -100,7 +106,7 @@ async function createApp(opts = {}) {
@@ -100,7 +106,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
: 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 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -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 };
@@ -195,11 +195,12 @@ function createCompanionChatStore(s) {
@@ -195,11 +195,12 @@ function createCompanionChatStore(s) {
async function settings(owner, chatId, next) {
async function settings(owner, chatId, next) {
const sets = [];
const sets = [];
const values = [];
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;
if (!(f in next)) continue;
sets.push(f + ' = ?');
sets.push(f + ' = ?');
if (f === 'temperature') values.push(next[f] == null || next[f] === '' ? null : Number(next[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 === '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));
else values.push(next[f] == null || next[f] === '' ? null : String(next[f]).slice(0, 2000));
}
}
if (sets.length) {
if (sets.length) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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 };
@@ -110,6 +110,20 @@ function load(env = process.env) {
@@ -110,6 +110,20 @@ function load(env = process.env) {
noteModel: String(env.EXCHANGE_COMPANIONS_NOTE_MODEL || 'openvibe/economy').slice(0, 120),
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.
// 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)),
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).
// /chat: how many messages a signed-in person may send a day (each one may run up to three models).
@@ -32,13 +32,15 @@ const floor = require('../companions/floor');
@@ -32,13 +32,15 @@ const floor = require('../companions/floor');
const codec = require('../companions/codec');
const codec = require('../companions/codec');
const { tokensOf, KINDS: NOTE_KINDS } = require('../companions/memory');
const { tokensOf, KINDS: NOTE_KINDS } = require('../companions/memory');
const { readTurns } = require('../companions/store');
const { readTurns } = require('../companions/store');
+const groupLib = require('../companions/group');
+const groupViews = require('../views/companion-groups');
const multer = require('multer');
const multer = require('multer');
const handleOf = (name) => String(name || 'someone').toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'someone';
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);
const tagsOf = (s) => String(s || '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean).slice(0, 5);
function createCompanionRoutes(ctx) {
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 db = ctx.s && ctx.s.db;
const r = asyncRouter();
const r = asyncRouter();
// A card may arrive as a file (a PNG with the card inside, or plain JSON).
// A card may arrive as a file (a PNG with the card inside, or plain JSON).
@@ -221,6 +223,8 @@ function createCompanionRoutes(ctx) {
@@ -221,6 +223,8 @@ function createCompanionRoutes(ctx) {
rating: (imported ? (src.rating === 'adult' ? 'adult' : 'everyone') : (b.rating === 'adult' ? 'adult' : 'everyone')),
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),
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),
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,
tags,
};
};
}
}
@@ -847,6 +851,8 @@ function createCompanionRoutes(ctx) {
@@ -847,6 +851,8 @@ function createCompanionRoutes(ctx) {
meta: [], copy: false,
meta: [], copy: false,
})
})
+ receiptFor(turn.answer)
+ 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>'
+ '</article>'
: '';
: '';
const acts = turn.answer
const acts = turn.answer
@@ -936,6 +942,11 @@ function createCompanionRoutes(ctx) {
@@ -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)) + '">'
+ '<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>'
+ '<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>'
+ '<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">'
+ '<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 + '/memory') + '">Memory' + (chat.locked ? ' · locked' : '') + '</a>'
+ '<a class="ex-chip" href="' + esc(base + '/lorebook?chat=' + encodeURIComponent(chat.id)) + '">Lorebook</a>'
+ '<a class="ex-chip" href="' + esc(base + '/lorebook?chat=' + encodeURIComponent(chat.id)) + '">Lorebook</a>'
@@ -955,6 +966,7 @@ function createCompanionRoutes(ctx) {
@@ -955,6 +966,7 @@ function createCompanionRoutes(ctx) {
+ chatSettings(chat, models, card)
+ chatSettings(chat, models, card)
+ '<a class="ovt-btn ex-chat-new" href="/companions" aria-label="Characters">' + thread.icon('plus') + '</a>'
+ '<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>'
+ '<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>';
+ '</header>';
}
}
@@ -1010,6 +1022,39 @@ function createCompanionRoutes(ctx) {
@@ -1010,6 +1022,39 @@ function createCompanionRoutes(ctx) {
}
}
/** Save the settings the composer carries, and answer. */
/** 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. */
/** Turn a flat list of messages into the pairs the note taker reads. */
function pairsOf(flat) {
function pairsOf(flat) {
const out = [];
const out = [];
@@ -1034,6 +1079,8 @@ function createCompanionRoutes(ctx) {
@@ -1034,6 +1079,8 @@ function createCompanionRoutes(ctx) {
temperature: req.body && req.body.temperature !== undefined ? (req.body.temperature === '' ? null : Number(req.body.temperature)) : chat.temperature,
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,
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,
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 userText = plainText != null ? String(plainText) : String(userMessage.content == null ? '' : userMessage.content);
const messages = await companionChats.messages(chat.id);
const messages = await companionChats.messages(chat.id);
@@ -1100,16 +1147,22 @@ function createCompanionRoutes(ctx) {
@@ -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 });
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;
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,
owner: subject, chatId: chat.id, role: 'assistant', content: stored,
turnId, variant, parentId: userMessage.id, model: settings.model || null,
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.
// After every few turns, the cheap model writes short notes into the person's own memory.
try {
try {
const after = await companionChats.messages(chat.id);
const after = await companionChats.messages(chat.id);
@@ -1241,7 +1294,236 @@ function createCompanionRoutes(ctx) {
@@ -1241,7 +1294,236 @@ function createCompanionRoutes(ctx) {
return res.redirect(303, '/companions');
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'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;
return r;
+
}
}
module.exports = { createCompanionRoutes };
module.exports = { createCompanionRoutes };
@@ -42,6 +42,12 @@ const TABLES = [
@@ -42,6 +42,12 @@ const TABLES = [
{ table: 'exchange_lorebook_entries', subject: 'owner', value: (usr) => usr, file: 'lorebook-entries.json', erase: 'delete' },
{ 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_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' },
{ 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.
// 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' },
{ table: 'exchange_share_defaults', subject: 'owner', value: (usr) => usr, file: 'share-defaults.json', erase: 'delete' },
];
];
@@ -94,7 +100,7 @@ function create({ db, log = console } = {}) {
@@ -94,7 +100,7 @@ function create({ db, log = console } = {}) {
service: 'exchange',
service: 'exchange',
tables: TABLES,
tables: TABLES,
extraErase,
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,
log,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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 };
@@ -122,6 +122,7 @@ function characterPage({ card, tags = [], viewer = null, mine = false, problem =
@@ -122,6 +122,7 @@ function characterPage({ card, tags = [], viewer = null, mine = false, problem =
+ section('First message', card.first_message)
+ section('First message', card.first_message)
+ section('Example dialogue', card.example_dialogue)
+ 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.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')
+ '<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=json">Export JSON</a>'
+ ' · <a class="ex-companion-export" href="/companions/' + esc(card.id) + '/export?format=png">Export PNG</a></p>'
+ ' · <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
@@ -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>'
+ '<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('avatar_url', 'Avatar image URL (optional)', c.avatar_url, 'maxlength="400"')
+ field('model', 'Model this card recommends (optional)', c.model, 'maxlength="120"')
+ 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>'
+ '<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">'
+ '<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>'
+ '<button type="submit" class="ex-btn ex-btn-primary">Save the card</button>'
new file mode 100644
new file mode 100644
@@ -0,0 +1,196 @@
@@ -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); });
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -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
// 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).
// 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 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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;