openvibers/openvibe.chat Commits
Vibe personas: a name and a face for each conversation (#546)
Changes
@@@ -160,6 +160,7 @@ same.- [README.md](README.md) — the product, cutover, data, events, rooms, Vibe, inboxes, feeds, deploy.- [docs/platform.md](docs/platform.md) — agent rooms and the digest Code/Chat/Actor use.++- [docs/vibe-personas.md](docs/vibe-personas.md) — the persona shape, the topic check, models, spend cap, owner page and tests.- [docs/chat-ingress.md](docs/chat-ingress.md) — `/internal/chat/*` tickets and reads.- [docs/calls-cutover.md](docs/calls-cutover.md), [docs/parity.md](docs/parity.md),[docs/chat-apps.md](docs/chat-apps.md), [docs/party-pool.md](docs/party-pool.md).@@@ -148,6 +148,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` || Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer || Vibe's search (OpenVibe and the web) | **OpenVibe.Search** (Search Fabric, `GET /api/v1/query`) | `server/rooms/search-fabric.js` (openvibe-sdk/search 0.70), Chat's service token (Network grants `search.query.delegate` on `openvibe.search`), the person in `X-OV-Subject`; each result keeps its provider, date and URL; Search's `/api/v1/search` and Actor's web task are the fallbacks only when the fabric is down |++| Vibe personas (name, glyph, hue, topic, avatar) | **OpenVibe.AI / Exchange** and **OpenVibe.Media** | `server/persona/index.js`: `ai.complete` with an explicit catalogue model and `chat.persona.image` for a picture, Chat's service token; each run's `usage.cost_usd` is kept in `vibe_persona_spend` under the owner's cap; avatar bytes go to Media (namespace `chat`) || Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` || Events | **OpenVibe.Events** | `service_outbox` (openvibe-sdk outbox) → `POST /api/v1/events` when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |@@@ -386,6 +387,20 @@ turned on "Let my AI use this computer's models".says "Vibe thinks with … (change)", "How Vibe did this" says which model answered, and a computer that does not answeris named, with what to check. Removing the key ends it.++**Vibe personas** (`server/persona/`, `migrations/0078_vibe_personas.sql`; the owner, 2026-11-12). Each Vibe++conversation gets a name and a face made from what it is about, and a new one when the topic changes. The shape is++`persona { name, glyph, hue, image_url|null, topic, made_at }`, on every item of `GET /api/vibe/conversations` and on++`chat` in `GET /api/vibe/chat` (null until the first answer, and past the cap). The name also names the chat until the++person renames it. The first is made after the first answer; then every 8 messages (configurable) the cheapest chat++model answers one word, `YES` or `NO`, to "has the topic changed?". The glyph is one name from++`openvibe-shared/glyphs`; an invalid answer is retried once and then replaced from the registry. Auto models are the++cheapest chat and image models in Exchange's catalogue; every call is `ai.complete` (or Chat's++`chat.persona.image` workflow for a picture) under Chat's own grant, billed to Chat, with its `usage.cost_usd`++recorded against the owner's monthly cap. Images are off by default and go to OpenVibe.Media. The owner page is++`/admin/persona` (spend shown, model choices, interval, cap, remake one chat); the same values are the revisioned++`chat.persona` namespace, and `GET|POST /api/admin/persona` + `POST /api/admin/persona/remake` are owner-sign-in++only. `docs/vibe-personas.md` has the shape, settings and test notes.++**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text@@@ -865,6 +880,7 @@ server/prefs/ chat preferences in the Network user module chat.prefserver/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycleserver/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service tokenserver/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)++server/persona/ Vibe personas: settings.js (chat.persona owner settings), index.js (the first name, the topic check, the cap, the avatar)server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)scripts/ parity, subscribe-events, n-1-recorddocs/ calls-cutover.md, parity.md, chat-ingress.md, capabilities-proposal/nnew file mode 100644@@@ -0,0 +1,104 @@++# Vibe personas++++Each Vibe conversation on OpenVibe.Chat has a persona: a name and a face made from what the chat is about,++remade when the topic changes. The owner, 2026-11-12: "for each vibe chat, it should generate a name based on++whatever the conversation or the vibe of the chat is, and then it'll generate an avatar for that chat bot …++and update every once in a while if the topic changes … the cheapest model possible, but configurable on the++admin panel."++++## The shape++++Every persona is exactly this object, on `GET /api/vibe/conversations` (each item) and on `chat` in++`GET /api/vibe/chat`:++++```json++{++ "name": "Keyboard Scout",++ "glyph": "keyboard",++ "hue": 210,++ "image_url": null,++ "topic": "mechanical keyboards and switches",++ "made_at": "2026-11-12T09:31:26.431Z"++}++```++++- `name`: 1–3 Title Case words that say what the chat is about, as a character. No real person's name, no slur,++ no digits. It is also the conversation's title until the person renames it.++- `glyph`: one name from the OpenVibe glyph registry (`require('openvibe-shared/glyphs').NAMES`). A model answer++ with any other name is retried once, then replaced from the registry, so no consumer ever receives an unknown++ icon.++- `hue`: 0–359, the colour that fits the topic.++- `image_url`: null while avatar pictures are off (the default); otherwise a small public OpenVibe.Media URL.++- `topic`: at most 12 words saying what the chat is about.++- `made_at`: UTC ISO timestamp.++++`persona` is `null` for a chat whose first answer has not happened yet, and for a chat past the monthly cap.++++## When it is made++++- After the first answer. The second message (Vibe's answer) is stored, then a `setImmediate` due check makes the++ persona. A reply never waits for it.++- Then every `check_every` messages (default 8): the cheapest chat model reads the current topic and the recent++ messages and answers exactly `YES` or `NO`. `NO` keeps the persona; `YES` makes a new one.++- One generation per room at a time, with an idempotency key on every model call, so a retry or a restart replays++ the same answer rather than paying twice.++- If the person renamed the chat (`PATCH /api/chat/vibe/:slug`), `rooms.vibe_title_locked` is set and a persona++ never writes over their title.++++## Models and money++++Every model call is OpenVibe.AI's `ai.complete` (Chat's own Network grant, namespace `chat.*` + `ai.complete`) with++an explicit Exchange catalogue model. `text_model` empty means Auto: the cheapest chat model in++`GET /api/v1/exchange/catalog`, chosen by input + output price. Avatar pictures use the Chat-owned++`chat.persona.image` workflow (the same image step as `ai.image`, under Chat's namespace) with the cheapest image++model when `image_model` is empty; the bytes are uploaded to OpenVibe.Media as Chat and the public URL is stored on++the persona. A failed picture never loses the name.++++The runs are Chat's own (not the person's allowance). Each run's `usage.cost_usd` is recorded in++`vibe_persona_spend`; the owner's monthly cap is checked before every call and read from that table. `0` stops++persona spending. At the cap, existing personas stay and new chats get no persona until the cap is raised or the++month turns (UTC).++++## Owner settings++++The page is **https://openvibe.chat/admin/persona** (owner only: an admin with Network's owner flag). It shows this++month's spend next to the cap, the model choices with their prices, the interval, and every recent conversation++with a **Remake** button. The same values are a revisioned configuration namespace, `chat.persona`, in++`openvibe-shared/config`, so they are auditable and Network's owner settings proxy can read them.++++| Key | Default | Meaning |++|---|---|---|++| `enabled` | `true` | make and check personas at all |++| `text_model` | `''` | catalogue model for names and checks; empty = Auto (cheapest chat model) |++| `images` | `false` | make an avatar picture too |++| `image_model` | `''` | catalogue model for the avatar; empty = Auto (cheapest image model) |++| `check_every` | `8` | messages between topic checks (2–100) |++| `monthly_cap_usd` | `5` | most Chat may spend on personas in a UTC month; 0 stops it |++++API, for scripts and the page:++++```http++GET /api/admin/persona { settings, defaults, spend, models, conversations, month, revision }++POST /api/admin/persona { enabled?, text_model?, images?, image_model?, check_every?, monthly_cap_usd? }++POST /api/admin/persona/remake { slug } → { persona, conversations }++```++++All three are owner sign-in only (an API token is refused) and return 403 to anyone else. `spend` is++`{ month, usd, cap_usd, remaining_usd, reached }`.++++## Storage++++- `vibe_personas` (migration `0078_vibe_personas.sql`): one row per room, the six fields and the check cursor++ (`checked_count`, `checked_at`), generation number, cascade with the room.++- `vibe_persona_spend`: one row per model call with month, kind (`text`/`image`), model, run id and the run's cost.++- `rooms.vibe_title_locked`: set by `server/rooms/vibe.js` `rename`; personas leave the title alone after that.++++The engine is `server/persona/index.js`; the settings store `server/persona/settings.js`; the page++`server/web/persona-admin.js`; the JSON API `server/rooms/persona-api.js`.++++## Tests++++`test/vibe-persona.test.js` uses recorded model answers (`test/fixtures/vibe-persona/recorded.json`) through++`aiClient._setClient()` and an injected catalogue (`aiClient._setCatalog()`), with a stand-in Media client, so no++test touches the network. It covers the first persona, the API shape and title, a NO check, a YES change, an++invalid glyph, the monthly cap, images, and the owner-only settings and remake.nnew file mode 100644@@@ -0,0 +1,35 @@++-- phase: expand++-- A face and a name for each Vibe conversation (the owner, 2026-11-12): made from what the chat is about,++-- remade when its topic changes. The persona belongs to the room and goes with it; the spend is what Chat's++-- own models cost for personas this month, so the owner's cap is real money and not a counter of attempts.++CREATE TABLE IF NOT EXISTS vibe_personas (++ room_id bigint PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,++ name text COLLATE "C" NOT NULL,++ glyph text COLLATE "C" NOT NULL,++ hue smallint NOT NULL,++ image_url text COLLATE "C",++ topic text COLLATE "C" NOT NULL,++ made_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ checked_at text COLLATE "C",++ checked_count integer NOT NULL DEFAULT 0,++ generation integer NOT NULL DEFAULT 1,++ CONSTRAINT vibe_personas_hue CHECK (hue >= 0 AND hue <= 359),++ CONSTRAINT vibe_personas_generation CHECK (generation >= 1)++);++++CREATE TABLE IF NOT EXISTS vibe_persona_spend (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ room_id bigint REFERENCES rooms(id) ON DELETE SET NULL,++ month text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ model text COLLATE "C",++ cost_usd numeric NOT NULL DEFAULT 0,++ run_id text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ CONSTRAINT vibe_persona_spend_kind CHECK (kind IN ('text', 'image'))++);++CREATE INDEX IF NOT EXISTS vibe_persona_spend_month ON vibe_persona_spend(month);++++-- A chat is named after its first message until the person renames it. Once they do, a persona never++-- writes over their name; this is the flag that records it.++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS vibe_title_locked smallint NOT NULL DEFAULT 0;@@@ -14,6 +14,7 @@ let _ai = null;let _http = null;let _tokens = null;let _complete = null;++let _catalog = null; // tests: a recorded catalogue, so no model page or persona test touches the network// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.let _lastProblem = null;@@@ -182,10 +183,11 @@ async function complete({ model, messages, subject, temperature = null }) {}async function catalog() {++ if (_catalog) return _catalog();const res = await fetch(`${config.ai.internalUrl}/api/v1/exchange/catalog`, { signal: AbortSignal.timeout(8000) });if (!res.ok) throw new Error(`AI catalogue answered ${res.status}`);return res.json();}--module.exports = { structured, stream, complete, catalog, enabled, bearer, http, lastProblem, _setClient(c) { _ai = c; }, _setComplete(c) { _complete = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },-- _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _complete = null; _lastProblem = null; } };++module.exports = { structured, stream, complete, catalog, enabled, bearer, http, lastProblem, _setClient(c) { _ai = c; }, _setCatalog(c) { _catalog = c; }, _setComplete(c) { _complete = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },++ _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _complete = null; _catalog = null; _lastProblem = null; } };@@@ -65,7 +65,7 @@ function getAllowedOrigins() {return allowed;}--function createApp({ chatServer, relay, events = null, callServer = null, settings = null, chatAppSettings = null }) {++function createApp({ chatServer, relay, events = null, callServer = null, settings = null, chatAppSettings = null, personaSettings = null }) {const allowedOrigins = getAllowedOrigins();const app = express();// Times leave with their zone (openvibe.design rule 148): the SQLite-era text columns are UTC.@@@ -160,11 +160,11 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin// or x-danger change needs X-OV-Fresh: 1, and the test route synthesizes one short line without echoing a key.// chat.apps (the Telegram, Discord and Slack bots) is served by the same routes; its Test button is mounted first,// because the voices' test route answers 404 for every other namespace.-- if (settings || chatAppSettings) {++ if (settings || chatAppSettings || personaSettings) {const guard = serviceAuth.guard('chat.config.manage');app.use('/internal/config', express.json({ limit: '64kb' }));if (chatAppSettings) app.post(`/internal/config/${chatAppSettings.namespace}/test/:key`, guard, require('./chat-apps/owner-settings').testRoute(chatAppSettings));-- const also = chatAppSettings ? [chatAppSettings.store] : [];++ const also = [chatAppSettings && chatAppSettings.store, personaSettings && personaSettings.store].filter(Boolean);if (settings) settings.mount(app, { guard, also });else require('./settings').mountStores(app, { guard, stores: also });}@@@ -377,6 +377,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settinapp.use('/api/dm', require('./chat/dm-routes'));// Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.app.use('/api/vibe', require('./rooms/vibe-api'));++ // The owner's Vibe persona settings (server/rooms/persona-api.js): owner sign-in only, never an API token.++ app.use('/api/admin/persona', limits.identify, require('./rooms/persona-api'));// A companion's answer, read aloud in the character's own voice (the person's own token, the agent// voice's per-person limiter).app.use('/api/tts', require('./chat/tts-person'));@@@ -63,6 +63,15 @@ async function start() {} catch (err) {console.warn('[Chat] chat-app settings unavailable, the chat apps read the environment:', err && err.message);}++ // Vibe personas' owner settings (chat.persona, server/persona/settings.js): the models, the re-check interval++ // and the monthly cap. A failure turns personas off (no store, no spending) and says why in the log.++ let personaSettings = null;++ try {++ personaSettings = await require('./persona/settings').createPersonaSettings({ db: db.getDb(), log: console });++ console.log(`[Chat] owner settings ready (${personaSettings.namespace})`);++ } catch (err) {++ console.warn('[Chat] persona settings unavailable, Vibe personas are off:', err && err.message);++ }// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this// process, as before. openvibe-sdk/valkey's createValkey returns null when the URL is unset.@@@ -79,7 +88,7 @@ async function start() {callServer.init(server);const relay = serviceOutbox.configure({ config });const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });-- const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer, settings, chatAppSettings });++ const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer, settings, chatAppSettings, personaSettings });void app.locals.chatApps.start();server.on('request', app);server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });nnew file mode 100644@@@ -0,0 +1,709 @@++'use strict';++/**++ * Vibe personas (the owner, 2026-11-12): every Vibe conversation gets a name and a face made from what the++ * chat is about, remade when the topic changes.++ *++ * persona { name, glyph, hue, image_url|null, topic, made_at }++ *++ * The shape is deliberately small and product-neutral: Actor and Codes can show the same run card's agent with++ * it (research-2026-11-12/one-agent-system.md §2), and the main session draws it in the Vibe panel from the API++ * field, without OpenVibe.Shared changing.++ *++ * Rules:++ * - Made after the first answer; checked every `check_every` messages (default 8). A check asks the cheapest++ * text model whether the topic changed, one word: YES or NO. YES makes a new persona.++ * - Due-based, idempotent and never blocking a reply: afterPost() schedules setImmediate() work and returns;++ * one generation per room at a time; every model call carries an idempotency key; a change's key names the++ * persona generation and the message count it read.++ * - The name also names the conversation, unless the person renamed it (rooms.vibe_title_locked).++ * - Glyph is one name from openvibe-shared/glyphs; an invalid answer is retried once and then replaced by a++ * deterministic glyph from the same registry, so no other product can ever receive an unknown icon.++ * - The models are the owner's: '' = Auto = the cheapest chat (or image) model in Exchange's catalogue, read++ * through Chat's own OpenVibe.AI grant (`chat.member.turn`'s ai.complete passthrough is not used; Chat runs++ * `ai.complete` with an explicit catalogue model). Text runs are billed to Chat, not the person.++ * - A monthly cap in US dollars is checked before every call and read from the runs' own usage. At the cap++ * Chat stops making and checking personas; existing ones stay.++ * - Images are off by default. When on, a small avatar is made by the cheapest image model through the++ * Chat-owned `chat.persona.image` workflow (OpenVibe.AI), uploaded to OpenVibe.Media as Chat, and its++ * public URL stored on the persona. A failed picture never loses the name.++ *++ * Owner settings and the admin page: server/persona/settings.js, /admin/persona (server/web/persona-admin.js),++ * GET|POST /api/admin/persona and POST /api/admin/persona/remake (server/rooms/persona-api.js).++ */++const crypto = require('crypto');++const db = require('../db/database');++const aiClient = require('../ai/client');++const media = require('../media/client');++const wordFilter = require('../chat/word-filter');++const settings = require('./settings');++++const GLYPHS = require('openvibe-shared/glyphs');++const GLYPH_NAMES = Object.freeze([...GLYPHS.NAMES]);++const GLYPH_SET = new Set(GLYPH_NAMES);++// Topic-friendly names from the registry, checked here so a prompt never asks for an icon that does not exist.++const GLYPH_CHOICES = Object.freeze([++ 'airplane', 'archive', 'bell', 'book', 'book-open', 'brain', 'branch', 'bridge', 'briefcase', 'bug',++ 'calendar', 'camera', 'car', 'cart', 'chart', 'chat', 'chip', 'city', 'clapper', 'clipboard', 'clock',++ 'cloud', 'code', 'coin', 'compass', 'computer', 'cookie', 'crown', 'database', 'dice', 'dish', 'dollar',++ 'door', 'download', 'drive', 'ear', 'film', 'filter', 'fire', 'fish', 'flag', 'flask', 'focus', 'folder',++ 'food', 'game', 'gauge', 'gavel', 'gear', 'gem', 'ghost', 'gift', 'globe', 'grid', 'group', 'hammer',++ 'hand', 'handshake', 'hash', 'headphones', 'heart', 'help', 'history', 'home', 'idea', 'image', 'inbox',++ 'key', 'keyboard', 'language', 'layers', 'leaf', 'lifebuoy', 'link', 'live', 'lock', 'lungs', 'mail',++ 'map', 'mask', 'medical', 'megaphone', 'members', 'mic', 'money', 'moon', 'mountain', 'mug', 'music',++ 'news', 'palette', 'paw', 'pencil', 'phone', 'piggy', 'plane', 'play', 'puzzle', 'radio', 'ranking',++ 'recycle', 'reply', 'road', 'robot', 'rocket', 'route', 'scale', 'scissors', 'scroll', 'search',++ 'seedling', 'send', 'server', 'shapes', 'shield', 'shirt', 'shoe', 'signature', 'skull', 'smile', 'snow',++ 'spark', 'star', 'steps', 'store', 'storm', 'sun', 'sword', 'table', 'tag', 'target', 'task', 'tent',++ 'terminal', 'text', 'ticket', 'timeline', 'toolbox', 'train', 'tree', 'trophy', 'tv', 'umbrella', 'user',++ 'video', 'wallet', 'wave', 'weight', 'wifi', 'wind', 'wizard', 'wrench', 'zap',++].filter((name) => GLYPH_SET.has(name)));++++const FALLBACK_TEXT_MODEL = 'shared/gpt-5-nano';++const FALLBACK_IMAGE_MODEL = 'openrouter/google/gemini-3.1-flash-lite-image';++const MAX_NAME = 32;++const MAX_TOPIC = 120;++const HISTORY_FOR_FIRST = 4;++const HISTORY_FOR_CHECK = 12;++const MAX_MESSAGE_CHARS = 700;++const MAX_PROMPT_CHARS = 7000;++const CAP_SEEN = 60_000;++const TEXT_CALL_MS = 45_000;++const IMAGE_CALL_MS = 60_000;++++const inFlight = new Set();++const catalogCache = { at: 0, value: null };++const lastProblem = new Map();++++class PersonaError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++const fail = (status, code, message) => { throw new PersonaError(status, code, message); };++++const monthKey = (d = new Date()) => d.toISOString().slice(0, 7);++++function iso(value) {++ if (!value) return null;++ const text = String(value);++ const d = new Date(text.includes('T') ? text : `${text.replace(' ', 'T')}Z`);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++++function clean(value, max) {++ return String(value == null ? '' : value)++ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')++ .replace(/\s+/g, ' ')++ .trim()++ .slice(0, max);++}++++/** A name as a character: 1–3 Title Case words, no digits, no markdown, nothing the word filter refuses. */++function cleanName(raw) {++ const s = clean(String(raw == null ? '' : raw).replace(/^["'`*_]+|["'`*_]+$/g, ''), MAX_NAME);++ if (!s || s.length < 2 || s.length > MAX_NAME) return null;++ if (!/^[\p{L}][\p{L}'’-]*(?: [\p{L}][\p{L}'’-]*){0,2}$/u.test(s)) return null;++ if (wordFilter.check(s).safe === false) return null;++ return s;++}++++function cleanTopic(raw, fallback = 'a conversation') {++ const s = clean(String(raw == null ? '' : raw).replace(/^["'`*_]+|["'`*_]+$/g, ''), MAX_TOPIC);++ if (!s || s.length < 2) return fallback;++ if (wordFilter.check(s).safe === false) return fallback;++ return s;++}++++/** A deterministic glyph from the same registry when the model's answer is unusable. */++function fallbackGlyph(seed) {++ const digest = crypto.createHash('sha256').update(String(seed || 'vibe')).digest();++ return GLYPH_CHOICES[digest[0] % GLYPH_CHOICES.length] || 'spark';++}++++function cleanHue(raw, seed) {++ const n = Number(raw);++ if (Number.isFinite(n)) return ((Math.round(n) % 360) + 360) % 360;++ const digest = crypto.createHash('sha256').update(String(seed || 'vibe')).digest();++ return ((digest[1] << 8 | digest[2]) % 360 + 360) % 360;++}++++/** JSON from a model that may wrap it in prose or a code fence. */++function jsonOf(text) {++ const raw = String(text || '').trim();++ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);++ const body = fenced ? fenced[1] : raw;++ const start = body.indexOf('{');++ const end = body.lastIndexOf('}');++ if (start < 0 || end <= start) return null;++ try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; }++}++++/** The model's one-word answer: YES only when the first word is YES. */++function yesOf(text) {++ return /^\s*(?:yes|y)\b/i.test(String(text || ''));++}++++const clampInt = (v, min, max, fallback) => {++ const n = Number(v);++ return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback;++};++++// ── models and spend ────────────────────────────────────────────────────────++++function offerPrice(model) {++ let best = null;++ for (const offer of (model && Array.isArray(model.offers) ? model.offers : [])) {++ const p = offer && offer.price;++ if (!p) continue;++ const input = Number(p.in != null ? p.in : p.input_per_million);++ const output = Number(p.out != null ? p.out : p.output_per_million);++ if (!Number.isFinite(input) || !Number.isFinite(output)) continue;++ const score = input + output;++ if (!best || score < best.score) best = { in: input, out: output, score };++ }++ return best;++}++++const modelIdOf = (model) => String((model && (model.slug || model.id)) || '').trim();++++/** Exchange's live catalogue, cached for a minute so a page load does not scan it twice. */++async function models() {++ if (catalogCache.value && Date.now() - catalogCache.at < CAP_SEEN) return catalogCache.value;++ const out = await aiClient.catalog();++ catalogCache.at = Date.now();++ catalogCache.value = out && Array.isArray(out.models) ? out.models : [];++ return catalogCache.value;++}++++async function cheapestModel(kind) {++ let catalog = [];++ try { catalog = await models(); } catch { catalog = []; }++ let best = null;++ for (const model of catalog) {++ const modelKind = model.kind || 'chat';++ if (modelKind !== kind) continue;++ const id = modelIdOf(model);++ if (!id) continue;++ const price = offerPrice(model);++ if (!price) continue;++ if (!best || price.score < best.score || (price.score === best.score && String(model.name || '').localeCompare(String(best.name || '')) < 0)) {++ best = { id, score: price.score, name: model.name || id };++ }++ }++ if (best) return best.id;++ return kind === 'image' ? FALLBACK_IMAGE_MODEL : FALLBACK_TEXT_MODEL;++}++++async function modelFor(kind) {++ const configured = clean(settings.value(kind === 'image' ? 'image_model' : 'text_model'), 200);++ if (configured) return configured;++ return await cheapestModel(kind);++}++++async function spendThisMonth() {++ const row = await db.get('SELECT COALESCE(SUM(cost_usd), 0) AS usd FROM vibe_persona_spend WHERE month = ?', [monthKey()]);++ return Number(row && row.usd) || 0;++}++++async function capReached() {++ const cap = Number(settings.value('monthly_cap_usd'));++ if (!Number.isFinite(cap) || cap <= 0) return true;++ return (await spendThisMonth()) >= cap;++}++++async function recordSpend(roomId, kind, model, run) {++ const cost = run && run.usage && run.usage.cost_usd != null ? Math.max(0, Number(run.usage.cost_usd) || 0) : 0;++ try {++ await db.run('INSERT INTO vibe_persona_spend (room_id, month, kind, model, cost_usd, run_id) VALUES (?, ?, ?, ?, ?, ?)',++ [roomId || null, monthKey(), kind, clean(model, 200) || null, cost, clean(run && run.id, 80) || null]);++ } catch (err) { console.warn('[Vibe persona] spend:', err && err.message); }++}++++// ── prompts and calls ────────────────────────────────────────────────────────++++function transcriptOf(rows) {++ return (rows || []).map((m) => `${m.ai_member_id ? 'Vibe' : (m.display_name || m.username || 'They')}: ${String(m.message || '').replace(/\s+/g, ' ').slice(0, MAX_MESSAGE_CHARS)}`).join('\n').slice(-MAX_PROMPT_CHARS);++}++++const PERSONA_SYSTEM = [++ 'You name a private chat with Vibe, OpenVibe\'s assistant. Read the messages and answer with JSON only, no markdown, no code fence:',++ '{"name":"...","glyph":"...","hue":210,"topic":"..."}',++ '- name: 1 to 3 English words in Title Case that say what this chat is really about, as a character: "Keyboard Scout", "Stream Doctor", "Dinner Planner". It is a nickname, never a real person\'s name, never a slur, and never more than 32 characters.',++ '- glyph: exactly one name from this list (one word, lowercase): ' + GLYPH_CHOICES.join(', ') + '.',++ '- hue: an integer from 0 to 359, the colour that fits the topic.',++ '- topic: at most 12 plain words saying what the chat is about.',++ 'Do not repeat the person\'s words. Do not use a person\'s name. Answer only the JSON object.',++].join('\n');++++function personaUser(rows, previous) {++ return [++ previous ? `The chat had this persona before: ${previous.name} (${previous.topic}). Make a different name and glyph that fit the new topic.` : '',++ 'The conversation:',++ transcriptOf(rows) || '(no messages yet)',++ '',++ 'Return only the JSON object for this chat.',++ ].filter(Boolean).join('\n');++}++++const CHECK_SYSTEM = [++ 'You decide whether the topic of a chat has changed.',++ 'Answer exactly one word: YES or NO.',++ 'Answer YES only when the conversation is now mostly about something else than the current topic; a follow-up, a detail or a new question on the same subject is NO.',++].join('\n');++++function checkUser(rows, persona) {++ return [++ `Current topic: ${persona.topic}`,++ 'Recent messages:',++ transcriptOf(rows) || '(none)',++ '',++ 'Has the topic changed? Answer only YES or NO.',++ ].join('\n');++}++++async function callModel({ roomId, kind, model, messages, maxTokens, temperature, key, timeoutMs }) {++ let output = null;++ try {++ output = await aiClient.structured('ai.complete', {++ model, messages, max_tokens: maxTokens, temperature,++ }, {++ force: true,++ idempotencyKey: key,++ onRun: (run) => recordSpend(roomId, kind, model, run),++ });++ } catch (err) {++ lastProblem.set(roomId, clean(err && err.message, 200));++ return null;++ }++ if (!output) { lastProblem.set(roomId, 'ai.empty'); return null; }++ const content = output && Array.isArray(output.choices) && output.choices[0] && output.choices[0].message++ ? output.choices[0].message.content : null;++ if (typeof content !== 'string' || !content.trim()) { lastProblem.set(roomId, 'ai.empty'); return null; }++ lastProblem.delete(roomId);++ return { text: content, model, at: Date.now() };++}++++async function askForPersona(room, rows, previous, key) {++ const model = await modelFor('chat');++ return await callModel({++ roomId: room.id, kind: 'text', model, key,++ messages: [{ role: 'system', content: PERSONA_SYSTEM }, { role: 'user', content: personaUser(rows, previous) }],++ maxTokens: 240, temperature: 0.4, timeoutMs: TEXT_CALL_MS,++ });++}++++function personaFrom(answer, seed, previous) {++ if (!answer) return null;++ const j = jsonOf(answer.text);++ if (!j || typeof j !== 'object') return null;++ const name = cleanName(j.name);++ if (!name) return null;++ const topic = cleanTopic(j.topic, previous ? previous.topic : 'a conversation');++ const glyph = GLYPH_SET.has(String(j.glyph || '')) ? String(j.glyph) : null;++ const hue = cleanHue(j.hue, `${name}|${topic}`);++ return { name, topic, glyph, hue };++}++++/**++ * A picture an image provider answered with an address: https only, every hop's address checked public by++ * openvibe-shared/egress (DNS rebinding included, through its lookup), at most two redirects, 20 s and 2 MB.++ * Null for anything else.++ */++async function downloadImage(url, hops = 0) {++ const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');++ let u;++ try { u = new URL(String(url)); } catch { return null; }++ if (u.protocol !== 'https:' || u.username || u.password || hops > 2) return null;++ try { await assertPublicUrl(u, { protocols: ['https:'] }); } catch { return null; }++ const res = await new Promise((resolve) => {++ const https = require('https');++ const req = https.request(u.href, { method: 'GET', lookup: safeLookup, timeout: 20_000, headers: { accept: 'image/*' } }, (r) => {++ const chunks = []; let size = 0; let over = false;++ r.on('data', (c) => { size += c.length; if (size > 2 * 1024 * 1024) { over = true; r.destroy(); return; } chunks.push(c); });++ r.on('end', () => resolve(over ? null : { status: r.statusCode, headers: r.headers, bytes: Buffer.concat(chunks) }));++ r.on('close', () => resolve(over ? null : { status: r.statusCode, headers: r.headers, bytes: Buffer.concat(chunks) }));++ r.on('error', () => resolve(null));++ });++ req.on('timeout', () => req.destroy());++ req.on('error', () => resolve(null));++ req.end();++ });++ if (!res) return null;++ if ([301, 302, 303, 307, 308].includes(Number(res.status)) && res.headers.location) {++ let next; try { next = new URL(String(res.headers.location), u).href; } catch { return null; }++ return downloadImage(next, hops + 1);++ }++ if (res.status !== 200 || !res.bytes.length) return null;++ return { type: String(res.headers['content-type'] || '').split(';')[0].trim().toLowerCase(), bytes: res.bytes };++}++++/** The avatar: one small picture from the image model, uploaded to Media as Chat. Never fatal. */++async function avatarFor(room, persona, key) {++ if (!settings.value('images')) return null;++ if (await capReached()) return null;++ const configured = clean(settings.value('image_model'), 200);++ const model = configured || await modelFor('image');++ const prompt = `A small square avatar picture for a chat called "${persona.name}" about "${persona.topic}". One clear subject, flat vector style, bold simple shapes, centred, no text, no letters, no logos, readable at 48 pixels.`;++ const call = (withModel, suffix) => aiClient.structured('chat.persona.image', {++ ...(withModel ? { model } : {}),++ prompt, size: '256x256', n: 1,++ }, {++ force: true,++ idempotencyKey: `${key}-image${suffix}`,++ onRun: (run) => recordSpend(room.id, 'image', model, run),++ });++ let out = null;++ try {++ out = await call(true, '');++ // Auto names the cheapest image model; an Exchange without that exact model falls back to the++ // image route's own configured model instead of losing the avatar. A model the owner chose is theirs.++ if (!out && !configured) out = await call(false, '-default');++ } catch (err) {++ console.warn('[Vibe persona] image:', err && err.message);++ return null;++ }++ const first = out && Array.isArray(out.images) ? out.images[0] : null;++ if (!first) return null;++ let buffer = null;++ let mimeType = 'image/png';++ try {++ if (typeof first.b64 === 'string' && first.b64.length > 32) {++ buffer = Buffer.from(first.b64.replace(/^data:image\/[a-z0-9.+-]+;base64,/i, ''), 'base64');++ const m = /^data:(image\/[a-z0-9.+-]+);base64,/i.exec(first.b64);++ if (m) mimeType = m[1].toLowerCase();++ } else if (typeof first.url === 'string' && first.url) {++ if (/^data:image\//i.test(first.url)) {++ const m = /^data:(image\/[a-z0-9.+-]+);base64,(.*)$/is.exec(first.url);++ if (m) { mimeType = m[1].toLowerCase(); buffer = Buffer.from(m[2], 'base64'); }++ } else if (/^https:\/\//i.test(first.url)) {++ // The provider's picture address is not ours to trust: the egress guard checks every hop is public.++ const got = await downloadImage(first.url);++ if (!got) return null;++ if (/^image\//.test(got.type)) mimeType = got.type;++ buffer = got.bytes;++ }++ }++ if (!buffer || !buffer.length || buffer.length > 2 * 1024 * 1024) return null;++ const uploaded = await media.uploadObject(buffer, { filename: `vibe-persona-${room.slug}.png`, mimeType });++ return uploaded && uploaded.url ? String(uploaded.url).slice(0, 1000) : null;++ } catch (err) {++ console.warn('[Vibe persona] avatar:', err && err.message);++ return null;++ }++}++++// ── storage ──────────────────────────────────────────────────────────────────++++const PERSONA_COLUMNS = 'room_id, name, glyph, hue, image_url, topic, made_at, checked_at, checked_count, generation';++++function wire(row) {++ if (!row) return null;++ return {++ name: String(row.name || ''),++ glyph: GLYPH_SET.has(String(row.glyph || '')) ? String(row.glyph) : fallbackGlyph(row.name || row.room_id),++ hue: cleanHue(row.hue, row.name || row.room_id),++ image_url: row.image_url ? String(row.image_url) : null,++ topic: String(row.topic || ''),++ made_at: iso(row.made_at),++ };++}++++async function forRoom(roomId) {++ return await db.get(`SELECT ${PERSONA_COLUMNS} FROM vibe_personas WHERE room_id = ?`, [Number(roomId)]) || null;++}++++async function forRooms(roomIds) {++ const ids = [...new Set((roomIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0))];++ const out = new Map();++ if (!ids.length) return out;++ const rows = await db.all(`SELECT ${PERSONA_COLUMNS} FROM vibe_personas WHERE room_id IN (${ids.map(() => '?').join(',')})`, ids);++ for (const row of rows) out.set(Number(row.room_id), row);++ return out;++}++++const countMessages = async (roomId) => Number((await db.get('SELECT COUNT(*) AS n FROM room_messages WHERE room_id = ? AND is_deleted = 0', [roomId]))?.n) || 0;++const hasAnswer = async (roomId) => !!(await db.get('SELECT 1 AS yes FROM room_messages WHERE room_id = ? AND ai_member_id IS NOT NULL AND is_deleted = 0 LIMIT 1', [roomId]));++++async function recentRows(roomId, { limit = HISTORY_FOR_CHECK, first = false } = {}) {++ const rows = await db.all(`SELECT m.id, m.message, m.ai_member_id, m.created_at, u.display_name, u.username++ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id++ WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id ${first ? 'ASC' : 'DESC'} LIMIT ?`, [roomId, limit]);++ return first ? rows : rows.reverse();++}++++async function firstMessage(roomId) {++ return await db.get('SELECT message FROM room_messages WHERE room_id = ? AND ai_member_id IS NULL AND is_deleted = 0 ORDER BY id LIMIT 1', [roomId]);++}++++async function touch(roomId, count) {++ await db.run('UPDATE vibe_personas SET checked_count = ?, checked_at = ov_now() WHERE room_id = ?', [Math.max(0, Number(count) || 0), roomId]);++}++++/** The conversation's name follows the persona until the person renames it (rooms.vibe_title_locked). */++async function titleFromPersona(roomId, name, previousName = null) {++ const room = await db.get('SELECT id, name, vibe_title_locked FROM rooms WHERE id = ?', [roomId]);++ if (!room || Number(room.vibe_title_locked)) return;++ const first = await firstMessage(roomId);++ let vibe = null;++ try { vibe = require('../rooms/vibe'); } catch { vibe = null; }++ const auto = first && vibe ? vibe.titleOf(first.message) : (vibe ? vibe.DEFAULT_TITLE : null);++ const current = String(room.name || '').trim();++ // Ours to write: the auto title, the default, or the last persona's own name. Anything else is a rename++ // from before this feature (no lock flag existed); a rename now sets the flag and returns above.++ const ours = !current || current === auto || current === (vibe && vibe.DEFAULT_TITLE) || (previousName && current === previousName);++ if (!ours) return;++ await db.run('UPDATE rooms SET name = ? WHERE id = ? AND COALESCE(vibe_title_locked, 0) = 0', [name, roomId]);++}++++async function savePersona(room, persona, { previous, imageUrl, count }) {++ const madeAt = new Date().toISOString();++ const generation = previous ? Number(previous.generation || 1) + 1 : 1;++ await db.run(`INSERT INTO vibe_personas (room_id, name, glyph, hue, image_url, topic, made_at, checked_at, checked_count, generation)++ VALUES (?, ?, ?, ?, ?, ?, ?, ov_now(), ?, ?)++ ON CONFLICT (room_id) DO UPDATE SET name = excluded.name, glyph = excluded.glyph, hue = excluded.hue,++ image_url = excluded.image_url, topic = excluded.topic, made_at = excluded.made_at,++ checked_at = excluded.checked_at, checked_count = excluded.checked_count, generation = excluded.generation`,++ [room.id, persona.name, persona.glyph, persona.hue, imageUrl, persona.topic, madeAt, Math.max(0, Number(count) || 0), generation]);++ await titleFromPersona(room.id, persona.name, previous && previous.name);++ return await forRoom(room.id);++}++++// ── generation ───────────────────────────────────────────────────────────────++++/**++ * Make a persona, or remake one. `previous` is the row being replaced; `count` is the message count this++ * generation read. Every call is idempotent under `key`; an invalid glyph or name is retried once and then++ * replaced from the registry, so a persona is never stored with an unknown icon.++ */++async function generate(room, { previous = null, mode = 'first', count = 0, key = null } = {}) {++ if (!settings.available() || !settings.value('enabled')) return null;++ if (await capReached()) return null;++ const rows = mode === 'first'++ ? await recentRows(room.id, { limit: HISTORY_FOR_FIRST, first: true })++ : await recentRows(room.id, { limit: HISTORY_FOR_CHECK });++ if (!rows.length) return null;++ const baseKey = key || `chat-persona-${room.id}-${mode}-${previous ? `${previous.generation}-${count}` : count || 'first'}`;++ let answer = await askForPersona(room, rows, previous, baseKey);++ let parsed = personaFrom(answer, `${room.id}|${count}`, previous);++ if (!parsed || !parsed.glyph) {++ // One retry with the failure named: a model can answer "None" for a glyph, or prose instead of JSON.++ if (!(await capReached())) {++ const retry = await callModel({++ roomId: room.id, kind: 'text', model: await modelFor('chat'), key: `${baseKey}-retry`,++ messages: [{ role: 'system', content: PERSONA_SYSTEM }, { role: 'user', content: `${personaUser(rows, previous)}\n\nYour previous answer was not usable (${!parsed ? 'not the JSON object' : `glyph "${parsed.glyph || ''}" is not in the list`}). Answer again with the JSON object only and a glyph from the list.` }],++ maxTokens: 240, temperature: 0.2, timeoutMs: TEXT_CALL_MS,++ });++ parsed = personaFrom(retry, `${room.id}|${count}|retry`, previous) || parsed;++ }++ }++ if (!parsed) return null;++ const persona = {++ name: parsed.name,++ glyph: parsed.glyph || fallbackGlyph(`${parsed.name}|${parsed.topic}`),++ hue: parsed.hue,++ topic: parsed.topic,++ };++ let imageUrl = null;++ if (settings.value('images')) imageUrl = await avatarFor(room, persona, baseKey);++ // A remake with no new picture keeps the old one rather than dropping a face the chat already had.++ if (!imageUrl && previous && previous.image_url) imageUrl = previous.image_url;++ const finalCount = await countMessages(room.id);++ const row = await savePersona(room, persona, { previous, imageUrl, count: finalCount });++ return wire(row);++}++++async function topicChanged(room, persona, count) {++ if (await capReached()) return null;++ const rows = await recentRows(room.id, { limit: HISTORY_FOR_CHECK });++ const model = await modelFor('chat');++ const answer = await callModel({++ roomId: room.id, kind: 'text', model, key: `chat-persona-${room.id}-check-${count}`,++ messages: [{ role: 'system', content: CHECK_SYSTEM }, { role: 'user', content: checkUser(rows, persona) }],++ maxTokens: 4, temperature: 0, timeoutMs: TEXT_CALL_MS,++ });++ if (!answer) return null;++ return yesOf(answer.text);++}++++/**++ * The engine's due check. Never called from a request path directly: afterPost() schedules it, and the admin++ * remake awaits its own call. One per room at a time; every model call is idempotent under the room and the++ * generation it reads.++ */++async function maybe(roomLike) {++ if (!settings.available() || !settings.value('enabled')) return null;++ const id = Number(roomLike && roomLike.id);++ if (!Number.isFinite(id) || id <= 0) return null;++ const room = await db.get("SELECT * FROM rooms WHERE id = ? AND assistant = 1 AND archived_at IS NULL", [id]);++ if (!room || inFlight.has(id)) return null;++ // Claim the room before the first await: two answers arriving together must make one persona, not two.++ inFlight.add(id);++ try {++ const persona = await forRoom(id);++ const count = await countMessages(id);++ if (!persona && !(await hasAnswer(id))) return null;++ if (persona) {++ const every = clampInt(settings.value('check_every'), 2, 100, 8);++ if (count - Number(persona.checked_count || 0) < every) return null;++ }++ if (await capReached()) return null;++ if (!persona) return await generate(room, { mode: 'first', count });++ const changed = await topicChanged(room, persona, count);++ if (changed === true) {++ try { return await generate(room, { previous: persona, mode: 'change', count }); }++ finally { await touch(id, await countMessages(id)); }++ }++ // NO, or a check that could not answer: wait another interval instead of asking on every message.++ await touch(id, count);++ return null;++ } finally { inFlight.delete(id); }++}++++/** Called after Vibe answered: schedules the due check and returns at once, never blocking the reply. */++function afterAnswer(room) {++ const id = Number(room && room.id);++ if (!Number.isFinite(id) || id <= 0) return;++ setImmediate(() => {++ maybe({ id }).catch((err) => console.warn(`[Vibe persona] room ${id}: ${err && err.message}`));++ });++}++++/** The admin's "remake one conversation": awaited, cap-respecting, one at a time. */++async function remake(slug, ownerId) {++ if (!settings.available()) fail(503, 'persona.unavailable', 'Persona settings are not available right now');++ if (!settings.value('enabled')) fail(409, 'persona.disabled', 'Vibe personas are off; turn them on at /admin/persona first');++ // Only the owner's own conversations: other people's Vibe chats are private (a persona's topic summarises them).++ const room = await db.get('SELECT * FROM rooms WHERE slug = ? AND owner_id = ? AND assistant = 1 AND archived_at IS NULL', [String(slug || ''), Number(ownerId) || 0]);++ if (!room) fail(404, 'persona.not_found', 'No such Vibe conversation');++ if (inFlight.has(room.id)) fail(409, 'persona.busy', 'That conversation is already making a persona; try again in a moment');++ if (await capReached()) fail(409, 'persona.cap', 'The monthly persona spend cap is reached; raise it on /admin/persona or wait for next month');++ const count = await countMessages(room.id);++ if (!(await hasAnswer(room.id))) fail(409, 'persona.no_answer', 'This conversation has no answer to name yet');++ const previous = await forRoom(room.id);++ inFlight.add(room.id);++ try {++ const made = await generate(room, { previous, mode: previous ? 'remake' : 'first', count, key: `chat-persona-${room.id}-remake-${Date.now()}` });++ if (!made) fail(502, 'persona.failed', 'The model did not answer with a usable persona; try again');++ return made;++ } finally { inFlight.delete(room.id); }++}++++// ── admin ────────────────────────────────────────────────────────────────────++++async function conversations({ limit = 60, ownerId = 0 } = {}) {++ const rows = await db.all(`SELECT r.id, r.slug, r.name, r.last_message_at, r.created_at,++ p.name AS persona_name, p.glyph, p.hue, p.image_url, p.topic, p.made_at,++ COALESCE(u.display_name, u.username, 'person ' || r.owner_id) AS owner,++ (SELECT COUNT(*) FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0) AS messages++ FROM rooms r++ LEFT JOIN vibe_personas p ON p.room_id = r.id++ LEFT JOIN ctx_users u ON u.id = r.owner_id++ WHERE r.assistant = 1 AND r.archived_at IS NULL AND r.owner_id = ?++ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC, r.id DESC LIMIT ?`, [Number(ownerId) || 0, Math.min(200, Math.max(1, Number(limit) || 60))]);++ return rows.map((r) => ({++ slug: r.slug, name: r.name, owner: r.owner, messages: Number(r.messages) || 0,++ last_message_at: iso(r.last_message_at || r.created_at),++ persona: r.persona_name ? wire({ name: r.persona_name, glyph: r.glyph, hue: r.hue, image_url: r.image_url, topic: r.topic, made_at: r.made_at }) : null,++ }));++}++++function modelOptionsOf(list, kind) {++ const out = [];++ for (const model of list) {++ if ((model.kind || 'chat') !== kind) continue;++ const id = modelIdOf(model);++ if (!id) continue;++ const price = offerPrice(model);++ out.push({ id, name: String(model.name || id), price });++ }++ out.sort((a, b) => {++ const pa = a.price ? a.price.score : Number.POSITIVE_INFINITY;++ const pb = b.price ? b.price.score : Number.POSITIVE_INFINITY;++ return pa - pb || a.name.localeCompare(b.name);++ });++ const capped = out.slice(0, 60);++ const configured = clean(settings.value(kind === 'image' ? 'image_model' : 'text_model'), 200);++ if (configured && !capped.some((m) => m.id === configured)) {++ const found = out.find((m) => m.id === configured);++ if (found) capped.push(found);++ }++ return capped;++}++++/** The settings, the month's spend (everyone's, no content) and the owner's OWN conversations. */++async function state(ownerId = 0) {++ const all = settings.all();++ const month = monthKey();++ const spent = await spendThisMonth();++ const cap = Number(all.monthly_cap_usd);++ let catalog = [];++ try { catalog = await models(); } catch { catalog = []; }++ return {++ settings: all,++ defaults: settings.DEFAULTS,++ revision: settings.current() && settings.current().revision ? settings.current().revision() : null,++ month,++ spend: {++ month, usd: Math.round(spent * 1e6) / 1e6, cap_usd: cap,++ remaining_usd: Math.max(0, Math.round((cap - spent) * 1e6) / 1e6),++ reached: !(cap > 0) || spent >= cap,++ },++ models: { text: modelOptionsOf(catalog, 'chat'), image: modelOptionsOf(catalog, 'image') },++ conversations: await conversations({ limit: 60, ownerId }),++ last_problem: Object.fromEntries(lastProblem),++ };++}++++const MODEL_RE = /^[a-z0-9][a-z0-9._/@:-]{1,255}$/i;++++async function update(input, { actor = null, reason = 'Vibe persona settings', ownerId = 0 } = {}) {++ if (!settings.available()) fail(503, 'persona.unavailable', 'Persona settings are not available right now');++ const b = input && typeof input === 'object' ? input : {};++ const changes = {};++ if (b.enabled !== undefined) {++ if (typeof b.enabled !== 'boolean' && b.enabled !== 'true' && b.enabled !== 'false' && b.enabled !== 1 && b.enabled !== 0) fail(422, 'persona.enabled', 'enabled is true or false');++ changes.enabled = b.enabled === true || b.enabled === 'true' || b.enabled === 1;++ }++ for (const [key, code] of [['text_model', 'persona.text_model'], ['image_model', 'persona.image_model']]) {++ if (b[key] === undefined) continue;++ const value = clean(b[key], 200);++ if (value && !MODEL_RE.test(value)) fail(422, code, 'A model id is letters, digits, dots, dashes, underscores, slashes or colons');++ changes[key] = value;++ }++ if (b.images !== undefined) {++ if (typeof b.images !== 'boolean' && b.images !== 'true' && b.images !== 'false' && b.images !== 1 && b.images !== 0) fail(422, 'persona.images', 'images is true or false');++ changes.images = b.images === true || b.images === 'true' || b.images === 1;++ }++ if (b.check_every !== undefined) {++ const n = Number(b.check_every);++ if (!Number.isFinite(n) || n < 2 || n > 100) fail(422, 'persona.check_every', 'Messages between topic checks is from 2 to 100');++ changes.check_every = Math.round(n);++ }++ if (b.monthly_cap_usd !== undefined) {++ const n = Number(b.monthly_cap_usd);++ if (!Number.isFinite(n) || n < 0 || n > 1000) fail(422, 'persona.cap', 'The monthly cap is from 0 to 1000 US dollars');++ changes.monthly_cap_usd = Math.round(n * 1e6) / 1e6;++ }++ if (!Object.keys(changes).length) return await state(ownerId);++ try {++ await settings.current().apply(changes, { merge: true, actor, reason: String(reason).slice(0, 300) });++ } catch (err) {++ if (err && err.status) fail(err.status, err.code || 'persona.settings', err.message);++ throw err;++ }++ return await state(ownerId);++}++++/** For tests: forget in-process locks and the catalogue cache. Never forgets the owner's stored settings. */++function _reset() {++ inFlight.clear();++ catalogCache.at = 0;++ catalogCache.value = null;++ lastProblem.clear();++}++++module.exports = {++ PersonaError, GLYPH_NAMES, GLYPH_CHOICES, MAX_NAME, MAX_TOPIC,++ wire, forRoom, forRooms, afterAnswer, maybe, remake, generate, state, update, conversations,++ spendThisMonth, capReached, cheapestModel, modelFor, _reset,++ // Read by tests that want to prove the prompts and validators without a network.++ cleanName, cleanTopic, cleanHue, jsonOf, yesOf, iso,++};nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * Owner settings for Vibe personas (the owner, 2026-11-12: each Vibe chat gets a name and a face made from++ * what it is about, remade when the topic changes, on "the cheapest model possible, but configurable on the++ * admin panel"). They live in the same revisioned configuration as Chat's other owner settings++ * (openvibe-shared/config, namespace chat.persona): the admin page at /admin/persona reads and writes them,++ * and Network's owner settings proxy can read the same namespace.++ *++ * No value here is a secret. The store keeps every revision with its actor, so a change to what Chat may++ * spend on personas is auditable and can be rolled back like the voice keys.++ *++ * enabled personas are made at all (default on)++ * text_model the catalogue model for names and topic checks; '' = Auto, the cheapest chat model++ * images make an avatar picture as well (default off)++ * image_model the catalogue model for the avatar; '' = Auto, the cheapest image model++ * check_every messages between topic checks (2–100, default 8)++ * monthly_cap_usd most Chat may spend on personas in a UTC month; 0 stops persona spending (default 5)++ */++const { createConfigStore } = require('openvibe-shared/config');++++const SERVICE = 'chat';++const NAMESPACE = 'chat.persona';++++const DEFAULTS = Object.freeze({++ enabled: true,++ text_model: '',++ images: false,++ image_model: '',++ check_every: 8,++ monthly_cap_usd: 5,++});++++const SCHEMA = Object.freeze({++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Vibe personas',++ description: 'The names and faces of Vibe conversations, the models that make them and what they may cost.',++ type: 'object',++ additionalProperties: false,++ properties: {++ enabled: {++ type: 'boolean', title: 'Names and faces for Vibe chats', default: true,++ description: 'Make a name, a glyph and a colour for each Vibe conversation from what it is about; check the topic again every few messages.',++ },++ text_model: {++ type: 'string', title: 'Text model', default: '', maxLength: 200,++ description: 'The Exchange model that names a chat and decides whether its topic changed. Empty means Auto: the cheapest chat model in the catalogue, which is the default and what Chat picks when it is blank.',++ },++ images: {++ type: 'boolean', title: 'Avatar pictures', default: false,++ description: 'Also make a small avatar picture with an image model and store it in OpenVibe.Media. Off by default: it costs more than the name.',++ },++ image_model: {++ type: 'string', title: 'Image model', default: '', maxLength: 200,++ description: 'The Exchange model that draws the avatar. Empty means Auto: the cheapest image model in the catalogue.',++ },++ check_every: {++ type: 'integer', title: 'Messages between topic checks', default: 8, minimum: 2, maximum: 100,++ description: 'How many messages may arrive before Chat asks the cheap model whether the chat has moved to a new topic (2 to 100).',++ },++ monthly_cap_usd: {++ type: 'number', title: 'Monthly spend cap (USD)', default: 5, minimum: 0, maximum: 1000,++ description: 'The most Chat may spend on personas in a UTC month, read from the runs it made. 0 stops persona spending; the page shows what has been spent this month.',++ },++ },++});++++let active = null;++++function read(store, key) {++ const fallback = DEFAULTS[key];++ let value;++ try { value = store.get(key); } catch { value = undefined; }++ if (value === undefined || value === null) return fallback;++ if (typeof fallback === 'boolean') return value === true || value === 'true' || value === 1 || value === '1';++ if (typeof fallback === 'number') { const n = Number(value); return Number.isFinite(n) ? n : fallback; }++ return String(value);++}++++/**++ * Create the store and make it the one the persona engine reads. Resolves once the active revision is++ * loaded; the caller (server/index.js) awaits it before the app mounts /internal/config.++ */++async function createPersonaSettings({ db, log = console } = {}) {++ const store = await createConfigStore({ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, log });++ active = store;++ return {++ service: SERVICE,++ namespace: NAMESPACE,++ schema: SCHEMA,++ defaults: DEFAULTS,++ store,++ /** The owner's value, or the default when it is unset or cannot be read. */++ value: (key) => read(store, key),++ get: () => ({ ...DEFAULTS, ...Object.fromEntries(Object.keys(DEFAULTS).map((key) => [key, read(store, key)])) }),++ /** The store's revision, for the page's "saved" line. */++ revision: () => (store.revision ? store.revision() : null),++ };++}++++/** Installed by createPersonaSettings; null before it has loaded (the engine makes no personas then). */++function current() { return active; }++++/** Whether the owner's settings are available at all. No store means no spending. */++function available() { return !!active; }++++/** One setting, with the default when the store has not loaded. */++function value(key) { return active ? read(active, key) : DEFAULTS[key]; }++++/** Every setting, with defaults. */++function all() { return active ? Object.fromEntries(Object.keys(DEFAULTS).map((key) => [key, read(active, key)])) : { ...DEFAULTS }; }++++/** For tests: forget the installed store. */++function _reset() { active = null; }++++module.exports = {++ SERVICE, NAMESPACE, DEFAULTS, SCHEMA,++ createPersonaSettings, current, available, value, all, _reset,++};@@@ -527,7 +527,10 @@ async function afterPost(room, message, user, deps = {}) {if (vibe && !own) await require('./vibe').countReply(user);// "How Vibe did this" says which model answered when it was the person's own (their key or their computer).if (vibe && own && steps && member.engine_label) steps.steps.unshift(`Answered with ${String(member.engine_label).slice(0, 160)}`);-- return await say(text, steps && steps.steps.length ? steps : null);++ const posted = await say(text, steps && steps.steps.length ? steps : null);++ // The persona is due-based and never blocks the answer: afterPost schedules it and returns.++ if (vibe) { try { require('./persona').afterAnswer(room); } catch { /* a persona never breaks an answer */ } }++ return posted;} catch (err) {console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);return null;nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++/**++ * Owner-only Vibe persona settings (the owner, 2026-11-12): GET|POST /api/admin/persona and++ * POST /api/admin/persona/remake. The same store and actions as the page at /admin/persona++ * (server/web/persona-admin.js), so a script or another agent does exactly what the owner does.++ *++ * GET /api/admin/persona { settings, defaults, spend, models, conversations, month, revision }++ * POST /api/admin/persona { enabled?, text_model?, images?, image_model?, check_every?, monthly_cap_usd? }++ * POST /api/admin/persona/remake { slug } → { persona }, 409 persona.busy / persona.cap / persona.no_answer++ *++ * Every route is for the owner's own sign-in (not an API token), and every route is owner-only: the same++ * isOwner() rule the rest of Chat uses (an admin with Network's owner flag).++ */++const express = require('express');++const { requireAuth } = require('../auth/auth');++const { isOwner } = require('../auth/permissions');++const { limits } = require('../net/actor-limits');++const persona = require('../persona');++++const router = express.Router();++const manageLimit = limits('chat.persona.manage', { minute: 30, hour: 300 });++const readLimit = limits('chat.persona.read', { minute: 120, hour: 1200 });++++router.use(requireAuth);++router.use((req, res, next) => {++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Vibe persona settings are for the owner\'s own sign-in', code: 'persona.person_only' });++ if (!isOwner(req.user)) return res.status(403).json({ error: 'Only the owner changes the Vibe personas', code: 'persona.owner' });++ next();++});++++function send(res, err) {++ if (err && err.status && err.code) return res.status(err.status).json({ error: err.message, code: err.code });++ console.error('[Vibe persona admin]', err && err.message);++ return res.status(500).json({ error: 'Something went wrong', code: 'persona.error' });++}++++router.get('/', readLimit, async (_req, res) => {++ try { res.set('Cache-Control', 'private, no-store').json(await persona.state(req.user.id)); } catch (err) { send(res, err); }++});++++router.post('/', manageLimit, async (req, res) => {++ try {++ const actor = req.user.subject_id ? { type: 'user', id: String(req.user.subject_id) } : null;++ res.set('Cache-Control', 'private, no-store').json(await persona.update(req.body || {}, { actor, reason: 'Vibe persona settings', ownerId: req.user.id }));++ } catch (err) { send(res, err); }++});++++router.post('/remake', manageLimit, async (req, res) => {++ try {++ const slug = String((req.body && req.body.slug) || '');++ const made = await persona.remake(slug, req.user.id);++ res.set('Cache-Control', 'private, no-store').json({ persona: made, conversations: (await persona.state(req.user.id)).conversations });++ } catch (err) { send(res, err); }++});++++module.exports = router;@@@ -9,6 +9,10 @@* { chat: null } before the first. after = a message id: only what came after it* (the Dock polls this for Vibe's answer). A Vibe message carries what Vibe really* did (results: the reminders and timers it set or stopped), absent when none.++ * chat.persona is the chat's persona (server/persona/index.js), or null until it++ * is made: { name, glyph, hue, image_url|null, topic, made_at }.++ * GET /api/vibe/conversations { conversations: [{ slug, name, last_message_at, created_at, persona, … }], count }:++ * every item carries the same persona shape, so the Vibe panel and the Dock draw one.* POST /api/vibe/undo { message_id, index }* undo one of those results: the reminder is stopped, the timer too, and the* result is marked undone. The person themself, in their own chat, within a day.@@@ -27,6 +31,7 @@ const { requireAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const rooms = require('./rooms');const vibe = require('./vibe');++const persona = require('../persona');const vibeResults = require('./vibe-results');const aiClient = require('../ai/client');const compare = require('./vibe-compare');@@@ -87,7 +92,7 @@ router.get('/chat', personOnly, async (req, res) => {const after = Number(req.query.after) || null;const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });const by = await compare.answers(room, rows.filter((m) => !m.ai_member_id).map((m) => Number(m.id)));-- return res.set('Cache-Control', 'private, no-store').json({ chat: { ...chatOf(room), ...(await compare.settings(room)) },++ return res.set('Cache-Control', 'private, no-store').json({ chat: { ...chatOf(room), persona: persona.wire(await persona.forRoom(room.id)), ...(await compare.settings(room)) },messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });});@@@ -36,6 +36,7 @@ const config = require('../config');const wordFilter = require('../chat/word-filter');const rooms = require('./rooms');const credentials = require('../ai/credentials');++const persona = require('../persona');const VIBE = { name: 'Vibe', handle: 'vibe' };const MAX_CHATS = 500;@@@ -162,7 +163,10 @@ async function work(room, user, text, mode) {return { message, task: null };}// A task Actor refused at once was already answered in the chat (workspace.announce) with Actor's reason.-- if (task.status !== 'failed' && !task.actor_task) await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);++ if (task.status !== 'failed' && !task.actor_task) {++ const said = await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);++ if (said) { try { persona.afterAnswer(room); } catch { /* a persona never breaks the answer */ } }++ }return { message, task };}@@@ -183,14 +187,16 @@ async function startWork(user, text, mode) {async function list(user, { limit = 100 } = {}) {if (!user) return [];const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));-- const rows = await db.all(`SELECT r.slug, r.name, r.last_message_at, r.created_at, r.vibe_origin_site,++ const rows = await db.all(`SELECT r.id, r.slug, r.name, r.last_message_at, r.created_at, r.vibe_origin_site,(SELECT m.message FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT 1) AS last_line,(SELECT m.created_at FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.ai_member_id IS NOT NULL ORDER BY m.id DESC LIMIT 1) AS last_answer_atFROM rooms rWHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL ORDER BY COALESCE(r.last_message_at, r.created_at) DESC LIMIT ?`, [user.id, lim]);++ const personas = await persona.forRooms(rows.map((r) => r.id));return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at,last_line: r.last_line ? String(r.last_line).replace(/\s+/g, ' ').slice(0, 160) : null,-- last_answer_at: r.last_answer_at || null, site: r.vibe_origin_site || null }));++ last_answer_at: r.last_answer_at || null, site: r.vibe_origin_site || null,++ persona: persona.wire(personas.get(Number(r.id))) }));}async function count(user) {@@@ -239,7 +245,8 @@ async function rename(room, user, name) {if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');const n = String(name || '').replace(/\s+/g, ' ').trim();if (n.length < 1 || n.length > TITLE_MAX) fail(422, 'vibe.name', `A chat's name is 1 to ${TITLE_MAX} characters`);-- await db.run('UPDATE rooms SET name = ? WHERE id = ?', [n, room.id]);++ // Their name stays theirs: a persona made later never writes over it (server/persona/index.js).++ await db.run('UPDATE rooms SET name = ?, vibe_title_locked = 1 WHERE id = ?', [n, room.id]);return await rooms.publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]), { role: 'owner' });}@@@ -1533,6 +1533,7 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr// ── What shipped, robots, sitemap ──// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });++ require('./persona-admin').mountPersonaAdmin(router, { page, needUser, sameSite, formLimit, notice });require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });require('./room-embed-pages').mountRoomEmbedPages(router, { viewerOf, sameSite, config });require('./developer-pages').mountDeveloperPages(router, { page, config });nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++/**++ * /admin/persona — the owner's page for Vibe personas (the owner, 2026-11-12: "the cheapest model possible, but++ * configurable on the admin panel … make it actually intelligent"). It is Chat's own owner panel because the++ * actions are Chat's (which model, when to re-check, how much a month, remake one chat), not a service key;++ * Network's owner settings proxy still reaches the same `chat.persona` namespace through /internal/config.++ *++ * GET /admin/persona the settings, what this month has cost, the model choices and the chats++ * POST /admin/persona save the settings (form)++ * POST /admin/persona/remake { slug } remake one conversation's persona (form)++ *++ * Owner only: an admin with Network's owner flag. Every model call is Chat's own and billed to Chat; the cap is++ * read from the runs' usage, not counted by attempts.++ */++const persona = require('../persona');++const { isOwner } = require('../auth/permissions');++const { esc } = require('./layout');++const ui = require('./ui');++++const usd = (n) => {++ const v = Number(n) || 0;++ const s = v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '');++ return `$${s || '0'}`;++};++const priceLabel = (p) => (p ? `$${Number(p.in).toFixed(2)} in / $${Number(p.out).toFixed(2)} out per M` : 'price unknown');++const glyph = (p) => (p ? `${esc(p.glyph)} · hue ${Number(p.hue)}` : 'not made yet');++++function modelSelect(name, current, options, autoLabel) {++ const known = options.slice();++ if (current && !known.some((m) => m.id === current)) known.unshift({ id: current, name: current, price: null });++ return `<label class="oc-set-row"><span class="oc-set-text">${name}<small>${esc(autoLabel)}</small></span>++<select name="${name === 'Text model' ? 'text_model' : 'image_model'}">++<option value=""${current ? '' : ' selected'}>Auto — the cheapest in the catalogue</option>++${known.map((m) => `<option value="${esc(m.id)}"${m.id === current ? ' selected' : ''}>${esc(m.name)} · ${esc(priceLabel(m.price))}</option>`).join('')}++</select></label>`;++}++++function conversationRows(rows) {++ if (!rows.length) return '<p class="oc-muted">No Vibe conversations yet.</p>';++ return `<ul class="oc-list">${rows.map((r) => `<li class="oc-row">++<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.persona ? r.persona.name : r.name || 'Chat with Vibe')}</strong> <span class="oc-muted">${esc(r.slug)}</span></span>++<span class="oc-row-sub">${r.persona ? `${glyph(r.persona)} · ${esc(r.persona.topic)}` : 'no persona yet'} · ${Number(r.messages)} messages</span></span>++<form class="oc-inline" method="post" action="/admin/persona/remake"><input type="hidden" name="slug" value="${esc(r.slug)}"><button class="oc-link" type="submit">Remake</button></form>++</li>`).join('')}</ul>`;++}++++function pageBody(state, message) {++ const s = state.settings;++ const spend = state.spend;++ const capLine = spend.reached++ ? `<p class="oc-notice oc-error" role="status">The monthly cap is reached (${usd(spend.usd)} of ${usd(spend.cap_usd)}). Existing personas stay; new ones wait for next month or a higher cap.</p>`++ : `<p class="oc-muted">Spent <strong>${usd(spend.usd)}</strong> of <strong>${usd(spend.cap_usd)}</strong> this month (${esc(spend.month)} UTC), ${usd(spend.remaining_usd)} left. A persona is named by the cheapest chat model and the cap is read from each run's own usage.</p>`;++ const revision = state.revision ? ` · revision ${Number(state.revision)}` : '';++ return `${message}${ui.head({ title: 'Vibe personas', sub: 'Names and faces for Vibe chats, made from what each chat is about' })}++<section class="oc-set" aria-labelledby="persona-spend"><h2 id="persona-spend">This month</h2>${capLine}${revision ? `<p class="oc-muted">Owner settings${esc(revision)}.</p>` : ''}</section>++<section class="oc-set" aria-labelledby="persona-settings"><h2 id="persona-settings">Settings</h2>++<form class="oc-form" method="post" action="/admin/persona">++<label class="oc-set-row"><span class="oc-set-text">Names and faces for Vibe chats<small>Off: no model is called and no persona is made or checked.</small></span><input type="checkbox" class="oc-switch" name="enabled" value="1"${s.enabled ? ' checked' : ''}></label>++${modelSelect('Text model', s.text_model, state.models.text, 'The cheapest chat model is used automatically when this is Auto.')}++<label class="oc-set-row"><span class="oc-set-text">Avatar pictures<small>Off by default: an image costs more than the name. Pictures go to OpenVibe.Media.</small></span><input type="checkbox" class="oc-switch" name="images" value="1"${s.images ? ' checked' : ''}></label>++${modelSelect('Image model', s.image_model, state.models.image, 'The cheapest image model is used automatically when this is Auto and pictures are on.')}++<label>Messages between topic checks <input type="number" name="check_every" min="2" max="100" step="1" value="${esc(s.check_every)}" required></label>++<label>Monthly spend cap (USD) <input type="number" name="monthly_cap_usd" min="0" max="1000" step="0.01" value="${esc(s.monthly_cap_usd)}" required></label>++<p class="oc-muted">The name is also the conversation's title until the person renames it. A chat is checked after its first answer and then every ${esc(s.check_every)} messages; a changed topic makes a new name and face.</p>++<p><button class="oc-button" type="submit">Save</button></p>++</form></section>++<section class="oc-set" aria-labelledby="persona-chats"><h2 id="persona-chats">Conversations</h2>++<p class="oc-muted">Your newest ${state.conversations.length} Vibe conversations (other people's are private: only the spend above counts them). Remake asks the model now, counts against the cap, and keeps the person's own title if they renamed the chat.</p>++${conversationRows(state.conversations)}</section>`;++}++++function mountPersonaAdmin(router, { page, needUser, sameSite, formLimit, notice }) {++ async function owner(req, res) {++ const actor = await needUser(req, res);++ if (!actor) return null;++ if (!isOwner(actor.user)) {++ await page(req, res, 403, {++ actor, title: 'Vibe personas', path: '/admin/persona', robots: 'noindex, nofollow', footer: 'compact',++ body: `<h1>Only the owner</h1><p>This page changes what Chat may spend on Vibe personas. It is for OpenVibe's owner.</p><p><a href="/vibe">Back to Vibe</a></p>`,++ });++ return null;++ }++ return actor;++ }++++ router.get('/admin/persona', async (req, res) => {++ const actor = await owner(req, res); if (!actor) return;++ let state;++ try { state = await persona.state(actor.user.id); } catch (err) {++ console.warn('[Vibe persona admin page]', err && err.message);++ return await page(req, res, 503, {++ actor, title: 'Vibe personas', path: '/admin/persona', robots: 'noindex, nofollow', footer: 'compact',++ body: '<h1>Vibe personas</h1><p>The persona settings are not available right now. The Chat log says why.</p>',++ });++ }++ const saved = req.query.remade ? notice('ok', 'Persona remade.')++ : req.query.saved ? notice('ok', `Saved${state.revision ? ` (revision ${state.revision})` : ''}.`)++ : req.query.error ? notice('error', String(req.query.error).slice(0, 240)) : '';++ await page(req, res, 200, {++ actor, title: 'Vibe personas', path: '/admin/persona', active: 'settings', robots: 'noindex, nofollow', shell: false, footer: 'compact',++ body: pageBody(state, saved),++ });++ });++++ router.post('/admin/persona', formLimit, sameSite, async (req, res) => {++ const actor = await owner(req, res); if (!actor) return;++ const b = req.body || {};++ try {++ await persona.update({++ enabled: b.enabled === '1',++ images: b.images === '1',++ text_model: b.text_model == null ? undefined : String(b.text_model).slice(0, 200),++ image_model: b.image_model == null ? undefined : String(b.image_model).slice(0, 200),++ check_every: b.check_every,++ monthly_cap_usd: b.monthly_cap_usd,++ }, { actor: actor.user.subject_id ? { type: 'user', id: String(actor.user.subject_id) } : null, reason: 'Vibe persona settings (owner page)' });++ res.redirect(303, '/admin/persona?saved=1#persona-settings');++ } catch (err) {++ const message = err && err.status ? err.message : 'The settings could not be saved right now';++ res.redirect(303, `/admin/persona?error=${encodeURIComponent(message)}#persona-settings`);++ }++ });++++ router.post('/admin/persona/remake', formLimit, sameSite, async (req, res) => {++ const actor = await owner(req, res); if (!actor) return;++ try {++ await persona.remake(String((req.body && req.body.slug) || ''), actor.user.id);++ res.redirect(303, '/admin/persona?remade=1#persona-chats');++ } catch (err) {++ const message = err && err.status ? err.message : 'That conversation could not be remade right now';++ res.redirect(303, `/admin/persona?error=${encodeURIComponent(message)}#persona-chats`);++ }++ });++}++++module.exports = { mountPersonaAdmin, pageBody, modelSelect, conversationRows, usd, priceLabel };@@@ -142,7 +142,7 @@ t('removing the panel token falls back to the environment; the hook is guarded',const denied = await h.http('POST', `/internal/config/${NS}/test/telegram.bot_token`, { token: h.serviceToken(['chat.stats.read']) });assert.strictEqual(denied.status, 403);const voices = await cfg('GET', '/internal/config');-- assert.deepStrictEqual(voices.body.namespaces.map((n) => n.namespace), ['chat.voices', 'chat.apps'], 'both namespaces on one route');++ assert.deepStrictEqual(voices.body.namespaces.map((n) => n.namespace), ['chat.voices', 'chat.apps', 'chat.persona'], 'every namespace on one route (2026-11-12: chat.persona)');});t('no log or console line carries a token', async () => {nnew file mode 100644@@@ -0,0 +1,36 @@++{++ "persona": {++ "first": {++ "name": "Keyboard Scout",++ "glyph": "keyboard",++ "hue": 210,++ "topic": "mechanical keyboards and switches"++ },++ "changed": {++ "name": "Stream Doctor",++ "glyph": "video",++ "hue": 12,++ "topic": "live streaming setups and OBS"++ },++ "invalid_glyph": {++ "name": "Ghost Guide",++ "glyph": "not-a-real-glyph",++ "hue": 400,++ "topic": "haunted houses and ghost stories"++ },++ "remade": {++ "name": "Desk Mechanic",++ "glyph": "wrench",++ "hue": 28,++ "topic": "fixing a squeaky desk and chair"++ }++ },++ "checks": {++ "no_change": "NO",++ "changed": "YES"++ },++ "image": {++ "b64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2ZQAAAABJRU5ErkJggg==",++ "url": null++ }++}@@@ -23,6 +23,7 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.const TABLES = ['vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import++ 'vibe_personas', 'vibe_persona_spend', // 0078: Vibe personas'chat_messages', 'person_chat_memory', 'person_chat_memory_settings', 'person_chat_memory_spend','dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides','channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',@@@ -114,6 +114,7 @@ t('DNS rebinding: public at the check, loopback at the download — refused whert('ratchet: every file that makes an outbound request itself is reviewed', () => {const REVIEWED = {'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',++ 'server/persona/index.js': 'a Vibe persona\'s picture address from the image provider (not a person\'s): https only, every hop through openvibe-shared/egress (assertPublicUrl and safeLookup), two redirects at most, 20 s, 2 MB','server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export','server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote','server/rooms/community-forum.js': 'Community API (configured internal URL), fixed space/thread endpoints; slugs and ids are validated and request paths are not user URLs',nnew file mode 100644@@@ -0,0 +1,224 @@++'use strict';++/**++ * Vibe personas (the owner, 2026-11-12): every Vibe conversation is named and faced from what it is about,++ * remade when its topic changes, on Chat's cheapest Exchange model, under the owner's monthly cap.++ *++ * The model answers here are recorded fixtures (test/fixtures/vibe-persona/recorded.json), fed through++ * aiClient._setClient(); the catalogue is injected too, so no test touches the network or a provider. The++ * Media upload is a stand-in. The behaviour under test is ours: when a persona is due, how a changed topic++ * is decided, what happens to an invalid glyph, the cap, the API shape and the owner-only panel.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++const glyphs = require('openvibe-shared/glyphs');++const FIX = require('./fixtures/vibe-persona/recorded.json');++++const t = suite('vibe personas');++let h, owner, ann, aiCalls, personaQueue, checkQueue, uploads, aiClient, media, persona;++++const systemOf = (call) => String(((call.input && call.input.messages) || []).find((m) => m.role === 'system')?.content || '');++const isPersonaCall = (call) => call.workflow === 'ai.complete' && /You name a private chat/.test(systemOf(call));++const isCheckCall = (call) => call.workflow === 'ai.complete' && /You decide whether/.test(systemOf(call));++const personaCalls = () => aiCalls.filter(isPersonaCall);++const checkCalls = () => aiCalls.filter(isCheckCall);++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++async function until(fn, what, { tries = 120, ms = 50 } = {}) {++ for (let i = 0; i < tries; i++) {++ const value = await fn();++ if (value) return value;++ await sleep(ms);++ }++ throw new Error(`Timed out waiting for ${what}`);++}++const chatOf = async (slug, u = ann) => (await h.http('GET', `/api/vibe/chat?slug=${encodeURIComponent(slug)}`, { token: u.token })).body;++const waitAnswer = async (slug, n = 1) => until(async () => {++ const r = await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token });++ return (r.body.messages || []).filter((m) => m.ai_member_id).length >= n;++}, `an answer in ${slug}`);++const waitPersona = async (slug, pred = (p) => !!p) => until(async () => {++ const chat = await chatOf(slug);++ return chat.chat && pred(chat.chat.persona) ? chat.chat.persona : null;++}, `a persona in ${slug}`);++const ask = async (message, slug = null, u = ann) => {++ const r = await h.http('POST', '/api/vibe/ask', { token: u.token, body: { message, ...(slug ? { slug } : { fresh: true }) } });++ assert.strictEqual(r.status, 201, r.text);++ return r.body.chat.slug;++};++const save = (body, u = owner) => h.http('POST', '/api/admin/persona', { token: u.token, body });++const clearSpend = async () => { await h.db.getDb().prepare('DELETE FROM vibe_persona_spend').run(); };++const resetAnswers = ({ personas = [], checks = [] } = {}) => {++ personaQueue = personas.slice();++ checkQueue = checks.slice();++};++++t('boot', async () => {++ h = await boot();++ aiClient = require('../server/ai/client');++ media = require('../server/media/client');++ persona = require('../server/persona');++ owner = h.addUser('owner', { role: 'admin', is_owner: 1, subject: ids.newId('user') });++ ann = h.addUser('ann', { subject: ids.newId('user') });++ for (const u of [owner, ann]) await h.ctx.upsertUser(h.live.users.get(u.id));++ persona._reset();++ aiCalls = [];++ uploads = [];++ resetAnswers();++ aiClient._setCatalog(async () => ({ models: [++ { slug: 'cheap/text', name: 'Cheap Text', kind: 'chat', offers: [{ price: { in: 0.01, out: 0.02 } }] },++ { slug: 'pricey/text', name: 'Pricey Text', kind: 'chat', offers: [{ price: { in: 10, out: 20 } }] },++ { slug: 'cheap/image', name: 'Cheap Image', kind: 'image', offers: [{ price: { in: 0.1, out: 0.2 } }] },++ ] }));++ aiClient._setClient({ runs: { create: async (workflow, input) => {++ aiCalls.push({ workflow, input });++ if (workflow === 'chat.persona.image') {++ return { run: { id: `img_${aiCalls.length}`, status: 'succeeded', output: { images: [{ b64: FIX.image.b64, url: null }], count: 1 }, usage: { cost_usd: 0.02 } } };++ }++ if (workflow !== 'ai.complete') return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'ok' } }] } } };++ let content;++ if (/You decide whether/.test(systemOf({ input }))) content = checkQueue.shift() || FIX.checks.no_change;++ else content = JSON.stringify(personaQueue.shift() || FIX.persona.first);++ return { run: { id: `run_${aiCalls.length}`, status: 'succeeded', output: { choices: [{ message: { content } }] }, usage: { cost_usd: 0.01 } } };++ } } });++ media._setClient({ upload: async (buffer, opts) => { uploads.push({ buffer, opts }); return { id: 'med_persona', public_url: 'https://media.openvibe.test/vibe-persona.png' }; } });++});++++t('the first answer makes a persona, the chat takes its name, and both APIs carry the shape', async () => {++ await clearSpend();++ assert.strictEqual((await save({ enabled: true, text_model: '', images: false, check_every: 8, monthly_cap_usd: 5 })).status, 200);++ resetAnswers({ personas: [FIX.persona.first] });++ const slug = await ask('I want a quiet mechanical keyboard with hot-swap switches and a nice thock');++ await waitAnswer(slug);++ const p = await waitPersona(slug);++ assert.strictEqual(p.name, 'Keyboard Scout');++ assert.strictEqual(p.glyph, 'keyboard');++ assert.strictEqual(p.hue, 210);++ assert.strictEqual(p.topic, 'mechanical keyboards and switches');++ assert.strictEqual(p.image_url, null);++ assert.ok(Number.isFinite(Date.parse(p.made_at)), p.made_at);++ const chat = await chatOf(slug);++ assert.deepStrictEqual(chat.chat.persona, p, 'GET /api/vibe/chat carries the same shape');++ assert.strictEqual(chat.chat.name, 'Keyboard Scout', 'the persona names the conversation');++ const list = await h.http('GET', '/api/vibe/conversations', { token: ann.token });++ const item = list.body.conversations.find((c) => c.slug === slug);++ assert.deepStrictEqual(item.persona, p, 'every item of /api/vibe/conversations carries the shape');++ assert.strictEqual(aiCalls.filter(isPersonaCall)[0].input.model, 'cheap/text', 'Auto picks the cheapest chat model');++});++++t('a topic check that says NO keeps the same persona and waits the interval', async () => {++ await clearSpend();++ assert.strictEqual((await save({ check_every: 2 })).status, 200);++ resetAnswers({ personas: [FIX.persona.first], checks: [FIX.checks.no_change] });++ const slug = await ask('Help me pick a keyboard switch that is not too loud');++ const first = await waitPersona(slug);++ await ask('What about keycaps that fit it?', slug);++ await until(async () => checkCalls().length >= 1, 'the topic check');++ await sleep(120);++ const same = await waitPersona(slug);++ assert.deepStrictEqual(same, first, 'NO leaves the made_at and every field alone');++ assert.strictEqual(same.name, 'Keyboard Scout');++});++++t('a changed topic makes a new persona, and the old name and face go', async () => {++ await clearSpend();++ assert.strictEqual((await save({ check_every: 2 })).status, 200);++ resetAnswers({ personas: [FIX.persona.first, FIX.persona.changed], checks: [FIX.checks.changed] });++ const slug = await ask('My mechanical keyboard sounds hollow; what foam fits?');++ const first = await waitPersona(slug);++ await ask('Now I want to start a live stream with OBS and three cameras; where do I begin?', slug);++ const changed = await waitPersona(slug, (p) => p && p.name !== first.name);++ assert.strictEqual(changed.name, 'Stream Doctor');++ assert.strictEqual(changed.glyph, 'video');++ assert.strictEqual(changed.topic, 'live streaming setups and OBS');++ assert.strictEqual((await chatOf(slug)).chat.name, 'Stream Doctor');++});++++t('an invalid glyph is retried and then replaced from the shared registry, never stored', async () => {++ await clearSpend();++ assert.strictEqual((await save({ check_every: 100 })).status, 200);++ resetAnswers({ personas: [FIX.persona.invalid_glyph, FIX.persona.invalid_glyph] });++ const slug = await ask('Tell me a ghost story about a house that hates doors');++ const p = await waitPersona(slug);++ await until(async () => personaCalls().length >= 2, 'the one retry');++ assert.strictEqual(p.name, 'Ghost Guide');++ assert.strictEqual(p.hue, 40, '400 wraps to 40');++ assert.ok(glyphs.NAMES.includes(p.glyph), `${p.glyph} is a glyph the registry names`);++ assert.ok(p.topic.includes('haunted') || p.topic.includes('ghost'), p.topic);++});++++t('the monthly cap stops new personas: a run that reached it makes no call', async () => {++ await clearSpend();++ assert.strictEqual((await save({ check_every: 100, monthly_cap_usd: 0.001 })).status, 200);++ resetAnswers({ personas: [FIX.persona.first] });++ const first = await ask('What is a good first mechanical keyboard under a hundred dollars?');++ await waitAnswer(first);++ const made = await waitPersona(first);++ assert.strictEqual(made.name, 'Keyboard Scout');++ const spent = (await h.http('GET', '/api/admin/persona', { token: owner.token })).body.spend;++ assert.ok(spent.usd >= 0.01, `the run's own cost is recorded (${spent.usd})`);++ assert.strictEqual(spent.reached, true);++ const callsBefore = aiCalls.length;++ const second = await ask('What about a keyboard for a small desk?');++ await waitAnswer(second);++ await sleep(250);++ assert.strictEqual((await chatOf(second)).chat.persona, null, 'a chat past the cap gets no persona');++ assert.strictEqual(aiCalls.length, callsBefore, 'and no model call is made for it');++});++++t('images on makes an avatar through the image model and stores it in Media', async () => {++ await clearSpend();++ assert.strictEqual((await save({ images: true, image_model: '', check_every: 100, monthly_cap_usd: 5 })).status, 200);++ resetAnswers({ personas: [FIX.persona.first] });++ const slug = await ask('Which keyboard plate material gives the best sound?');++ const p = await waitPersona(slug);++ assert.strictEqual(p.image_url, 'https://media.openvibe.test/vibe-persona.png');++ assert.strictEqual(uploads.length, 1);++ assert.ok(uploads[0].buffer.length > 8);++ const imageCall = aiCalls.find((c) => c.workflow === 'chat.persona.image');++ assert.strictEqual(imageCall.input.size, '256x256');++ assert.strictEqual(imageCall.input.model, 'cheap/image', 'Auto asks for the cheapest image model');++ assert.strictEqual((await save({ images: false })).status, 200);++});++++t('only the owner reads or changes the panel; it lists and remakes only their own chats, never losing a title they chose', async () => {++ assert.strictEqual((await h.http('GET', '/api/admin/persona', { token: ann.token })).status, 403);++ assert.strictEqual((await save({ enabled: false }, ann)).status, 403);++ const state = await h.http('GET', '/api/admin/persona', { token: owner.token });++ assert.strictEqual(state.status, 200, state.text);++ assert.strictEqual(state.body.settings.enabled, true);++ assert.ok(state.body.spend && Number.isFinite(state.body.spend.usd));++ assert.ok(Array.isArray(state.body.conversations));++ assert.ok(state.body.models.text.some((m) => m.id === 'cheap/text'));++ assert.ok(state.body.models.image.some((m) => m.id === 'cheap/image'));++++ const ownerPage = await h.http('GET', '/admin/persona', { token: owner.token });++ assert.strictEqual(ownerPage.status, 200);++ assert.match(ownerPage.text, /Vibe personas/);++ const annPage = await h.http('GET', '/admin/persona', { token: ann.token });++ assert.strictEqual(annPage.status, 403);++ assert.match(annPage.text, /Only the owner/);++++ // Another person's Vibe chats are private: a persona's topic summarises them, so the panel never lists them and++ // the owner cannot remake one.++ const theirs = await ask('Help me plan a surprise party for my sister');++ await waitPersona(theirs);++ const listed = (await h.http('GET', '/api/admin/persona', { token: owner.token })).body.conversations.map((c) => c.slug);++ assert.ok(!listed.includes(theirs), 'someone else\'s chat is not on the owner\'s panel');++ const refused = await h.http('POST', '/api/admin/persona/remake', { token: owner.token, body: { slug: theirs } });++ assert.strictEqual(refused.status, 404, 'and it cannot be remade from there');++++ const slug = await ask('I need to fix a squeaky desk; what tools should I get?', null, owner);++ await waitPersona(slug, (p) => !!p);++ assert.ok((await h.http('GET', '/api/admin/persona', { token: owner.token })).body.conversations.some((c) => c.slug === slug), 'the owner\'s own chat is listed');++ const renamed = await h.http('PATCH', `/api/chat/vibe/${slug}`, { token: owner.token, body: { name: 'My Own Desk Chat' } });++ assert.strictEqual(renamed.status, 200, renamed.text);++ resetAnswers({ personas: [FIX.persona.remade] });++ const made = await h.http('POST', '/api/admin/persona/remake', { token: owner.token, body: { slug } });++ assert.strictEqual(made.status, 200, made.text);++ assert.strictEqual(made.body.persona.name, 'Desk Mechanic');++ assert.strictEqual(made.body.persona.glyph, 'wrench');++ const chat = await chatOf(slug, owner);++ assert.strictEqual(chat.chat.name, 'My Own Desk Chat', 'a person\'s rename is never overwritten by a persona');++ assert.strictEqual(chat.chat.persona.name, 'Desk Mechanic');++});
@@ -160,6 +160,7 @@ same.
@@ -160,6 +160,7 @@ same.
- [README.md](README.md) — the product, cutover, data, events, rooms, Vibe, inboxes, feeds, deploy.
- [README.md](README.md) — the product, cutover, data, events, rooms, Vibe, inboxes, feeds, deploy.
- [docs/platform.md](docs/platform.md) — agent rooms and the digest Code/Chat/Actor use.
- [docs/platform.md](docs/platform.md) — agent rooms and the digest Code/Chat/Actor use.
+- [docs/vibe-personas.md](docs/vibe-personas.md) — the persona shape, the topic check, models, spend cap, owner page and tests.
- [docs/chat-ingress.md](docs/chat-ingress.md) — `/internal/chat/*` tickets and reads.
- [docs/chat-ingress.md](docs/chat-ingress.md) — `/internal/chat/*` tickets and reads.
- [docs/calls-cutover.md](docs/calls-cutover.md), [docs/parity.md](docs/parity.md),
- [docs/calls-cutover.md](docs/calls-cutover.md), [docs/parity.md](docs/parity.md),
[docs/chat-apps.md](docs/chat-apps.md), [docs/party-pool.md](docs/party-pool.md).
[docs/chat-apps.md](docs/chat-apps.md), [docs/party-pool.md](docs/party-pool.md).
@@ -148,6 +148,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -148,6 +148,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
| Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer |
| Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer |
| Vibe's search (OpenVibe and the web) | **OpenVibe.Search** (Search Fabric, `GET /api/v1/query`) | `server/rooms/search-fabric.js` (openvibe-sdk/search 0.70), Chat's service token (Network grants `search.query.delegate` on `openvibe.search`), the person in `X-OV-Subject`; each result keeps its provider, date and URL; Search's `/api/v1/search` and Actor's web task are the fallbacks only when the fabric is down |
| Vibe's search (OpenVibe and the web) | **OpenVibe.Search** (Search Fabric, `GET /api/v1/query`) | `server/rooms/search-fabric.js` (openvibe-sdk/search 0.70), Chat's service token (Network grants `search.query.delegate` on `openvibe.search`), the person in `X-OV-Subject`; each result keeps its provider, date and URL; Search's `/api/v1/search` and Actor's web task are the fallbacks only when the fabric is down |
+| Vibe personas (name, glyph, hue, topic, avatar) | **OpenVibe.AI / Exchange** and **OpenVibe.Media** | `server/persona/index.js`: `ai.complete` with an explicit catalogue model and `chat.persona.image` for a picture, Chat's service token; each run's `usage.cost_usd` is kept in `vibe_persona_spend` under the owner's cap; avatar bytes go to Media (namespace `chat`) |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Events | **OpenVibe.Events** | `service_outbox` (openvibe-sdk outbox) → `POST /api/v1/events` when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |
| Events | **OpenVibe.Events** | `service_outbox` (openvibe-sdk outbox) → `POST /api/v1/events` when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |
@@ -386,6 +387,20 @@ turned on "Let my AI use this computer's models".
@@ -386,6 +387,20 @@ turned on "Let my AI use this computer's models".
says "Vibe thinks with … (change)", "How Vibe did this" says which model answered, and a computer that does not answer
says "Vibe thinks with … (change)", "How Vibe did this" says which model answered, and a computer that does not answer
is named, with what to check. Removing the key ends it.
is named, with what to check. Removing the key ends it.
+**Vibe personas** (`server/persona/`, `migrations/0078_vibe_personas.sql`; the owner, 2026-11-12). Each Vibe
+conversation gets a name and a face made from what it is about, and a new one when the topic changes. The shape is
+`persona { name, glyph, hue, image_url|null, topic, made_at }`, on every item of `GET /api/vibe/conversations` and on
+`chat` in `GET /api/vibe/chat` (null until the first answer, and past the cap). The name also names the chat until the
+person renames it. The first is made after the first answer; then every 8 messages (configurable) the cheapest chat
+model answers one word, `YES` or `NO`, to "has the topic changed?". The glyph is one name from
+`openvibe-shared/glyphs`; an invalid answer is retried once and then replaced from the registry. Auto models are the
+cheapest chat and image models in Exchange's catalogue; every call is `ai.complete` (or Chat's
+`chat.persona.image` workflow for a picture) under Chat's own grant, billed to Chat, with its `usage.cost_usd`
+recorded against the owner's monthly cap. Images are off by default and go to OpenVibe.Media. The owner page is
+`/admin/persona` (spend shown, model choices, interval, cap, remake one chat); the same values are the revisioned
+`chat.persona` namespace, and `GET|POST /api/admin/persona` + `POST /api/admin/persona/remake` are owner-sign-in
+only. `docs/vibe-personas.md` has the shape, settings and test notes.
+
**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,
**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,
`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,
`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,
channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text
channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text
@@ -865,6 +880,7 @@ server/prefs/ chat preferences in the Network user module chat.pref
@@ -865,6 +880,7 @@ server/prefs/ chat preferences in the Network user module chat.pref
server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
+server/persona/ Vibe personas: settings.js (chat.persona owner settings), index.js (the first name, the topic check, the cap, the avatar)
server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)
server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)
scripts/ parity, subscribe-events, n-1-record
scripts/ parity, subscribe-events, n-1-record
docs/ calls-cutover.md, parity.md, chat-ingress.md, capabilities-proposal/
docs/ calls-cutover.md, parity.md, chat-ingress.md, capabilities-proposal/
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+# Vibe personas
+
+Each Vibe conversation on OpenVibe.Chat has a persona: a name and a face made from what the chat is about,
+remade when the topic changes. The owner, 2026-11-12: "for each vibe chat, it should generate a name based on
+whatever the conversation or the vibe of the chat is, and then it'll generate an avatar for that chat bot …
+and update every once in a while if the topic changes … the cheapest model possible, but configurable on the
+admin panel."
+
+## The shape
+
+Every persona is exactly this object, on `GET /api/vibe/conversations` (each item) and on `chat` in
+`GET /api/vibe/chat`:
+
+```json
+{
+ "name": "Keyboard Scout",
+ "glyph": "keyboard",
+ "hue": 210,
+ "image_url": null,
+ "topic": "mechanical keyboards and switches",
+ "made_at": "2026-11-12T09:31:26.431Z"
+}
+```
+
+- `name`: 1–3 Title Case words that say what the chat is about, as a character. No real person's name, no slur,
+ no digits. It is also the conversation's title until the person renames it.
+- `glyph`: one name from the OpenVibe glyph registry (`require('openvibe-shared/glyphs').NAMES`). A model answer
+ with any other name is retried once, then replaced from the registry, so no consumer ever receives an unknown
+ icon.
+- `hue`: 0–359, the colour that fits the topic.
+- `image_url`: null while avatar pictures are off (the default); otherwise a small public OpenVibe.Media URL.
+- `topic`: at most 12 words saying what the chat is about.
+- `made_at`: UTC ISO timestamp.
+
+`persona` is `null` for a chat whose first answer has not happened yet, and for a chat past the monthly cap.
+
+## When it is made
+
+- After the first answer. The second message (Vibe's answer) is stored, then a `setImmediate` due check makes the
+ persona. A reply never waits for it.
+- Then every `check_every` messages (default 8): the cheapest chat model reads the current topic and the recent
+ messages and answers exactly `YES` or `NO`. `NO` keeps the persona; `YES` makes a new one.
+- One generation per room at a time, with an idempotency key on every model call, so a retry or a restart replays
+ the same answer rather than paying twice.
+- If the person renamed the chat (`PATCH /api/chat/vibe/:slug`), `rooms.vibe_title_locked` is set and a persona
+ never writes over their title.
+
+## Models and money
+
+Every model call is OpenVibe.AI's `ai.complete` (Chat's own Network grant, namespace `chat.*` + `ai.complete`) with
+an explicit Exchange catalogue model. `text_model` empty means Auto: the cheapest chat model in
+`GET /api/v1/exchange/catalog`, chosen by input + output price. Avatar pictures use the Chat-owned
+`chat.persona.image` workflow (the same image step as `ai.image`, under Chat's namespace) with the cheapest image
+model when `image_model` is empty; the bytes are uploaded to OpenVibe.Media as Chat and the public URL is stored on
+the persona. A failed picture never loses the name.
+
+The runs are Chat's own (not the person's allowance). Each run's `usage.cost_usd` is recorded in
+`vibe_persona_spend`; the owner's monthly cap is checked before every call and read from that table. `0` stops
+persona spending. At the cap, existing personas stay and new chats get no persona until the cap is raised or the
+month turns (UTC).
+
+## Owner settings
+
+The page is **https://openvibe.chat/admin/persona** (owner only: an admin with Network's owner flag). It shows this
+month's spend next to the cap, the model choices with their prices, the interval, and every recent conversation
+with a **Remake** button. The same values are a revisioned configuration namespace, `chat.persona`, in
+`openvibe-shared/config`, so they are auditable and Network's owner settings proxy can read them.
+
+| Key | Default | Meaning |
+|---|---|---|
+| `enabled` | `true` | make and check personas at all |
+| `text_model` | `''` | catalogue model for names and checks; empty = Auto (cheapest chat model) |
+| `images` | `false` | make an avatar picture too |
+| `image_model` | `''` | catalogue model for the avatar; empty = Auto (cheapest image model) |
+| `check_every` | `8` | messages between topic checks (2–100) |
+| `monthly_cap_usd` | `5` | most Chat may spend on personas in a UTC month; 0 stops it |
+
+API, for scripts and the page:
+
+```http
+GET /api/admin/persona { settings, defaults, spend, models, conversations, month, revision }
+POST /api/admin/persona { enabled?, text_model?, images?, image_model?, check_every?, monthly_cap_usd? }
+POST /api/admin/persona/remake { slug } → { persona, conversations }
+```
+
+All three are owner sign-in only (an API token is refused) and return 403 to anyone else. `spend` is
+`{ month, usd, cap_usd, remaining_usd, reached }`.
+
+## Storage
+
+- `vibe_personas` (migration `0078_vibe_personas.sql`): one row per room, the six fields and the check cursor
+ (`checked_count`, `checked_at`), generation number, cascade with the room.
+- `vibe_persona_spend`: one row per model call with month, kind (`text`/`image`), model, run id and the run's cost.
+- `rooms.vibe_title_locked`: set by `server/rooms/vibe.js` `rename`; personas leave the title alone after that.
+
+The engine is `server/persona/index.js`; the settings store `server/persona/settings.js`; the page
+`server/web/persona-admin.js`; the JSON API `server/rooms/persona-api.js`.
+
+## Tests
+
+`test/vibe-persona.test.js` uses recorded model answers (`test/fixtures/vibe-persona/recorded.json`) through
+`aiClient._setClient()` and an injected catalogue (`aiClient._setCatalog()`), with a stand-in Media client, so no
+test touches the network. It covers the first persona, the API shape and title, a NO check, a YES change, an
+invalid glyph, the monthly cap, images, and the owner-only settings and remake.
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+-- phase: expand
+-- A face and a name for each Vibe conversation (the owner, 2026-11-12): made from what the chat is about,
+-- remade when its topic changes. The persona belongs to the room and goes with it; the spend is what Chat's
+-- own models cost for personas this month, so the owner's cap is real money and not a counter of attempts.
+CREATE TABLE IF NOT EXISTS vibe_personas (
+ room_id bigint PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
+ name text COLLATE "C" NOT NULL,
+ glyph text COLLATE "C" NOT NULL,
+ hue smallint NOT NULL,
+ image_url text COLLATE "C",
+ topic text COLLATE "C" NOT NULL,
+ made_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ checked_at text COLLATE "C",
+ checked_count integer NOT NULL DEFAULT 0,
+ generation integer NOT NULL DEFAULT 1,
+ CONSTRAINT vibe_personas_hue CHECK (hue >= 0 AND hue <= 359),
+ CONSTRAINT vibe_personas_generation CHECK (generation >= 1)
+);
+
+CREATE TABLE IF NOT EXISTS vibe_persona_spend (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ room_id bigint REFERENCES rooms(id) ON DELETE SET NULL,
+ month text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ model text COLLATE "C",
+ cost_usd numeric NOT NULL DEFAULT 0,
+ run_id text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ CONSTRAINT vibe_persona_spend_kind CHECK (kind IN ('text', 'image'))
+);
+CREATE INDEX IF NOT EXISTS vibe_persona_spend_month ON vibe_persona_spend(month);
+
+-- A chat is named after its first message until the person renames it. Once they do, a persona never
+-- writes over their name; this is the flag that records it.
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS vibe_title_locked smallint NOT NULL DEFAULT 0;
@@ -14,6 +14,7 @@ let _ai = null;
@@ -14,6 +14,7 @@ let _ai = null;
let _http = null;
let _http = null;
let _tokens = null;
let _tokens = null;
let _complete = null;
let _complete = null;
+let _catalog = null; // tests: a recorded catalogue, so no model page or persona test touches the network
// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that
// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that
// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.
// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.
let _lastProblem = null;
let _lastProblem = null;
@@ -182,10 +183,11 @@ async function complete({ model, messages, subject, temperature = null }) {
@@ -182,10 +183,11 @@ async function complete({ model, messages, subject, temperature = null }) {
}
}
async function catalog() {
async function catalog() {
+ if (_catalog) return _catalog();
const res = await fetch(`${config.ai.internalUrl}/api/v1/exchange/catalog`, { signal: AbortSignal.timeout(8000) });
const res = await fetch(`${config.ai.internalUrl}/api/v1/exchange/catalog`, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`AI catalogue answered ${res.status}`);
if (!res.ok) throw new Error(`AI catalogue answered ${res.status}`);
return res.json();
return res.json();
}
}
-module.exports = { structured, stream, complete, catalog, enabled, bearer, http, lastProblem, _setClient(c) { _ai = c; }, _setComplete(c) { _complete = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },
- _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _complete = null; _lastProblem = null; } };
+module.exports = { structured, stream, complete, catalog, enabled, bearer, http, lastProblem, _setClient(c) { _ai = c; }, _setCatalog(c) { _catalog = c; }, _setComplete(c) { _complete = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },
+ _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _complete = null; _catalog = null; _lastProblem = null; } };
@@ -65,7 +65,7 @@ function getAllowedOrigins() {
@@ -65,7 +65,7 @@ function getAllowedOrigins() {
return allowed;
return allowed;
}
}
-function createApp({ chatServer, relay, events = null, callServer = null, settings = null, chatAppSettings = null }) {
+function createApp({ chatServer, relay, events = null, callServer = null, settings = null, chatAppSettings = null, personaSettings = null }) {
const allowedOrigins = getAllowedOrigins();
const allowedOrigins = getAllowedOrigins();
const app = express();
const app = express();
// Times leave with their zone (openvibe.design rule 148): the SQLite-era text columns are UTC.
// Times leave with their zone (openvibe.design rule 148): the SQLite-era text columns are UTC.
@@ -160,11 +160,11 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -160,11 +160,11 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// or x-danger change needs X-OV-Fresh: 1, and the test route synthesizes one short line without echoing a key.
// or x-danger change needs X-OV-Fresh: 1, and the test route synthesizes one short line without echoing a key.
// chat.apps (the Telegram, Discord and Slack bots) is served by the same routes; its Test button is mounted first,
// chat.apps (the Telegram, Discord and Slack bots) is served by the same routes; its Test button is mounted first,
// because the voices' test route answers 404 for every other namespace.
// because the voices' test route answers 404 for every other namespace.
- if (settings || chatAppSettings) {
+ if (settings || chatAppSettings || personaSettings) {
const guard = serviceAuth.guard('chat.config.manage');
const guard = serviceAuth.guard('chat.config.manage');
app.use('/internal/config', express.json({ limit: '64kb' }));
app.use('/internal/config', express.json({ limit: '64kb' }));
if (chatAppSettings) app.post(`/internal/config/${chatAppSettings.namespace}/test/:key`, guard, require('./chat-apps/owner-settings').testRoute(chatAppSettings));
if (chatAppSettings) app.post(`/internal/config/${chatAppSettings.namespace}/test/:key`, guard, require('./chat-apps/owner-settings').testRoute(chatAppSettings));
- const also = chatAppSettings ? [chatAppSettings.store] : [];
+ const also = [chatAppSettings && chatAppSettings.store, personaSettings && personaSettings.store].filter(Boolean);
if (settings) settings.mount(app, { guard, also });
if (settings) settings.mount(app, { guard, also });
else require('./settings').mountStores(app, { guard, stores: also });
else require('./settings').mountStores(app, { guard, stores: also });
}
}
@@ -377,6 +377,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -377,6 +377,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/dm', require('./chat/dm-routes'));
// Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.
// Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.
app.use('/api/vibe', require('./rooms/vibe-api'));
app.use('/api/vibe', require('./rooms/vibe-api'));
+ // The owner's Vibe persona settings (server/rooms/persona-api.js): owner sign-in only, never an API token.
+ app.use('/api/admin/persona', limits.identify, require('./rooms/persona-api'));
// A companion's answer, read aloud in the character's own voice (the person's own token, the agent
// A companion's answer, read aloud in the character's own voice (the person's own token, the agent
// voice's per-person limiter).
// voice's per-person limiter).
app.use('/api/tts', require('./chat/tts-person'));
app.use('/api/tts', require('./chat/tts-person'));
@@ -63,6 +63,15 @@ async function start() {
@@ -63,6 +63,15 @@ async function start() {
} catch (err) {
} catch (err) {
console.warn('[Chat] chat-app settings unavailable, the chat apps read the environment:', err && err.message);
console.warn('[Chat] chat-app settings unavailable, the chat apps read the environment:', err && err.message);
}
}
+ // Vibe personas' owner settings (chat.persona, server/persona/settings.js): the models, the re-check interval
+ // and the monthly cap. A failure turns personas off (no store, no spending) and says why in the log.
+ let personaSettings = null;
+ try {
+ personaSettings = await require('./persona/settings').createPersonaSettings({ db: db.getDb(), log: console });
+ console.log(`[Chat] owner settings ready (${personaSettings.namespace})`);
+ } catch (err) {
+ console.warn('[Chat] persona settings unavailable, Vibe personas are off:', err && err.message);
+ }
// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this
// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this
// process, as before. openvibe-sdk/valkey's createValkey returns null when the URL is unset.
// process, as before. openvibe-sdk/valkey's createValkey returns null when the URL is unset.
@@ -79,7 +88,7 @@ async function start() {
@@ -79,7 +88,7 @@ async function start() {
callServer.init(server);
callServer.init(server);
const relay = serviceOutbox.configure({ config });
const relay = serviceOutbox.configure({ config });
const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });
const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });
- const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer, settings, chatAppSettings });
+ const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer, settings, chatAppSettings, personaSettings });
void app.locals.chatApps.start();
void app.locals.chatApps.start();
server.on('request', app);
server.on('request', app);
server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,709 @@
@@ -0,0 +1,709 @@
+'use strict';
+/**
+ * Vibe personas (the owner, 2026-11-12): every Vibe conversation gets a name and a face made from what the
+ * chat is about, remade when the topic changes.
+ *
+ * persona { name, glyph, hue, image_url|null, topic, made_at }
+ *
+ * The shape is deliberately small and product-neutral: Actor and Codes can show the same run card's agent with
+ * it (research-2026-11-12/one-agent-system.md §2), and the main session draws it in the Vibe panel from the API
+ * field, without OpenVibe.Shared changing.
+ *
+ * Rules:
+ * - Made after the first answer; checked every `check_every` messages (default 8). A check asks the cheapest
+ * text model whether the topic changed, one word: YES or NO. YES makes a new persona.
+ * - Due-based, idempotent and never blocking a reply: afterPost() schedules setImmediate() work and returns;
+ * one generation per room at a time; every model call carries an idempotency key; a change's key names the
+ * persona generation and the message count it read.
+ * - The name also names the conversation, unless the person renamed it (rooms.vibe_title_locked).
+ * - Glyph is one name from openvibe-shared/glyphs; an invalid answer is retried once and then replaced by a
+ * deterministic glyph from the same registry, so no other product can ever receive an unknown icon.
+ * - The models are the owner's: '' = Auto = the cheapest chat (or image) model in Exchange's catalogue, read
+ * through Chat's own OpenVibe.AI grant (`chat.member.turn`'s ai.complete passthrough is not used; Chat runs
+ * `ai.complete` with an explicit catalogue model). Text runs are billed to Chat, not the person.
+ * - A monthly cap in US dollars is checked before every call and read from the runs' own usage. At the cap
+ * Chat stops making and checking personas; existing ones stay.
+ * - Images are off by default. When on, a small avatar is made by the cheapest image model through the
+ * Chat-owned `chat.persona.image` workflow (OpenVibe.AI), uploaded to OpenVibe.Media as Chat, and its
+ * public URL stored on the persona. A failed picture never loses the name.
+ *
+ * Owner settings and the admin page: server/persona/settings.js, /admin/persona (server/web/persona-admin.js),
+ * GET|POST /api/admin/persona and POST /api/admin/persona/remake (server/rooms/persona-api.js).
+ */
+const crypto = require('crypto');
+const db = require('../db/database');
+const aiClient = require('../ai/client');
+const media = require('../media/client');
+const wordFilter = require('../chat/word-filter');
+const settings = require('./settings');
+
+const GLYPHS = require('openvibe-shared/glyphs');
+const GLYPH_NAMES = Object.freeze([...GLYPHS.NAMES]);
+const GLYPH_SET = new Set(GLYPH_NAMES);
+// Topic-friendly names from the registry, checked here so a prompt never asks for an icon that does not exist.
+const GLYPH_CHOICES = Object.freeze([
+ 'airplane', 'archive', 'bell', 'book', 'book-open', 'brain', 'branch', 'bridge', 'briefcase', 'bug',
+ 'calendar', 'camera', 'car', 'cart', 'chart', 'chat', 'chip', 'city', 'clapper', 'clipboard', 'clock',
+ 'cloud', 'code', 'coin', 'compass', 'computer', 'cookie', 'crown', 'database', 'dice', 'dish', 'dollar',
+ 'door', 'download', 'drive', 'ear', 'film', 'filter', 'fire', 'fish', 'flag', 'flask', 'focus', 'folder',
+ 'food', 'game', 'gauge', 'gavel', 'gear', 'gem', 'ghost', 'gift', 'globe', 'grid', 'group', 'hammer',
+ 'hand', 'handshake', 'hash', 'headphones', 'heart', 'help', 'history', 'home', 'idea', 'image', 'inbox',
+ 'key', 'keyboard', 'language', 'layers', 'leaf', 'lifebuoy', 'link', 'live', 'lock', 'lungs', 'mail',
+ 'map', 'mask', 'medical', 'megaphone', 'members', 'mic', 'money', 'moon', 'mountain', 'mug', 'music',
+ 'news', 'palette', 'paw', 'pencil', 'phone', 'piggy', 'plane', 'play', 'puzzle', 'radio', 'ranking',
+ 'recycle', 'reply', 'road', 'robot', 'rocket', 'route', 'scale', 'scissors', 'scroll', 'search',
+ 'seedling', 'send', 'server', 'shapes', 'shield', 'shirt', 'shoe', 'signature', 'skull', 'smile', 'snow',
+ 'spark', 'star', 'steps', 'store', 'storm', 'sun', 'sword', 'table', 'tag', 'target', 'task', 'tent',
+ 'terminal', 'text', 'ticket', 'timeline', 'toolbox', 'train', 'tree', 'trophy', 'tv', 'umbrella', 'user',
+ 'video', 'wallet', 'wave', 'weight', 'wifi', 'wind', 'wizard', 'wrench', 'zap',
+].filter((name) => GLYPH_SET.has(name)));
+
+const FALLBACK_TEXT_MODEL = 'shared/gpt-5-nano';
+const FALLBACK_IMAGE_MODEL = 'openrouter/google/gemini-3.1-flash-lite-image';
+const MAX_NAME = 32;
+const MAX_TOPIC = 120;
+const HISTORY_FOR_FIRST = 4;
+const HISTORY_FOR_CHECK = 12;
+const MAX_MESSAGE_CHARS = 700;
+const MAX_PROMPT_CHARS = 7000;
+const CAP_SEEN = 60_000;
+const TEXT_CALL_MS = 45_000;
+const IMAGE_CALL_MS = 60_000;
+
+const inFlight = new Set();
+const catalogCache = { at: 0, value: null };
+const lastProblem = new Map();
+
+class PersonaError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+const fail = (status, code, message) => { throw new PersonaError(status, code, message); };
+
+const monthKey = (d = new Date()) => d.toISOString().slice(0, 7);
+
+function iso(value) {
+ if (!value) return null;
+ const text = String(value);
+ const d = new Date(text.includes('T') ? text : `${text.replace(' ', 'T')}Z`);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+function clean(value, max) {
+ return String(value == null ? '' : value)
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, max);
+}
+
+/** A name as a character: 1–3 Title Case words, no digits, no markdown, nothing the word filter refuses. */
+function cleanName(raw) {
+ const s = clean(String(raw == null ? '' : raw).replace(/^["'`*_]+|["'`*_]+$/g, ''), MAX_NAME);
+ if (!s || s.length < 2 || s.length > MAX_NAME) return null;
+ if (!/^[\p{L}][\p{L}'’-]*(?: [\p{L}][\p{L}'’-]*){0,2}$/u.test(s)) return null;
+ if (wordFilter.check(s).safe === false) return null;
+ return s;
+}
+
+function cleanTopic(raw, fallback = 'a conversation') {
+ const s = clean(String(raw == null ? '' : raw).replace(/^["'`*_]+|["'`*_]+$/g, ''), MAX_TOPIC);
+ if (!s || s.length < 2) return fallback;
+ if (wordFilter.check(s).safe === false) return fallback;
+ return s;
+}
+
+/** A deterministic glyph from the same registry when the model's answer is unusable. */
+function fallbackGlyph(seed) {
+ const digest = crypto.createHash('sha256').update(String(seed || 'vibe')).digest();
+ return GLYPH_CHOICES[digest[0] % GLYPH_CHOICES.length] || 'spark';
+}
+
+function cleanHue(raw, seed) {
+ const n = Number(raw);
+ if (Number.isFinite(n)) return ((Math.round(n) % 360) + 360) % 360;
+ const digest = crypto.createHash('sha256').update(String(seed || 'vibe')).digest();
+ return ((digest[1] << 8 | digest[2]) % 360 + 360) % 360;
+}
+
+/** JSON from a model that may wrap it in prose or a code fence. */
+function jsonOf(text) {
+ const raw = String(text || '').trim();
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
+ const body = fenced ? fenced[1] : raw;
+ const start = body.indexOf('{');
+ const end = body.lastIndexOf('}');
+ if (start < 0 || end <= start) return null;
+ try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; }
+}
+
+/** The model's one-word answer: YES only when the first word is YES. */
+function yesOf(text) {
+ return /^\s*(?:yes|y)\b/i.test(String(text || ''));
+}
+
+const clampInt = (v, min, max, fallback) => {
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback;
+};
+
+// ── models and spend ────────────────────────────────────────────────────────
+
+function offerPrice(model) {
+ let best = null;
+ for (const offer of (model && Array.isArray(model.offers) ? model.offers : [])) {
+ const p = offer && offer.price;
+ if (!p) continue;
+ const input = Number(p.in != null ? p.in : p.input_per_million);
+ const output = Number(p.out != null ? p.out : p.output_per_million);
+ if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
+ const score = input + output;
+ if (!best || score < best.score) best = { in: input, out: output, score };
+ }
+ return best;
+}
+
+const modelIdOf = (model) => String((model && (model.slug || model.id)) || '').trim();
+
+/** Exchange's live catalogue, cached for a minute so a page load does not scan it twice. */
+async function models() {
+ if (catalogCache.value && Date.now() - catalogCache.at < CAP_SEEN) return catalogCache.value;
+ const out = await aiClient.catalog();
+ catalogCache.at = Date.now();
+ catalogCache.value = out && Array.isArray(out.models) ? out.models : [];
+ return catalogCache.value;
+}
+
+async function cheapestModel(kind) {
+ let catalog = [];
+ try { catalog = await models(); } catch { catalog = []; }
+ let best = null;
+ for (const model of catalog) {
+ const modelKind = model.kind || 'chat';
+ if (modelKind !== kind) continue;
+ const id = modelIdOf(model);
+ if (!id) continue;
+ const price = offerPrice(model);
+ if (!price) continue;
+ if (!best || price.score < best.score || (price.score === best.score && String(model.name || '').localeCompare(String(best.name || '')) < 0)) {
+ best = { id, score: price.score, name: model.name || id };
+ }
+ }
+ if (best) return best.id;
+ return kind === 'image' ? FALLBACK_IMAGE_MODEL : FALLBACK_TEXT_MODEL;
+}
+
+async function modelFor(kind) {
+ const configured = clean(settings.value(kind === 'image' ? 'image_model' : 'text_model'), 200);
+ if (configured) return configured;
+ return await cheapestModel(kind);
+}
+
+async function spendThisMonth() {
+ const row = await db.get('SELECT COALESCE(SUM(cost_usd), 0) AS usd FROM vibe_persona_spend WHERE month = ?', [monthKey()]);
+ return Number(row && row.usd) || 0;
+}
+
+async function capReached() {
+ const cap = Number(settings.value('monthly_cap_usd'));
+ if (!Number.isFinite(cap) || cap <= 0) return true;
+ return (await spendThisMonth()) >= cap;
+}
+
+async function recordSpend(roomId, kind, model, run) {
+ const cost = run && run.usage && run.usage.cost_usd != null ? Math.max(0, Number(run.usage.cost_usd) || 0) : 0;
+ try {
+ await db.run('INSERT INTO vibe_persona_spend (room_id, month, kind, model, cost_usd, run_id) VALUES (?, ?, ?, ?, ?, ?)',
+ [roomId || null, monthKey(), kind, clean(model, 200) || null, cost, clean(run && run.id, 80) || null]);
+ } catch (err) { console.warn('[Vibe persona] spend:', err && err.message); }
+}
+
+// ── prompts and calls ────────────────────────────────────────────────────────
+
+function transcriptOf(rows) {
+ return (rows || []).map((m) => `${m.ai_member_id ? 'Vibe' : (m.display_name || m.username || 'They')}: ${String(m.message || '').replace(/\s+/g, ' ').slice(0, MAX_MESSAGE_CHARS)}`).join('\n').slice(-MAX_PROMPT_CHARS);
+}
+
+const PERSONA_SYSTEM = [
+ 'You name a private chat with Vibe, OpenVibe\'s assistant. Read the messages and answer with JSON only, no markdown, no code fence:',
+ '{"name":"...","glyph":"...","hue":210,"topic":"..."}',
+ '- name: 1 to 3 English words in Title Case that say what this chat is really about, as a character: "Keyboard Scout", "Stream Doctor", "Dinner Planner". It is a nickname, never a real person\'s name, never a slur, and never more than 32 characters.',
+ '- glyph: exactly one name from this list (one word, lowercase): ' + GLYPH_CHOICES.join(', ') + '.',
+ '- hue: an integer from 0 to 359, the colour that fits the topic.',
+ '- topic: at most 12 plain words saying what the chat is about.',
+ 'Do not repeat the person\'s words. Do not use a person\'s name. Answer only the JSON object.',
+].join('\n');
+
+function personaUser(rows, previous) {
+ return [
+ previous ? `The chat had this persona before: ${previous.name} (${previous.topic}). Make a different name and glyph that fit the new topic.` : '',
+ 'The conversation:',
+ transcriptOf(rows) || '(no messages yet)',
+ '',
+ 'Return only the JSON object for this chat.',
+ ].filter(Boolean).join('\n');
+}
+
+const CHECK_SYSTEM = [
+ 'You decide whether the topic of a chat has changed.',
+ 'Answer exactly one word: YES or NO.',
+ 'Answer YES only when the conversation is now mostly about something else than the current topic; a follow-up, a detail or a new question on the same subject is NO.',
+].join('\n');
+
+function checkUser(rows, persona) {
+ return [
+ `Current topic: ${persona.topic}`,
+ 'Recent messages:',
+ transcriptOf(rows) || '(none)',
+ '',
+ 'Has the topic changed? Answer only YES or NO.',
+ ].join('\n');
+}
+
+async function callModel({ roomId, kind, model, messages, maxTokens, temperature, key, timeoutMs }) {
+ let output = null;
+ try {
+ output = await aiClient.structured('ai.complete', {
+ model, messages, max_tokens: maxTokens, temperature,
+ }, {
+ force: true,
+ idempotencyKey: key,
+ onRun: (run) => recordSpend(roomId, kind, model, run),
+ });
+ } catch (err) {
+ lastProblem.set(roomId, clean(err && err.message, 200));
+ return null;
+ }
+ if (!output) { lastProblem.set(roomId, 'ai.empty'); return null; }
+ const content = output && Array.isArray(output.choices) && output.choices[0] && output.choices[0].message
+ ? output.choices[0].message.content : null;
+ if (typeof content !== 'string' || !content.trim()) { lastProblem.set(roomId, 'ai.empty'); return null; }
+ lastProblem.delete(roomId);
+ return { text: content, model, at: Date.now() };
+}
+
+async function askForPersona(room, rows, previous, key) {
+ const model = await modelFor('chat');
+ return await callModel({
+ roomId: room.id, kind: 'text', model, key,
+ messages: [{ role: 'system', content: PERSONA_SYSTEM }, { role: 'user', content: personaUser(rows, previous) }],
+ maxTokens: 240, temperature: 0.4, timeoutMs: TEXT_CALL_MS,
+ });
+}
+
+function personaFrom(answer, seed, previous) {
+ if (!answer) return null;
+ const j = jsonOf(answer.text);
+ if (!j || typeof j !== 'object') return null;
+ const name = cleanName(j.name);
+ if (!name) return null;
+ const topic = cleanTopic(j.topic, previous ? previous.topic : 'a conversation');
+ const glyph = GLYPH_SET.has(String(j.glyph || '')) ? String(j.glyph) : null;
+ const hue = cleanHue(j.hue, `${name}|${topic}`);
+ return { name, topic, glyph, hue };
+}
+
+/**
+ * A picture an image provider answered with an address: https only, every hop's address checked public by
+ * openvibe-shared/egress (DNS rebinding included, through its lookup), at most two redirects, 20 s and 2 MB.
+ * Null for anything else.
+ */
+async function downloadImage(url, hops = 0) {
+ const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');
+ let u;
+ try { u = new URL(String(url)); } catch { return null; }
+ if (u.protocol !== 'https:' || u.username || u.password || hops > 2) return null;
+ try { await assertPublicUrl(u, { protocols: ['https:'] }); } catch { return null; }
+ const res = await new Promise((resolve) => {
+ const https = require('https');
+ const req = https.request(u.href, { method: 'GET', lookup: safeLookup, timeout: 20_000, headers: { accept: 'image/*' } }, (r) => {
+ const chunks = []; let size = 0; let over = false;
+ r.on('data', (c) => { size += c.length; if (size > 2 * 1024 * 1024) { over = true; r.destroy(); return; } chunks.push(c); });
+ r.on('end', () => resolve(over ? null : { status: r.statusCode, headers: r.headers, bytes: Buffer.concat(chunks) }));
+ r.on('close', () => resolve(over ? null : { status: r.statusCode, headers: r.headers, bytes: Buffer.concat(chunks) }));
+ r.on('error', () => resolve(null));
+ });
+ req.on('timeout', () => req.destroy());
+ req.on('error', () => resolve(null));
+ req.end();
+ });
+ if (!res) return null;
+ if ([301, 302, 303, 307, 308].includes(Number(res.status)) && res.headers.location) {
+ let next; try { next = new URL(String(res.headers.location), u).href; } catch { return null; }
+ return downloadImage(next, hops + 1);
+ }
+ if (res.status !== 200 || !res.bytes.length) return null;
+ return { type: String(res.headers['content-type'] || '').split(';')[0].trim().toLowerCase(), bytes: res.bytes };
+}
+
+/** The avatar: one small picture from the image model, uploaded to Media as Chat. Never fatal. */
+async function avatarFor(room, persona, key) {
+ if (!settings.value('images')) return null;
+ if (await capReached()) return null;
+ const configured = clean(settings.value('image_model'), 200);
+ const model = configured || await modelFor('image');
+ const prompt = `A small square avatar picture for a chat called "${persona.name}" about "${persona.topic}". One clear subject, flat vector style, bold simple shapes, centred, no text, no letters, no logos, readable at 48 pixels.`;
+ const call = (withModel, suffix) => aiClient.structured('chat.persona.image', {
+ ...(withModel ? { model } : {}),
+ prompt, size: '256x256', n: 1,
+ }, {
+ force: true,
+ idempotencyKey: `${key}-image${suffix}`,
+ onRun: (run) => recordSpend(room.id, 'image', model, run),
+ });
+ let out = null;
+ try {
+ out = await call(true, '');
+ // Auto names the cheapest image model; an Exchange without that exact model falls back to the
+ // image route's own configured model instead of losing the avatar. A model the owner chose is theirs.
+ if (!out && !configured) out = await call(false, '-default');
+ } catch (err) {
+ console.warn('[Vibe persona] image:', err && err.message);
+ return null;
+ }
+ const first = out && Array.isArray(out.images) ? out.images[0] : null;
+ if (!first) return null;
+ let buffer = null;
+ let mimeType = 'image/png';
+ try {
+ if (typeof first.b64 === 'string' && first.b64.length > 32) {
+ buffer = Buffer.from(first.b64.replace(/^data:image\/[a-z0-9.+-]+;base64,/i, ''), 'base64');
+ const m = /^data:(image\/[a-z0-9.+-]+);base64,/i.exec(first.b64);
+ if (m) mimeType = m[1].toLowerCase();
+ } else if (typeof first.url === 'string' && first.url) {
+ if (/^data:image\//i.test(first.url)) {
+ const m = /^data:(image\/[a-z0-9.+-]+);base64,(.*)$/is.exec(first.url);
+ if (m) { mimeType = m[1].toLowerCase(); buffer = Buffer.from(m[2], 'base64'); }
+ } else if (/^https:\/\//i.test(first.url)) {
+ // The provider's picture address is not ours to trust: the egress guard checks every hop is public.
+ const got = await downloadImage(first.url);
+ if (!got) return null;
+ if (/^image\//.test(got.type)) mimeType = got.type;
+ buffer = got.bytes;
+ }
+ }
+ if (!buffer || !buffer.length || buffer.length > 2 * 1024 * 1024) return null;
+ const uploaded = await media.uploadObject(buffer, { filename: `vibe-persona-${room.slug}.png`, mimeType });
+ return uploaded && uploaded.url ? String(uploaded.url).slice(0, 1000) : null;
+ } catch (err) {
+ console.warn('[Vibe persona] avatar:', err && err.message);
+ return null;
+ }
+}
+
+// ── storage ──────────────────────────────────────────────────────────────────
+
+const PERSONA_COLUMNS = 'room_id, name, glyph, hue, image_url, topic, made_at, checked_at, checked_count, generation';
+
+function wire(row) {
+ if (!row) return null;
+ return {
+ name: String(row.name || ''),
+ glyph: GLYPH_SET.has(String(row.glyph || '')) ? String(row.glyph) : fallbackGlyph(row.name || row.room_id),
+ hue: cleanHue(row.hue, row.name || row.room_id),
+ image_url: row.image_url ? String(row.image_url) : null,
+ topic: String(row.topic || ''),
+ made_at: iso(row.made_at),
+ };
+}
+
+async function forRoom(roomId) {
+ return await db.get(`SELECT ${PERSONA_COLUMNS} FROM vibe_personas WHERE room_id = ?`, [Number(roomId)]) || null;
+}
+
+async function forRooms(roomIds) {
+ const ids = [...new Set((roomIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0))];
+ const out = new Map();
+ if (!ids.length) return out;
+ const rows = await db.all(`SELECT ${PERSONA_COLUMNS} FROM vibe_personas WHERE room_id IN (${ids.map(() => '?').join(',')})`, ids);
+ for (const row of rows) out.set(Number(row.room_id), row);
+ return out;
+}
+
+const countMessages = async (roomId) => Number((await db.get('SELECT COUNT(*) AS n FROM room_messages WHERE room_id = ? AND is_deleted = 0', [roomId]))?.n) || 0;
+const hasAnswer = async (roomId) => !!(await db.get('SELECT 1 AS yes FROM room_messages WHERE room_id = ? AND ai_member_id IS NOT NULL AND is_deleted = 0 LIMIT 1', [roomId]));
+
+async function recentRows(roomId, { limit = HISTORY_FOR_CHECK, first = false } = {}) {
+ const rows = await db.all(`SELECT m.id, m.message, m.ai_member_id, m.created_at, u.display_name, u.username
+ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id
+ WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id ${first ? 'ASC' : 'DESC'} LIMIT ?`, [roomId, limit]);
+ return first ? rows : rows.reverse();
+}
+
+async function firstMessage(roomId) {
+ return await db.get('SELECT message FROM room_messages WHERE room_id = ? AND ai_member_id IS NULL AND is_deleted = 0 ORDER BY id LIMIT 1', [roomId]);
+}
+
+async function touch(roomId, count) {
+ await db.run('UPDATE vibe_personas SET checked_count = ?, checked_at = ov_now() WHERE room_id = ?', [Math.max(0, Number(count) || 0), roomId]);
+}
+
+/** The conversation's name follows the persona until the person renames it (rooms.vibe_title_locked). */
+async function titleFromPersona(roomId, name, previousName = null) {
+ const room = await db.get('SELECT id, name, vibe_title_locked FROM rooms WHERE id = ?', [roomId]);
+ if (!room || Number(room.vibe_title_locked)) return;
+ const first = await firstMessage(roomId);
+ let vibe = null;
+ try { vibe = require('../rooms/vibe'); } catch { vibe = null; }
+ const auto = first && vibe ? vibe.titleOf(first.message) : (vibe ? vibe.DEFAULT_TITLE : null);
+ const current = String(room.name || '').trim();
+ // Ours to write: the auto title, the default, or the last persona's own name. Anything else is a rename
+ // from before this feature (no lock flag existed); a rename now sets the flag and returns above.
+ const ours = !current || current === auto || current === (vibe && vibe.DEFAULT_TITLE) || (previousName && current === previousName);
+ if (!ours) return;
+ await db.run('UPDATE rooms SET name = ? WHERE id = ? AND COALESCE(vibe_title_locked, 0) = 0', [name, roomId]);
+}
+
+async function savePersona(room, persona, { previous, imageUrl, count }) {
+ const madeAt = new Date().toISOString();
+ const generation = previous ? Number(previous.generation || 1) + 1 : 1;
+ await db.run(`INSERT INTO vibe_personas (room_id, name, glyph, hue, image_url, topic, made_at, checked_at, checked_count, generation)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ov_now(), ?, ?)
+ ON CONFLICT (room_id) DO UPDATE SET name = excluded.name, glyph = excluded.glyph, hue = excluded.hue,
+ image_url = excluded.image_url, topic = excluded.topic, made_at = excluded.made_at,
+ checked_at = excluded.checked_at, checked_count = excluded.checked_count, generation = excluded.generation`,
+ [room.id, persona.name, persona.glyph, persona.hue, imageUrl, persona.topic, madeAt, Math.max(0, Number(count) || 0), generation]);
+ await titleFromPersona(room.id, persona.name, previous && previous.name);
+ return await forRoom(room.id);
+}
+
+// ── generation ───────────────────────────────────────────────────────────────
+
+/**
+ * Make a persona, or remake one. `previous` is the row being replaced; `count` is the message count this
+ * generation read. Every call is idempotent under `key`; an invalid glyph or name is retried once and then
+ * replaced from the registry, so a persona is never stored with an unknown icon.
+ */
+async function generate(room, { previous = null, mode = 'first', count = 0, key = null } = {}) {
+ if (!settings.available() || !settings.value('enabled')) return null;
+ if (await capReached()) return null;
+ const rows = mode === 'first'
+ ? await recentRows(room.id, { limit: HISTORY_FOR_FIRST, first: true })
+ : await recentRows(room.id, { limit: HISTORY_FOR_CHECK });
+ if (!rows.length) return null;
+ const baseKey = key || `chat-persona-${room.id}-${mode}-${previous ? `${previous.generation}-${count}` : count || 'first'}`;
+ let answer = await askForPersona(room, rows, previous, baseKey);
+ let parsed = personaFrom(answer, `${room.id}|${count}`, previous);
+ if (!parsed || !parsed.glyph) {
+ // One retry with the failure named: a model can answer "None" for a glyph, or prose instead of JSON.
+ if (!(await capReached())) {
+ const retry = await callModel({
+ roomId: room.id, kind: 'text', model: await modelFor('chat'), key: `${baseKey}-retry`,
+ messages: [{ role: 'system', content: PERSONA_SYSTEM }, { role: 'user', content: `${personaUser(rows, previous)}\n\nYour previous answer was not usable (${!parsed ? 'not the JSON object' : `glyph "${parsed.glyph || ''}" is not in the list`}). Answer again with the JSON object only and a glyph from the list.` }],
+ maxTokens: 240, temperature: 0.2, timeoutMs: TEXT_CALL_MS,
+ });
+ parsed = personaFrom(retry, `${room.id}|${count}|retry`, previous) || parsed;
+ }
+ }
+ if (!parsed) return null;
+ const persona = {
+ name: parsed.name,
+ glyph: parsed.glyph || fallbackGlyph(`${parsed.name}|${parsed.topic}`),
+ hue: parsed.hue,
+ topic: parsed.topic,
+ };
+ let imageUrl = null;
+ if (settings.value('images')) imageUrl = await avatarFor(room, persona, baseKey);
+ // A remake with no new picture keeps the old one rather than dropping a face the chat already had.
+ if (!imageUrl && previous && previous.image_url) imageUrl = previous.image_url;
+ const finalCount = await countMessages(room.id);
+ const row = await savePersona(room, persona, { previous, imageUrl, count: finalCount });
+ return wire(row);
+}
+
+async function topicChanged(room, persona, count) {
+ if (await capReached()) return null;
+ const rows = await recentRows(room.id, { limit: HISTORY_FOR_CHECK });
+ const model = await modelFor('chat');
+ const answer = await callModel({
+ roomId: room.id, kind: 'text', model, key: `chat-persona-${room.id}-check-${count}`,
+ messages: [{ role: 'system', content: CHECK_SYSTEM }, { role: 'user', content: checkUser(rows, persona) }],
+ maxTokens: 4, temperature: 0, timeoutMs: TEXT_CALL_MS,
+ });
+ if (!answer) return null;
+ return yesOf(answer.text);
+}
+
+/**
+ * The engine's due check. Never called from a request path directly: afterPost() schedules it, and the admin
+ * remake awaits its own call. One per room at a time; every model call is idempotent under the room and the
+ * generation it reads.
+ */
+async function maybe(roomLike) {
+ if (!settings.available() || !settings.value('enabled')) return null;
+ const id = Number(roomLike && roomLike.id);
+ if (!Number.isFinite(id) || id <= 0) return null;
+ const room = await db.get("SELECT * FROM rooms WHERE id = ? AND assistant = 1 AND archived_at IS NULL", [id]);
+ if (!room || inFlight.has(id)) return null;
+ // Claim the room before the first await: two answers arriving together must make one persona, not two.
+ inFlight.add(id);
+ try {
+ const persona = await forRoom(id);
+ const count = await countMessages(id);
+ if (!persona && !(await hasAnswer(id))) return null;
+ if (persona) {
+ const every = clampInt(settings.value('check_every'), 2, 100, 8);
+ if (count - Number(persona.checked_count || 0) < every) return null;
+ }
+ if (await capReached()) return null;
+ if (!persona) return await generate(room, { mode: 'first', count });
+ const changed = await topicChanged(room, persona, count);
+ if (changed === true) {
+ try { return await generate(room, { previous: persona, mode: 'change', count }); }
+ finally { await touch(id, await countMessages(id)); }
+ }
+ // NO, or a check that could not answer: wait another interval instead of asking on every message.
+ await touch(id, count);
+ return null;
+ } finally { inFlight.delete(id); }
+}
+
+/** Called after Vibe answered: schedules the due check and returns at once, never blocking the reply. */
+function afterAnswer(room) {
+ const id = Number(room && room.id);
+ if (!Number.isFinite(id) || id <= 0) return;
+ setImmediate(() => {
+ maybe({ id }).catch((err) => console.warn(`[Vibe persona] room ${id}: ${err && err.message}`));
+ });
+}
+
+/** The admin's "remake one conversation": awaited, cap-respecting, one at a time. */
+async function remake(slug, ownerId) {
+ if (!settings.available()) fail(503, 'persona.unavailable', 'Persona settings are not available right now');
+ if (!settings.value('enabled')) fail(409, 'persona.disabled', 'Vibe personas are off; turn them on at /admin/persona first');
+ // Only the owner's own conversations: other people's Vibe chats are private (a persona's topic summarises them).
+ const room = await db.get('SELECT * FROM rooms WHERE slug = ? AND owner_id = ? AND assistant = 1 AND archived_at IS NULL', [String(slug || ''), Number(ownerId) || 0]);
+ if (!room) fail(404, 'persona.not_found', 'No such Vibe conversation');
+ if (inFlight.has(room.id)) fail(409, 'persona.busy', 'That conversation is already making a persona; try again in a moment');
+ if (await capReached()) fail(409, 'persona.cap', 'The monthly persona spend cap is reached; raise it on /admin/persona or wait for next month');
+ const count = await countMessages(room.id);
+ if (!(await hasAnswer(room.id))) fail(409, 'persona.no_answer', 'This conversation has no answer to name yet');
+ const previous = await forRoom(room.id);
+ inFlight.add(room.id);
+ try {
+ const made = await generate(room, { previous, mode: previous ? 'remake' : 'first', count, key: `chat-persona-${room.id}-remake-${Date.now()}` });
+ if (!made) fail(502, 'persona.failed', 'The model did not answer with a usable persona; try again');
+ return made;
+ } finally { inFlight.delete(room.id); }
+}
+
+// ── admin ────────────────────────────────────────────────────────────────────
+
+async function conversations({ limit = 60, ownerId = 0 } = {}) {
+ const rows = await db.all(`SELECT r.id, r.slug, r.name, r.last_message_at, r.created_at,
+ p.name AS persona_name, p.glyph, p.hue, p.image_url, p.topic, p.made_at,
+ COALESCE(u.display_name, u.username, 'person ' || r.owner_id) AS owner,
+ (SELECT COUNT(*) FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0) AS messages
+ FROM rooms r
+ LEFT JOIN vibe_personas p ON p.room_id = r.id
+ LEFT JOIN ctx_users u ON u.id = r.owner_id
+ WHERE r.assistant = 1 AND r.archived_at IS NULL AND r.owner_id = ?
+ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC, r.id DESC LIMIT ?`, [Number(ownerId) || 0, Math.min(200, Math.max(1, Number(limit) || 60))]);
+ return rows.map((r) => ({
+ slug: r.slug, name: r.name, owner: r.owner, messages: Number(r.messages) || 0,
+ last_message_at: iso(r.last_message_at || r.created_at),
+ persona: r.persona_name ? wire({ name: r.persona_name, glyph: r.glyph, hue: r.hue, image_url: r.image_url, topic: r.topic, made_at: r.made_at }) : null,
+ }));
+}
+
+function modelOptionsOf(list, kind) {
+ const out = [];
+ for (const model of list) {
+ if ((model.kind || 'chat') !== kind) continue;
+ const id = modelIdOf(model);
+ if (!id) continue;
+ const price = offerPrice(model);
+ out.push({ id, name: String(model.name || id), price });
+ }
+ out.sort((a, b) => {
+ const pa = a.price ? a.price.score : Number.POSITIVE_INFINITY;
+ const pb = b.price ? b.price.score : Number.POSITIVE_INFINITY;
+ return pa - pb || a.name.localeCompare(b.name);
+ });
+ const capped = out.slice(0, 60);
+ const configured = clean(settings.value(kind === 'image' ? 'image_model' : 'text_model'), 200);
+ if (configured && !capped.some((m) => m.id === configured)) {
+ const found = out.find((m) => m.id === configured);
+ if (found) capped.push(found);
+ }
+ return capped;
+}
+
+/** The settings, the month's spend (everyone's, no content) and the owner's OWN conversations. */
+async function state(ownerId = 0) {
+ const all = settings.all();
+ const month = monthKey();
+ const spent = await spendThisMonth();
+ const cap = Number(all.monthly_cap_usd);
+ let catalog = [];
+ try { catalog = await models(); } catch { catalog = []; }
+ return {
+ settings: all,
+ defaults: settings.DEFAULTS,
+ revision: settings.current() && settings.current().revision ? settings.current().revision() : null,
+ month,
+ spend: {
+ month, usd: Math.round(spent * 1e6) / 1e6, cap_usd: cap,
+ remaining_usd: Math.max(0, Math.round((cap - spent) * 1e6) / 1e6),
+ reached: !(cap > 0) || spent >= cap,
+ },
+ models: { text: modelOptionsOf(catalog, 'chat'), image: modelOptionsOf(catalog, 'image') },
+ conversations: await conversations({ limit: 60, ownerId }),
+ last_problem: Object.fromEntries(lastProblem),
+ };
+}
+
+const MODEL_RE = /^[a-z0-9][a-z0-9._/@:-]{1,255}$/i;
+
+async function update(input, { actor = null, reason = 'Vibe persona settings', ownerId = 0 } = {}) {
+ if (!settings.available()) fail(503, 'persona.unavailable', 'Persona settings are not available right now');
+ const b = input && typeof input === 'object' ? input : {};
+ const changes = {};
+ if (b.enabled !== undefined) {
+ if (typeof b.enabled !== 'boolean' && b.enabled !== 'true' && b.enabled !== 'false' && b.enabled !== 1 && b.enabled !== 0) fail(422, 'persona.enabled', 'enabled is true or false');
+ changes.enabled = b.enabled === true || b.enabled === 'true' || b.enabled === 1;
+ }
+ for (const [key, code] of [['text_model', 'persona.text_model'], ['image_model', 'persona.image_model']]) {
+ if (b[key] === undefined) continue;
+ const value = clean(b[key], 200);
+ if (value && !MODEL_RE.test(value)) fail(422, code, 'A model id is letters, digits, dots, dashes, underscores, slashes or colons');
+ changes[key] = value;
+ }
+ if (b.images !== undefined) {
+ if (typeof b.images !== 'boolean' && b.images !== 'true' && b.images !== 'false' && b.images !== 1 && b.images !== 0) fail(422, 'persona.images', 'images is true or false');
+ changes.images = b.images === true || b.images === 'true' || b.images === 1;
+ }
+ if (b.check_every !== undefined) {
+ const n = Number(b.check_every);
+ if (!Number.isFinite(n) || n < 2 || n > 100) fail(422, 'persona.check_every', 'Messages between topic checks is from 2 to 100');
+ changes.check_every = Math.round(n);
+ }
+ if (b.monthly_cap_usd !== undefined) {
+ const n = Number(b.monthly_cap_usd);
+ if (!Number.isFinite(n) || n < 0 || n > 1000) fail(422, 'persona.cap', 'The monthly cap is from 0 to 1000 US dollars');
+ changes.monthly_cap_usd = Math.round(n * 1e6) / 1e6;
+ }
+ if (!Object.keys(changes).length) return await state(ownerId);
+ try {
+ await settings.current().apply(changes, { merge: true, actor, reason: String(reason).slice(0, 300) });
+ } catch (err) {
+ if (err && err.status) fail(err.status, err.code || 'persona.settings', err.message);
+ throw err;
+ }
+ return await state(ownerId);
+}
+
+/** For tests: forget in-process locks and the catalogue cache. Never forgets the owner's stored settings. */
+function _reset() {
+ inFlight.clear();
+ catalogCache.at = 0;
+ catalogCache.value = null;
+ lastProblem.clear();
+}
+
+module.exports = {
+ PersonaError, GLYPH_NAMES, GLYPH_CHOICES, MAX_NAME, MAX_TOPIC,
+ wire, forRoom, forRooms, afterAnswer, maybe, remake, generate, state, update, conversations,
+ spendThisMonth, capReached, cheapestModel, modelFor, _reset,
+ // Read by tests that want to prove the prompts and validators without a network.
+ cleanName, cleanTopic, cleanHue, jsonOf, yesOf, iso,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * Owner settings for Vibe personas (the owner, 2026-11-12: each Vibe chat gets a name and a face made from
+ * what it is about, remade when the topic changes, on "the cheapest model possible, but configurable on the
+ * admin panel"). They live in the same revisioned configuration as Chat's other owner settings
+ * (openvibe-shared/config, namespace chat.persona): the admin page at /admin/persona reads and writes them,
+ * and Network's owner settings proxy can read the same namespace.
+ *
+ * No value here is a secret. The store keeps every revision with its actor, so a change to what Chat may
+ * spend on personas is auditable and can be rolled back like the voice keys.
+ *
+ * enabled personas are made at all (default on)
+ * text_model the catalogue model for names and topic checks; '' = Auto, the cheapest chat model
+ * images make an avatar picture as well (default off)
+ * image_model the catalogue model for the avatar; '' = Auto, the cheapest image model
+ * check_every messages between topic checks (2–100, default 8)
+ * monthly_cap_usd most Chat may spend on personas in a UTC month; 0 stops persona spending (default 5)
+ */
+const { createConfigStore } = require('openvibe-shared/config');
+
+const SERVICE = 'chat';
+const NAMESPACE = 'chat.persona';
+
+const DEFAULTS = Object.freeze({
+ enabled: true,
+ text_model: '',
+ images: false,
+ image_model: '',
+ check_every: 8,
+ monthly_cap_usd: 5,
+});
+
+const SCHEMA = Object.freeze({
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Vibe personas',
+ description: 'The names and faces of Vibe conversations, the models that make them and what they may cost.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ enabled: {
+ type: 'boolean', title: 'Names and faces for Vibe chats', default: true,
+ description: 'Make a name, a glyph and a colour for each Vibe conversation from what it is about; check the topic again every few messages.',
+ },
+ text_model: {
+ type: 'string', title: 'Text model', default: '', maxLength: 200,
+ description: 'The Exchange model that names a chat and decides whether its topic changed. Empty means Auto: the cheapest chat model in the catalogue, which is the default and what Chat picks when it is blank.',
+ },
+ images: {
+ type: 'boolean', title: 'Avatar pictures', default: false,
+ description: 'Also make a small avatar picture with an image model and store it in OpenVibe.Media. Off by default: it costs more than the name.',
+ },
+ image_model: {
+ type: 'string', title: 'Image model', default: '', maxLength: 200,
+ description: 'The Exchange model that draws the avatar. Empty means Auto: the cheapest image model in the catalogue.',
+ },
+ check_every: {
+ type: 'integer', title: 'Messages between topic checks', default: 8, minimum: 2, maximum: 100,
+ description: 'How many messages may arrive before Chat asks the cheap model whether the chat has moved to a new topic (2 to 100).',
+ },
+ monthly_cap_usd: {
+ type: 'number', title: 'Monthly spend cap (USD)', default: 5, minimum: 0, maximum: 1000,
+ description: 'The most Chat may spend on personas in a UTC month, read from the runs it made. 0 stops persona spending; the page shows what has been spent this month.',
+ },
+ },
+});
+
+let active = null;
+
+function read(store, key) {
+ const fallback = DEFAULTS[key];
+ let value;
+ try { value = store.get(key); } catch { value = undefined; }
+ if (value === undefined || value === null) return fallback;
+ if (typeof fallback === 'boolean') return value === true || value === 'true' || value === 1 || value === '1';
+ if (typeof fallback === 'number') { const n = Number(value); return Number.isFinite(n) ? n : fallback; }
+ return String(value);
+}
+
+/**
+ * Create the store and make it the one the persona engine reads. Resolves once the active revision is
+ * loaded; the caller (server/index.js) awaits it before the app mounts /internal/config.
+ */
+async function createPersonaSettings({ db, log = console } = {}) {
+ const store = await createConfigStore({ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, log });
+ active = store;
+ return {
+ service: SERVICE,
+ namespace: NAMESPACE,
+ schema: SCHEMA,
+ defaults: DEFAULTS,
+ store,
+ /** The owner's value, or the default when it is unset or cannot be read. */
+ value: (key) => read(store, key),
+ get: () => ({ ...DEFAULTS, ...Object.fromEntries(Object.keys(DEFAULTS).map((key) => [key, read(store, key)])) }),
+ /** The store's revision, for the page's "saved" line. */
+ revision: () => (store.revision ? store.revision() : null),
+ };
+}
+
+/** Installed by createPersonaSettings; null before it has loaded (the engine makes no personas then). */
+function current() { return active; }
+
+/** Whether the owner's settings are available at all. No store means no spending. */
+function available() { return !!active; }
+
+/** One setting, with the default when the store has not loaded. */
+function value(key) { return active ? read(active, key) : DEFAULTS[key]; }
+
+/** Every setting, with defaults. */
+function all() { return active ? Object.fromEntries(Object.keys(DEFAULTS).map((key) => [key, read(active, key)])) : { ...DEFAULTS }; }
+
+/** For tests: forget the installed store. */
+function _reset() { active = null; }
+
+module.exports = {
+ SERVICE, NAMESPACE, DEFAULTS, SCHEMA,
+ createPersonaSettings, current, available, value, all, _reset,
+};
@@ -527,7 +527,10 @@ async function afterPost(room, message, user, deps = {}) {
@@ -527,7 +527,10 @@ async function afterPost(room, message, user, deps = {}) {
if (vibe && !own) await require('./vibe').countReply(user);
if (vibe && !own) await require('./vibe').countReply(user);
// "How Vibe did this" says which model answered when it was the person's own (their key or their computer).
// "How Vibe did this" says which model answered when it was the person's own (their key or their computer).
if (vibe && own && steps && member.engine_label) steps.steps.unshift(`Answered with ${String(member.engine_label).slice(0, 160)}`);
if (vibe && own && steps && member.engine_label) steps.steps.unshift(`Answered with ${String(member.engine_label).slice(0, 160)}`);
- return await say(text, steps && steps.steps.length ? steps : null);
+ const posted = await say(text, steps && steps.steps.length ? steps : null);
+ // The persona is due-based and never blocks the answer: afterPost schedules it and returns.
+ if (vibe) { try { require('./persona').afterAnswer(room); } catch { /* a persona never breaks an answer */ } }
+ return posted;
} catch (err) {
} catch (err) {
console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);
console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);
return null;
return null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * Owner-only Vibe persona settings (the owner, 2026-11-12): GET|POST /api/admin/persona and
+ * POST /api/admin/persona/remake. The same store and actions as the page at /admin/persona
+ * (server/web/persona-admin.js), so a script or another agent does exactly what the owner does.
+ *
+ * GET /api/admin/persona { settings, defaults, spend, models, conversations, month, revision }
+ * POST /api/admin/persona { enabled?, text_model?, images?, image_model?, check_every?, monthly_cap_usd? }
+ * POST /api/admin/persona/remake { slug } → { persona }, 409 persona.busy / persona.cap / persona.no_answer
+ *
+ * Every route is for the owner's own sign-in (not an API token), and every route is owner-only: the same
+ * isOwner() rule the rest of Chat uses (an admin with Network's owner flag).
+ */
+const express = require('express');
+const { requireAuth } = require('../auth/auth');
+const { isOwner } = require('../auth/permissions');
+const { limits } = require('../net/actor-limits');
+const persona = require('../persona');
+
+const router = express.Router();
+const manageLimit = limits('chat.persona.manage', { minute: 30, hour: 300 });
+const readLimit = limits('chat.persona.read', { minute: 120, hour: 1200 });
+
+router.use(requireAuth);
+router.use((req, res, next) => {
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Vibe persona settings are for the owner\'s own sign-in', code: 'persona.person_only' });
+ if (!isOwner(req.user)) return res.status(403).json({ error: 'Only the owner changes the Vibe personas', code: 'persona.owner' });
+ next();
+});
+
+function send(res, err) {
+ if (err && err.status && err.code) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.error('[Vibe persona admin]', err && err.message);
+ return res.status(500).json({ error: 'Something went wrong', code: 'persona.error' });
+}
+
+router.get('/', readLimit, async (_req, res) => {
+ try { res.set('Cache-Control', 'private, no-store').json(await persona.state(req.user.id)); } catch (err) { send(res, err); }
+});
+
+router.post('/', manageLimit, async (req, res) => {
+ try {
+ const actor = req.user.subject_id ? { type: 'user', id: String(req.user.subject_id) } : null;
+ res.set('Cache-Control', 'private, no-store').json(await persona.update(req.body || {}, { actor, reason: 'Vibe persona settings', ownerId: req.user.id }));
+ } catch (err) { send(res, err); }
+});
+
+router.post('/remake', manageLimit, async (req, res) => {
+ try {
+ const slug = String((req.body && req.body.slug) || '');
+ const made = await persona.remake(slug, req.user.id);
+ res.set('Cache-Control', 'private, no-store').json({ persona: made, conversations: (await persona.state(req.user.id)).conversations });
+ } catch (err) { send(res, err); }
+});
+
+module.exports = router;
@@ -9,6 +9,10 @@
@@ -9,6 +9,10 @@
* { chat: null } before the first. after = a message id: only what came after it
* { chat: null } before the first. after = a message id: only what came after it
* (the Dock polls this for Vibe's answer). A Vibe message carries what Vibe really
* (the Dock polls this for Vibe's answer). A Vibe message carries what Vibe really
* did (results: the reminders and timers it set or stopped), absent when none.
* did (results: the reminders and timers it set or stopped), absent when none.
+ * chat.persona is the chat's persona (server/persona/index.js), or null until it
+ * is made: { name, glyph, hue, image_url|null, topic, made_at }.
+ * GET /api/vibe/conversations { conversations: [{ slug, name, last_message_at, created_at, persona, … }], count }:
+ * every item carries the same persona shape, so the Vibe panel and the Dock draw one.
* POST /api/vibe/undo { message_id, index }
* POST /api/vibe/undo { message_id, index }
* undo one of those results: the reminder is stopped, the timer too, and the
* undo one of those results: the reminder is stopped, the timer too, and the
* result is marked undone. The person themself, in their own chat, within a day.
* result is marked undone. The person themself, in their own chat, within a day.
@@ -27,6 +31,7 @@ const { requireAuth } = require('../auth/auth');
@@ -27,6 +31,7 @@ const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
const vibe = require('./vibe');
const vibe = require('./vibe');
+const persona = require('../persona');
const vibeResults = require('./vibe-results');
const vibeResults = require('./vibe-results');
const aiClient = require('../ai/client');
const aiClient = require('../ai/client');
const compare = require('./vibe-compare');
const compare = require('./vibe-compare');
@@ -87,7 +92,7 @@ router.get('/chat', personOnly, async (req, res) => {
@@ -87,7 +92,7 @@ router.get('/chat', personOnly, async (req, res) => {
const after = Number(req.query.after) || null;
const after = Number(req.query.after) || null;
const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });
const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });
const by = await compare.answers(room, rows.filter((m) => !m.ai_member_id).map((m) => Number(m.id)));
const by = await compare.answers(room, rows.filter((m) => !m.ai_member_id).map((m) => Number(m.id)));
- return res.set('Cache-Control', 'private, no-store').json({ chat: { ...chatOf(room), ...(await compare.settings(room)) },
+ return res.set('Cache-Control', 'private, no-store').json({ chat: { ...chatOf(room), persona: persona.wire(await persona.forRoom(room.id)), ...(await compare.settings(room)) },
messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });
messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });
});
});
@@ -36,6 +36,7 @@ const config = require('../config');
@@ -36,6 +36,7 @@ const config = require('../config');
const wordFilter = require('../chat/word-filter');
const wordFilter = require('../chat/word-filter');
const rooms = require('./rooms');
const rooms = require('./rooms');
const credentials = require('../ai/credentials');
const credentials = require('../ai/credentials');
+const persona = require('../persona');
const VIBE = { name: 'Vibe', handle: 'vibe' };
const VIBE = { name: 'Vibe', handle: 'vibe' };
const MAX_CHATS = 500;
const MAX_CHATS = 500;
@@ -162,7 +163,10 @@ async function work(room, user, text, mode) {
@@ -162,7 +163,10 @@ async function work(room, user, text, mode) {
return { message, task: null };
return { message, task: null };
}
}
// A task Actor refused at once was already answered in the chat (workspace.announce) with Actor's reason.
// A task Actor refused at once was already answered in the chat (workspace.announce) with Actor's reason.
- if (task.status !== 'failed' && !task.actor_task) await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);
+ if (task.status !== 'failed' && !task.actor_task) {
+ const said = await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);
+ if (said) { try { persona.afterAnswer(room); } catch { /* a persona never breaks the answer */ } }
+ }
return { message, task };
return { message, task };
}
}
@@ -183,14 +187,16 @@ async function startWork(user, text, mode) {
@@ -183,14 +187,16 @@ async function startWork(user, text, mode) {
async function list(user, { limit = 100 } = {}) {
async function list(user, { limit = 100 } = {}) {
if (!user) return [];
if (!user) return [];
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
- const rows = await db.all(`SELECT r.slug, r.name, r.last_message_at, r.created_at, r.vibe_origin_site,
+ const rows = await db.all(`SELECT r.id, r.slug, r.name, r.last_message_at, r.created_at, r.vibe_origin_site,
(SELECT m.message FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT 1) AS last_line,
(SELECT m.message FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT 1) AS last_line,
(SELECT m.created_at FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.ai_member_id IS NOT NULL ORDER BY m.id DESC LIMIT 1) AS last_answer_at
(SELECT m.created_at FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.ai_member_id IS NOT NULL ORDER BY m.id DESC LIMIT 1) AS last_answer_at
FROM rooms r
FROM rooms r
WHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL ORDER BY COALESCE(r.last_message_at, r.created_at) DESC LIMIT ?`, [user.id, lim]);
WHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL ORDER BY COALESCE(r.last_message_at, r.created_at) DESC LIMIT ?`, [user.id, lim]);
+ const personas = await persona.forRooms(rows.map((r) => r.id));
return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at,
return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at,
last_line: r.last_line ? String(r.last_line).replace(/\s+/g, ' ').slice(0, 160) : null,
last_line: r.last_line ? String(r.last_line).replace(/\s+/g, ' ').slice(0, 160) : null,
- last_answer_at: r.last_answer_at || null, site: r.vibe_origin_site || null }));
+ last_answer_at: r.last_answer_at || null, site: r.vibe_origin_site || null,
+ persona: persona.wire(personas.get(Number(r.id))) }));
}
}
async function count(user) {
async function count(user) {
@@ -239,7 +245,8 @@ async function rename(room, user, name) {
@@ -239,7 +245,8 @@ async function rename(room, user, name) {
if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');
if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');
const n = String(name || '').replace(/\s+/g, ' ').trim();
const n = String(name || '').replace(/\s+/g, ' ').trim();
if (n.length < 1 || n.length > TITLE_MAX) fail(422, 'vibe.name', `A chat's name is 1 to ${TITLE_MAX} characters`);
if (n.length < 1 || n.length > TITLE_MAX) fail(422, 'vibe.name', `A chat's name is 1 to ${TITLE_MAX} characters`);
- await db.run('UPDATE rooms SET name = ? WHERE id = ?', [n, room.id]);
+ // Their name stays theirs: a persona made later never writes over it (server/persona/index.js).
+ await db.run('UPDATE rooms SET name = ?, vibe_title_locked = 1 WHERE id = ?', [n, room.id]);
return await rooms.publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]), { role: 'owner' });
return await rooms.publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]), { role: 'owner' });
}
}
@@ -1533,6 +1533,7 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
@@ -1533,6 +1533,7 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
// ── What shipped, robots, sitemap ──
// ── What shipped, robots, sitemap ──
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
+ require('./persona-admin').mountPersonaAdmin(router, { page, needUser, sameSite, formLimit, notice });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
require('./room-embed-pages').mountRoomEmbedPages(router, { viewerOf, sameSite, config });
require('./room-embed-pages').mountRoomEmbedPages(router, { viewerOf, sameSite, config });
require('./developer-pages').mountDeveloperPages(router, { page, config });
require('./developer-pages').mountDeveloperPages(router, { page, config });
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+/**
+ * /admin/persona — the owner's page for Vibe personas (the owner, 2026-11-12: "the cheapest model possible, but
+ * configurable on the admin panel … make it actually intelligent"). It is Chat's own owner panel because the
+ * actions are Chat's (which model, when to re-check, how much a month, remake one chat), not a service key;
+ * Network's owner settings proxy still reaches the same `chat.persona` namespace through /internal/config.
+ *
+ * GET /admin/persona the settings, what this month has cost, the model choices and the chats
+ * POST /admin/persona save the settings (form)
+ * POST /admin/persona/remake { slug } remake one conversation's persona (form)
+ *
+ * Owner only: an admin with Network's owner flag. Every model call is Chat's own and billed to Chat; the cap is
+ * read from the runs' usage, not counted by attempts.
+ */
+const persona = require('../persona');
+const { isOwner } = require('../auth/permissions');
+const { esc } = require('./layout');
+const ui = require('./ui');
+
+const usd = (n) => {
+ const v = Number(n) || 0;
+ const s = v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
+ return `$${s || '0'}`;
+};
+const priceLabel = (p) => (p ? `$${Number(p.in).toFixed(2)} in / $${Number(p.out).toFixed(2)} out per M` : 'price unknown');
+const glyph = (p) => (p ? `${esc(p.glyph)} · hue ${Number(p.hue)}` : 'not made yet');
+
+function modelSelect(name, current, options, autoLabel) {
+ const known = options.slice();
+ if (current && !known.some((m) => m.id === current)) known.unshift({ id: current, name: current, price: null });
+ return `<label class="oc-set-row"><span class="oc-set-text">${name}<small>${esc(autoLabel)}</small></span>
+<select name="${name === 'Text model' ? 'text_model' : 'image_model'}">
+<option value=""${current ? '' : ' selected'}>Auto — the cheapest in the catalogue</option>
+${known.map((m) => `<option value="${esc(m.id)}"${m.id === current ? ' selected' : ''}>${esc(m.name)} · ${esc(priceLabel(m.price))}</option>`).join('')}
+</select></label>`;
+}
+
+function conversationRows(rows) {
+ if (!rows.length) return '<p class="oc-muted">No Vibe conversations yet.</p>';
+ return `<ul class="oc-list">${rows.map((r) => `<li class="oc-row">
+<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.persona ? r.persona.name : r.name || 'Chat with Vibe')}</strong> <span class="oc-muted">${esc(r.slug)}</span></span>
+<span class="oc-row-sub">${r.persona ? `${glyph(r.persona)} · ${esc(r.persona.topic)}` : 'no persona yet'} · ${Number(r.messages)} messages</span></span>
+<form class="oc-inline" method="post" action="/admin/persona/remake"><input type="hidden" name="slug" value="${esc(r.slug)}"><button class="oc-link" type="submit">Remake</button></form>
+</li>`).join('')}</ul>`;
+}
+
+function pageBody(state, message) {
+ const s = state.settings;
+ const spend = state.spend;
+ const capLine = spend.reached
+ ? `<p class="oc-notice oc-error" role="status">The monthly cap is reached (${usd(spend.usd)} of ${usd(spend.cap_usd)}). Existing personas stay; new ones wait for next month or a higher cap.</p>`
+ : `<p class="oc-muted">Spent <strong>${usd(spend.usd)}</strong> of <strong>${usd(spend.cap_usd)}</strong> this month (${esc(spend.month)} UTC), ${usd(spend.remaining_usd)} left. A persona is named by the cheapest chat model and the cap is read from each run's own usage.</p>`;
+ const revision = state.revision ? ` · revision ${Number(state.revision)}` : '';
+ return `${message}${ui.head({ title: 'Vibe personas', sub: 'Names and faces for Vibe chats, made from what each chat is about' })}
+<section class="oc-set" aria-labelledby="persona-spend"><h2 id="persona-spend">This month</h2>${capLine}${revision ? `<p class="oc-muted">Owner settings${esc(revision)}.</p>` : ''}</section>
+<section class="oc-set" aria-labelledby="persona-settings"><h2 id="persona-settings">Settings</h2>
+<form class="oc-form" method="post" action="/admin/persona">
+<label class="oc-set-row"><span class="oc-set-text">Names and faces for Vibe chats<small>Off: no model is called and no persona is made or checked.</small></span><input type="checkbox" class="oc-switch" name="enabled" value="1"${s.enabled ? ' checked' : ''}></label>
+${modelSelect('Text model', s.text_model, state.models.text, 'The cheapest chat model is used automatically when this is Auto.')}
+<label class="oc-set-row"><span class="oc-set-text">Avatar pictures<small>Off by default: an image costs more than the name. Pictures go to OpenVibe.Media.</small></span><input type="checkbox" class="oc-switch" name="images" value="1"${s.images ? ' checked' : ''}></label>
+${modelSelect('Image model', s.image_model, state.models.image, 'The cheapest image model is used automatically when this is Auto and pictures are on.')}
+<label>Messages between topic checks <input type="number" name="check_every" min="2" max="100" step="1" value="${esc(s.check_every)}" required></label>
+<label>Monthly spend cap (USD) <input type="number" name="monthly_cap_usd" min="0" max="1000" step="0.01" value="${esc(s.monthly_cap_usd)}" required></label>
+<p class="oc-muted">The name is also the conversation's title until the person renames it. A chat is checked after its first answer and then every ${esc(s.check_every)} messages; a changed topic makes a new name and face.</p>
+<p><button class="oc-button" type="submit">Save</button></p>
+</form></section>
+<section class="oc-set" aria-labelledby="persona-chats"><h2 id="persona-chats">Conversations</h2>
+<p class="oc-muted">Your newest ${state.conversations.length} Vibe conversations (other people's are private: only the spend above counts them). Remake asks the model now, counts against the cap, and keeps the person's own title if they renamed the chat.</p>
+${conversationRows(state.conversations)}</section>`;
+}
+
+function mountPersonaAdmin(router, { page, needUser, sameSite, formLimit, notice }) {
+ async function owner(req, res) {
+ const actor = await needUser(req, res);
+ if (!actor) return null;
+ if (!isOwner(actor.user)) {
+ await page(req, res, 403, {
+ actor, title: 'Vibe personas', path: '/admin/persona', robots: 'noindex, nofollow', footer: 'compact',
+ body: `<h1>Only the owner</h1><p>This page changes what Chat may spend on Vibe personas. It is for OpenVibe's owner.</p><p><a href="/vibe">Back to Vibe</a></p>`,
+ });
+ return null;
+ }
+ return actor;
+ }
+
+ router.get('/admin/persona', async (req, res) => {
+ const actor = await owner(req, res); if (!actor) return;
+ let state;
+ try { state = await persona.state(actor.user.id); } catch (err) {
+ console.warn('[Vibe persona admin page]', err && err.message);
+ return await page(req, res, 503, {
+ actor, title: 'Vibe personas', path: '/admin/persona', robots: 'noindex, nofollow', footer: 'compact',
+ body: '<h1>Vibe personas</h1><p>The persona settings are not available right now. The Chat log says why.</p>',
+ });
+ }
+ const saved = req.query.remade ? notice('ok', 'Persona remade.')
+ : req.query.saved ? notice('ok', `Saved${state.revision ? ` (revision ${state.revision})` : ''}.`)
+ : req.query.error ? notice('error', String(req.query.error).slice(0, 240)) : '';
+ await page(req, res, 200, {
+ actor, title: 'Vibe personas', path: '/admin/persona', active: 'settings', robots: 'noindex, nofollow', shell: false, footer: 'compact',
+ body: pageBody(state, saved),
+ });
+ });
+
+ router.post('/admin/persona', formLimit, sameSite, async (req, res) => {
+ const actor = await owner(req, res); if (!actor) return;
+ const b = req.body || {};
+ try {
+ await persona.update({
+ enabled: b.enabled === '1',
+ images: b.images === '1',
+ text_model: b.text_model == null ? undefined : String(b.text_model).slice(0, 200),
+ image_model: b.image_model == null ? undefined : String(b.image_model).slice(0, 200),
+ check_every: b.check_every,
+ monthly_cap_usd: b.monthly_cap_usd,
+ }, { actor: actor.user.subject_id ? { type: 'user', id: String(actor.user.subject_id) } : null, reason: 'Vibe persona settings (owner page)' });
+ res.redirect(303, '/admin/persona?saved=1#persona-settings');
+ } catch (err) {
+ const message = err && err.status ? err.message : 'The settings could not be saved right now';
+ res.redirect(303, `/admin/persona?error=${encodeURIComponent(message)}#persona-settings`);
+ }
+ });
+
+ router.post('/admin/persona/remake', formLimit, sameSite, async (req, res) => {
+ const actor = await owner(req, res); if (!actor) return;
+ try {
+ await persona.remake(String((req.body && req.body.slug) || ''), actor.user.id);
+ res.redirect(303, '/admin/persona?remade=1#persona-chats');
+ } catch (err) {
+ const message = err && err.status ? err.message : 'That conversation could not be remade right now';
+ res.redirect(303, `/admin/persona?error=${encodeURIComponent(message)}#persona-chats`);
+ }
+ });
+}
+
+module.exports = { mountPersonaAdmin, pageBody, modelSelect, conversationRows, usd, priceLabel };
@@ -142,7 +142,7 @@ t('removing the panel token falls back to the environment; the hook is guarded',
@@ -142,7 +142,7 @@ t('removing the panel token falls back to the environment; the hook is guarded',
const denied = await h.http('POST', `/internal/config/${NS}/test/telegram.bot_token`, { token: h.serviceToken(['chat.stats.read']) });
const denied = await h.http('POST', `/internal/config/${NS}/test/telegram.bot_token`, { token: h.serviceToken(['chat.stats.read']) });
assert.strictEqual(denied.status, 403);
assert.strictEqual(denied.status, 403);
const voices = await cfg('GET', '/internal/config');
const voices = await cfg('GET', '/internal/config');
- assert.deepStrictEqual(voices.body.namespaces.map((n) => n.namespace), ['chat.voices', 'chat.apps'], 'both namespaces on one route');
+ assert.deepStrictEqual(voices.body.namespaces.map((n) => n.namespace), ['chat.voices', 'chat.apps', 'chat.persona'], 'every namespace on one route (2026-11-12: chat.persona)');
});
});
t('no log or console line carries a token', async () => {
t('no log or console line carries a token', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+{
+ "persona": {
+ "first": {
+ "name": "Keyboard Scout",
+ "glyph": "keyboard",
+ "hue": 210,
+ "topic": "mechanical keyboards and switches"
+ },
+ "changed": {
+ "name": "Stream Doctor",
+ "glyph": "video",
+ "hue": 12,
+ "topic": "live streaming setups and OBS"
+ },
+ "invalid_glyph": {
+ "name": "Ghost Guide",
+ "glyph": "not-a-real-glyph",
+ "hue": 400,
+ "topic": "haunted houses and ghost stories"
+ },
+ "remade": {
+ "name": "Desk Mechanic",
+ "glyph": "wrench",
+ "hue": 28,
+ "topic": "fixing a squeaky desk and chair"
+ }
+ },
+ "checks": {
+ "no_change": "NO",
+ "changed": "YES"
+ },
+ "image": {
+ "b64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2ZQAAAABJRU5ErkJggg==",
+ "url": null
+ }
+}
@@ -23,6 +23,7 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||
@@ -23,6 +23,7 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||
// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.
// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.
const TABLES = [
const TABLES = [
'vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import
'vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import
+ 'vibe_personas', 'vibe_persona_spend', // 0078: Vibe personas
'chat_messages', 'person_chat_memory', 'person_chat_memory_settings', 'person_chat_memory_spend',
'chat_messages', 'person_chat_memory', 'person_chat_memory_settings', 'person_chat_memory_spend',
'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
@@ -114,6 +114,7 @@ t('DNS rebinding: public at the check, loopback at the download — refused wher
@@ -114,6 +114,7 @@ t('DNS rebinding: public at the check, loopback at the download — refused wher
t('ratchet: every file that makes an outbound request itself is reviewed', () => {
t('ratchet: every file that makes an outbound request itself is reviewed', () => {
const REVIEWED = {
const REVIEWED = {
'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',
'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',
+ 'server/persona/index.js': 'a Vibe persona\'s picture address from the image provider (not a person\'s): https only, every hop through openvibe-shared/egress (assertPublicUrl and safeLookup), two redirects at most, 20 s, 2 MB',
'server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export',
'server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export',
'server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote',
'server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote',
'server/rooms/community-forum.js': 'Community API (configured internal URL), fixed space/thread endpoints; slugs and ids are validated and request paths are not user URLs',
'server/rooms/community-forum.js': 'Community API (configured internal URL), fixed space/thread endpoints; slugs and ids are validated and request paths are not user URLs',
new file mode 100644
new file mode 100644
@@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
+'use strict';
+/**
+ * Vibe personas (the owner, 2026-11-12): every Vibe conversation is named and faced from what it is about,
+ * remade when its topic changes, on Chat's cheapest Exchange model, under the owner's monthly cap.
+ *
+ * The model answers here are recorded fixtures (test/fixtures/vibe-persona/recorded.json), fed through
+ * aiClient._setClient(); the catalogue is injected too, so no test touches the network or a provider. The
+ * Media upload is a stand-in. The behaviour under test is ours: when a persona is due, how a changed topic
+ * is decided, what happens to an invalid glyph, the cap, the API shape and the owner-only panel.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+const glyphs = require('openvibe-shared/glyphs');
+const FIX = require('./fixtures/vibe-persona/recorded.json');
+
+const t = suite('vibe personas');
+let h, owner, ann, aiCalls, personaQueue, checkQueue, uploads, aiClient, media, persona;
+
+const systemOf = (call) => String(((call.input && call.input.messages) || []).find((m) => m.role === 'system')?.content || '');
+const isPersonaCall = (call) => call.workflow === 'ai.complete' && /You name a private chat/.test(systemOf(call));
+const isCheckCall = (call) => call.workflow === 'ai.complete' && /You decide whether/.test(systemOf(call));
+const personaCalls = () => aiCalls.filter(isPersonaCall);
+const checkCalls = () => aiCalls.filter(isCheckCall);
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+async function until(fn, what, { tries = 120, ms = 50 } = {}) {
+ for (let i = 0; i < tries; i++) {
+ const value = await fn();
+ if (value) return value;
+ await sleep(ms);
+ }
+ throw new Error(`Timed out waiting for ${what}`);
+}
+const chatOf = async (slug, u = ann) => (await h.http('GET', `/api/vibe/chat?slug=${encodeURIComponent(slug)}`, { token: u.token })).body;
+const waitAnswer = async (slug, n = 1) => until(async () => {
+ const r = await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token });
+ return (r.body.messages || []).filter((m) => m.ai_member_id).length >= n;
+}, `an answer in ${slug}`);
+const waitPersona = async (slug, pred = (p) => !!p) => until(async () => {
+ const chat = await chatOf(slug);
+ return chat.chat && pred(chat.chat.persona) ? chat.chat.persona : null;
+}, `a persona in ${slug}`);
+const ask = async (message, slug = null, u = ann) => {
+ const r = await h.http('POST', '/api/vibe/ask', { token: u.token, body: { message, ...(slug ? { slug } : { fresh: true }) } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.body.chat.slug;
+};
+const save = (body, u = owner) => h.http('POST', '/api/admin/persona', { token: u.token, body });
+const clearSpend = async () => { await h.db.getDb().prepare('DELETE FROM vibe_persona_spend').run(); };
+const resetAnswers = ({ personas = [], checks = [] } = {}) => {
+ personaQueue = personas.slice();
+ checkQueue = checks.slice();
+};
+
+t('boot', async () => {
+ h = await boot();
+ aiClient = require('../server/ai/client');
+ media = require('../server/media/client');
+ persona = require('../server/persona');
+ owner = h.addUser('owner', { role: 'admin', is_owner: 1, subject: ids.newId('user') });
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ for (const u of [owner, ann]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ persona._reset();
+ aiCalls = [];
+ uploads = [];
+ resetAnswers();
+ aiClient._setCatalog(async () => ({ models: [
+ { slug: 'cheap/text', name: 'Cheap Text', kind: 'chat', offers: [{ price: { in: 0.01, out: 0.02 } }] },
+ { slug: 'pricey/text', name: 'Pricey Text', kind: 'chat', offers: [{ price: { in: 10, out: 20 } }] },
+ { slug: 'cheap/image', name: 'Cheap Image', kind: 'image', offers: [{ price: { in: 0.1, out: 0.2 } }] },
+ ] }));
+ aiClient._setClient({ runs: { create: async (workflow, input) => {
+ aiCalls.push({ workflow, input });
+ if (workflow === 'chat.persona.image') {
+ return { run: { id: `img_${aiCalls.length}`, status: 'succeeded', output: { images: [{ b64: FIX.image.b64, url: null }], count: 1 }, usage: { cost_usd: 0.02 } } };
+ }
+ if (workflow !== 'ai.complete') return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'ok' } }] } } };
+ let content;
+ if (/You decide whether/.test(systemOf({ input }))) content = checkQueue.shift() || FIX.checks.no_change;
+ else content = JSON.stringify(personaQueue.shift() || FIX.persona.first);
+ return { run: { id: `run_${aiCalls.length}`, status: 'succeeded', output: { choices: [{ message: { content } }] }, usage: { cost_usd: 0.01 } } };
+ } } });
+ media._setClient({ upload: async (buffer, opts) => { uploads.push({ buffer, opts }); return { id: 'med_persona', public_url: 'https://media.openvibe.test/vibe-persona.png' }; } });
+});
+
+t('the first answer makes a persona, the chat takes its name, and both APIs carry the shape', async () => {
+ await clearSpend();
+ assert.strictEqual((await save({ enabled: true, text_model: '', images: false, check_every: 8, monthly_cap_usd: 5 })).status, 200);
+ resetAnswers({ personas: [FIX.persona.first] });
+ const slug = await ask('I want a quiet mechanical keyboard with hot-swap switches and a nice thock');
+ await waitAnswer(slug);
+ const p = await waitPersona(slug);
+ assert.strictEqual(p.name, 'Keyboard Scout');
+ assert.strictEqual(p.glyph, 'keyboard');
+ assert.strictEqual(p.hue, 210);
+ assert.strictEqual(p.topic, 'mechanical keyboards and switches');
+ assert.strictEqual(p.image_url, null);
+ assert.ok(Number.isFinite(Date.parse(p.made_at)), p.made_at);
+ const chat = await chatOf(slug);
+ assert.deepStrictEqual(chat.chat.persona, p, 'GET /api/vibe/chat carries the same shape');
+ assert.strictEqual(chat.chat.name, 'Keyboard Scout', 'the persona names the conversation');
+ const list = await h.http('GET', '/api/vibe/conversations', { token: ann.token });
+ const item = list.body.conversations.find((c) => c.slug === slug);
+ assert.deepStrictEqual(item.persona, p, 'every item of /api/vibe/conversations carries the shape');
+ assert.strictEqual(aiCalls.filter(isPersonaCall)[0].input.model, 'cheap/text', 'Auto picks the cheapest chat model');
+});
+
+t('a topic check that says NO keeps the same persona and waits the interval', async () => {
+ await clearSpend();
+ assert.strictEqual((await save({ check_every: 2 })).status, 200);
+ resetAnswers({ personas: [FIX.persona.first], checks: [FIX.checks.no_change] });
+ const slug = await ask('Help me pick a keyboard switch that is not too loud');
+ const first = await waitPersona(slug);
+ await ask('What about keycaps that fit it?', slug);
+ await until(async () => checkCalls().length >= 1, 'the topic check');
+ await sleep(120);
+ const same = await waitPersona(slug);
+ assert.deepStrictEqual(same, first, 'NO leaves the made_at and every field alone');
+ assert.strictEqual(same.name, 'Keyboard Scout');
+});
+
+t('a changed topic makes a new persona, and the old name and face go', async () => {
+ await clearSpend();
+ assert.strictEqual((await save({ check_every: 2 })).status, 200);
+ resetAnswers({ personas: [FIX.persona.first, FIX.persona.changed], checks: [FIX.checks.changed] });
+ const slug = await ask('My mechanical keyboard sounds hollow; what foam fits?');
+ const first = await waitPersona(slug);
+ await ask('Now I want to start a live stream with OBS and three cameras; where do I begin?', slug);
+ const changed = await waitPersona(slug, (p) => p && p.name !== first.name);
+ assert.strictEqual(changed.name, 'Stream Doctor');
+ assert.strictEqual(changed.glyph, 'video');
+ assert.strictEqual(changed.topic, 'live streaming setups and OBS');
+ assert.strictEqual((await chatOf(slug)).chat.name, 'Stream Doctor');
+});
+
+t('an invalid glyph is retried and then replaced from the shared registry, never stored', async () => {
+ await clearSpend();
+ assert.strictEqual((await save({ check_every: 100 })).status, 200);
+ resetAnswers({ personas: [FIX.persona.invalid_glyph, FIX.persona.invalid_glyph] });
+ const slug = await ask('Tell me a ghost story about a house that hates doors');
+ const p = await waitPersona(slug);
+ await until(async () => personaCalls().length >= 2, 'the one retry');
+ assert.strictEqual(p.name, 'Ghost Guide');
+ assert.strictEqual(p.hue, 40, '400 wraps to 40');
+ assert.ok(glyphs.NAMES.includes(p.glyph), `${p.glyph} is a glyph the registry names`);
+ assert.ok(p.topic.includes('haunted') || p.topic.includes('ghost'), p.topic);
+});
+
+t('the monthly cap stops new personas: a run that reached it makes no call', async () => {
+ await clearSpend();
+ assert.strictEqual((await save({ check_every: 100, monthly_cap_usd: 0.001 })).status, 200);
+ resetAnswers({ personas: [FIX.persona.first] });
+ const first = await ask('What is a good first mechanical keyboard under a hundred dollars?');
+ await waitAnswer(first);
+ const made = await waitPersona(first);
+ assert.strictEqual(made.name, 'Keyboard Scout');
+ const spent = (await h.http('GET', '/api/admin/persona', { token: owner.token })).body.spend;
+ assert.ok(spent.usd >= 0.01, `the run's own cost is recorded (${spent.usd})`);
+ assert.strictEqual(spent.reached, true);
+ const callsBefore = aiCalls.length;
+ const second = await ask('What about a keyboard for a small desk?');
+ await waitAnswer(second);
+ await sleep(250);
+ assert.strictEqual((await chatOf(second)).chat.persona, null, 'a chat past the cap gets no persona');
+ assert.strictEqual(aiCalls.length, callsBefore, 'and no model call is made for it');
+});
+
+t('images on makes an avatar through the image model and stores it in Media', async () => {
+ await clearSpend();
+ assert.strictEqual((await save({ images: true, image_model: '', check_every: 100, monthly_cap_usd: 5 })).status, 200);
+ resetAnswers({ personas: [FIX.persona.first] });
+ const slug = await ask('Which keyboard plate material gives the best sound?');
+ const p = await waitPersona(slug);
+ assert.strictEqual(p.image_url, 'https://media.openvibe.test/vibe-persona.png');
+ assert.strictEqual(uploads.length, 1);
+ assert.ok(uploads[0].buffer.length > 8);
+ const imageCall = aiCalls.find((c) => c.workflow === 'chat.persona.image');
+ assert.strictEqual(imageCall.input.size, '256x256');
+ assert.strictEqual(imageCall.input.model, 'cheap/image', 'Auto asks for the cheapest image model');
+ assert.strictEqual((await save({ images: false })).status, 200);
+});
+
+t('only the owner reads or changes the panel; it lists and remakes only their own chats, never losing a title they chose', async () => {
+ assert.strictEqual((await h.http('GET', '/api/admin/persona', { token: ann.token })).status, 403);
+ assert.strictEqual((await save({ enabled: false }, ann)).status, 403);
+ const state = await h.http('GET', '/api/admin/persona', { token: owner.token });
+ assert.strictEqual(state.status, 200, state.text);
+ assert.strictEqual(state.body.settings.enabled, true);
+ assert.ok(state.body.spend && Number.isFinite(state.body.spend.usd));
+ assert.ok(Array.isArray(state.body.conversations));
+ assert.ok(state.body.models.text.some((m) => m.id === 'cheap/text'));
+ assert.ok(state.body.models.image.some((m) => m.id === 'cheap/image'));
+
+ const ownerPage = await h.http('GET', '/admin/persona', { token: owner.token });
+ assert.strictEqual(ownerPage.status, 200);
+ assert.match(ownerPage.text, /Vibe personas/);
+ const annPage = await h.http('GET', '/admin/persona', { token: ann.token });
+ assert.strictEqual(annPage.status, 403);
+ assert.match(annPage.text, /Only the owner/);
+
+ // Another person's Vibe chats are private: a persona's topic summarises them, so the panel never lists them and
+ // the owner cannot remake one.
+ const theirs = await ask('Help me plan a surprise party for my sister');
+ await waitPersona(theirs);
+ const listed = (await h.http('GET', '/api/admin/persona', { token: owner.token })).body.conversations.map((c) => c.slug);
+ assert.ok(!listed.includes(theirs), 'someone else\'s chat is not on the owner\'s panel');
+ const refused = await h.http('POST', '/api/admin/persona/remake', { token: owner.token, body: { slug: theirs } });
+ assert.strictEqual(refused.status, 404, 'and it cannot be remade from there');
+
+ const slug = await ask('I need to fix a squeaky desk; what tools should I get?', null, owner);
+ await waitPersona(slug, (p) => !!p);
+ assert.ok((await h.http('GET', '/api/admin/persona', { token: owner.token })).body.conversations.some((c) => c.slug === slug), 'the owner\'s own chat is listed');
+ const renamed = await h.http('PATCH', `/api/chat/vibe/${slug}`, { token: owner.token, body: { name: 'My Own Desk Chat' } });
+ assert.strictEqual(renamed.status, 200, renamed.text);
+ resetAnswers({ personas: [FIX.persona.remade] });
+ const made = await h.http('POST', '/api/admin/persona/remake', { token: owner.token, body: { slug } });
+ assert.strictEqual(made.status, 200, made.text);
+ assert.strictEqual(made.body.persona.name, 'Desk Mechanic');
+ assert.strictEqual(made.body.persona.glyph, 'wrench');
+ const chat = await chatOf(slug, owner);
+ assert.strictEqual(chat.chat.name, 'My Own Desk Chat', 'a person\'s rename is never overwritten by a persona');
+ assert.strictEqual(chat.chat.persona.name, 'Desk Mechanic');
+});