openvibers/openvibe.chat Commits

Vibe personas: a name and a face for each conversation (#546)

OP OpenVibers committed 17 h ago · 3e448779e7 · 20 files, +1479 −13

* Vibe personas: a name and a face for each conversation, from what it is about

The owner, 11-12: a name and an avatar per Vibe chat, remade when the topic changes, on the cheapest
model, configurable on the admin panel. After the first answer, then every check_every messages
(a YES/NO topic check), Chat's own AI grant makes { name, glyph, hue, image_url, topic } (glyph
checked against the registry; pictures off by default). A monthly cap (default $5) from the runs'
own usage; a person's rename is never overwritten. /admin/persona and /api/admin/persona are
owner-only and list and remake only the owner's own chats: other people's Vibe chats are private.
Migration 0078 (expand): vibe_personas, vibe_persona_spend, rooms.vibe_title_locked.

* Vibe personas: the provider's picture through the egress guard; the schema and outbound lists know the new tables and call

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex ef11196..82ce3f1 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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).
ddiff --git a/README.md b/README.md
iindex 7e62c19..7f38ef8 100644
---- a/README.md
++++ b/README.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 answer
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`,
`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.pref
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/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-record
docs/ calls-cutover.md, parity.md, chat-ingress.md, capabilities-proposal/
ddiff --git a/docs/vibe-personas.md b/docs/vibe-personas.md
nnew file mode 100644
iindex 0000000..2d4d74d
---- /dev/null
++++ b/docs/vibe-personas.md
@@@ -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.
ddiff --git a/migrations/0078_vibe_personas.sql b/migrations/0078_vibe_personas.sql
nnew file mode 100644
iindex 0000000..b868891
---- /dev/null
++++ b/migrations/0078_vibe_personas.sql
@@@ -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;
ddiff --git a/server/ai/client.js b/server/ai/client.js
iindex 0a826bc..a5b3bc3 100644
---- a/server/ai/client.js
++++ b/server/ai/client.js
@@@ -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; } };
ddiff --git a/server/app.js b/server/app.js
iindex aff1a2d..e782bdf 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, settin
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.
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'));
ddiff --git a/server/index.js b/server/index.js
iindex 4bac9cf..5d4acec 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 { /* */ } }); });
ddiff --git a/server/persona/index.js b/server/persona/index.js
nnew file mode 100644
iindex 0000000..8806240
---- /dev/null
++++ b/server/persona/index.js
@@@ -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,
++};
ddiff --git a/server/persona/settings.js b/server/persona/settings.js
nnew file mode 100644
iindex 0000000..019068b
---- /dev/null
++++ b/server/persona/settings.js
@@@ -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,
++};
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex 056a361..009b453 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -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;
ddiff --git a/server/rooms/persona-api.js b/server/rooms/persona-api.js
nnew file mode 100644
iindex 0000000..a79e5ee
---- /dev/null
++++ b/server/rooms/persona-api.js
@@@ -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;
ddiff --git a/server/rooms/vibe-api.js b/server/rooms/vibe-api.js
iindex 6cb9071..09d4723 100644
---- a/server/rooms/vibe-api.js
++++ b/server/rooms/vibe-api.js
@@@ -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)) } : {}) })) });
});
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex efae2e6..b15c2ea 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -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_at
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]);
++ 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' });
}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex d562bab..c29b8db 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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 });
ddiff --git a/server/web/persona-admin.js b/server/web/persona-admin.js
nnew file mode 100644
iindex 0000000..7e8f492
---- /dev/null
++++ b/server/web/persona-admin.js
@@@ -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 };
ddiff --git a/test/chat-apps-settings.test.js b/test/chat-apps-settings.test.js
iindex a9eb1cb..9b80897 100644
---- a/test/chat-apps-settings.test.js
++++ b/test/chat-apps-settings.test.js
@@@ -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 () => {
ddiff --git a/test/fixtures/vibe-persona/recorded.json b/test/fixtures/vibe-persona/recorded.json
nnew file mode 100644
iindex 0000000..39a1994
---- /dev/null
++++ b/test/fixtures/vibe-persona/recorded.json
@@@ -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
++ }
++}
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex e122a5f..2dfe26e 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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',
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 7dfde22..30be3a3 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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', () => {
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',
ddiff --git a/test/vibe-persona.test.js b/test/vibe-persona.test.js
nnew file mode 100644
iindex 0000000..8981a95
---- /dev/null
++++ b/test/vibe-persona.test.js
@@@ -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');
++});