openvibers/openvibe.chat Commits

Chat: AI members in rooms (friend groups, couples, a Food household) (#102)

OP OpenVibers committed 28 days ago · aaac3c038c · 17 files, +652 −43

* Chat: AI members in rooms (friend groups, couples, a Food household)

The owner's Q14/Q15: friend groups and couples with AI members, and a Food
household's chat with an AI member that knows the pantry.

- migration 0012: room_ai_members (name, @handle, persona, the room's notes,
  an optional linked service), room_messages.ai_member_id, room_ai_usage
- server/rooms/ai-members.js: a room's owner adds up to 3; writing @handle
  makes it answer through OpenVibe.AI chat.member.reply with the last 30
  messages, its notes and shared context, attributed to the room; posted as
  a room message with ai_member_id (user_id 0), room_ai_typing first; one at
  a time per room, 20 an hour per person, 100 a room a day
  (CHAT_AI_MEMBER_REPLIES_PER_DAY), CHAT_AI_MEMBERS=0 to switch off
- server/rooms/food-context.js: a member linked to a Food household reads
  its pantry and plan with Chat's service token (food.household.read)
- API: GET|POST /api/chat/rooms/:slug/ai-members, PATCH|DELETE .../:id;
  members can be added by account subject
- openvibe.chat: AI messages badged (never a profile link), "@handle to ask"
  on the room page, an AI members section in room settings, a "writing"
  line while it answers
- account data: exported; deleting an account keeps the AI members it added
  without its subject

Deploy only after Chat's held contract 0009 is due (2026-10-16 17:39 UTC):
0010-0012 wait behind it, and this code reads room_messages.ai_member_id.

* Chat N-1 fixture: recorded from production's e5199dd now that its 0009 contract, 0010 and 0011 have applied

Changes

ddiff --git a/README.md b/README.md
iindex 12c159f..dd3cb0d 100644
---- a/README.md
++++ b/README.md
@@@ -291,6 +291,23 @@ side (the space's owner or staff), so one person holding both ends makes the lin
involved. Chat shows attachments only to the room's managers (room settings, with Detach), never as a public
claim on the room page.
++**AI members** (`server/rooms/ai-members.js`, `migrations/0012_room_ai_members.sql`; the owner's ask: friend groups
++and couples with AI members, and a Food household's chat with one that knows the pantry). A room's owner (or chat
++staff) adds up to 3: a name (its `@handle` is the name in lowercase), how it should be, and notes it always knows.
++When someone who may post writes `@handle`, it answers: the room's last 30 messages, its notes and any shared context
++go to OpenVibe.AI's `chat.member.reply` with Chat's own service token, attributed to the room
++(`{ service: chat, type: room }`), and the answer is a room message with `ai_member_id` set and `user_id` 0, shown with
++an AI badge, never as a person. Sockets get `room_ai_typing { room, name }` first. Only the first member named answers;
++it never answers an AI message; one answer at a time per room, 20 an hour from one person, and
++`CHAT_AI_MEMBER_REPLIES_PER_DAY` (100) a room a day, past which it says so instead. `CHAT_AI_MEMBERS=0` keeps them
++quiet (independent of the insight job's `CHAT_AI_ENABLED`). An AI member linked to an OpenVibe.Food household
++(`link_service: food`) also gets the household's pantry and plan, read from Food's
++`/internal/households/:plan/chat-context` with Chat's service token (`food.household.read`, at most once a minute;
++`CHAT_FOOD_INTERNAL_URL`). API: `GET|POST /api/chat/rooms/:slug/ai-members`, `PATCH|DELETE …/ai-members/:id` (people
++only; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A room
++member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
++account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
++
**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/messages` the DM inbox
(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block
ddiff --git a/migrations/0012_room_ai_members.sql b/migrations/0012_room_ai_members.sql
nnew file mode 100644
iindex 0000000..c844c7c
---- /dev/null
++++ b/migrations/0012_room_ai_members.sql
@@@ -0,0 +1,30 @@
++-- phase: expand
++-- AI members of a room (server/rooms/ai-members.js; owner 2026-09-28 Q14: friend groups and couples with AI members,
++-- Q15: a Food household's chat with an AI member that knows the pantry). A room's managers add one with a name and how
++-- it should be; when someone writes @handle in the room, it answers through OpenVibe.AI (chat.member.reply) and its
++-- reply is a room message with ai_member_id set (user_id 0: no person wrote it).
++CREATE TABLE IF NOT EXISTS room_ai_members (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
++ name text COLLATE "C" NOT NULL, -- shown in the room (2-24 characters)
++ handle text COLLATE "C" NOT NULL, -- lowercase, what @mentions match
++ persona text NOT NULL DEFAULT '', -- how the room wants it to be (<= 1000)
++ memory text NOT NULL DEFAULT '', -- the room's notes for it (<= 2000)
++ link_service text COLLATE "C", -- a service that gives it shared context: 'food'
++ link_resource text COLLATE "C", -- that service's id for it: a Food household
++ enabled bigint NOT NULL DEFAULT 1,
++ created_by_subject text COLLATE "C", -- usr_… (NULL once that account is deleted)
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ UNIQUE (room_id, handle)
++);
++
++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS ai_member_id bigint;
++
++-- Replies per room per UTC day (the room's daily cap).
++CREATE TABLE IF NOT EXISTS room_ai_usage (
++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
++ day text COLLATE "C" NOT NULL,
++ replies integer NOT NULL DEFAULT 0,
++ PRIMARY KEY (room_id, day)
++);
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 7bda95b..c76f8cb 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -112,3 +112,15 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-members form { margin-left: 0; width: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
.oc-new select { flex: 1 1 140px; }
}
++
++/* AI members of a room (server/rooms/ai-members.js): their badge, their messages, "writing…", the settings forms */
++.oc-badge.oc-ai { color: #c4b5fd; border-color: rgba(167, 139, 250, .5); margin-left: 0; }
++.oc-msg-ai { background: color-mix(in srgb, #a78bfa 7%, transparent); border-radius: 8px; }
++.oc-ai-name { font-weight: 700; color: #ddd6fe; }
++.oc-thinking { color: var(--oc-muted); font-style: italic; padding: 2px 6px; }
++.oc-ai-here { margin: .2rem 0 .6rem; }
++.oc-ai-here code { font-size: .9em; }
++.oc-ai-member { border: 1px solid var(--oc-line); border-radius: 12px; padding: 10px 14px; margin: 10px 0; }
++.oc-ai-member h3 { margin: 0 0 6px; font-size: 1rem; }
++.oc-ai-form textarea { width: 100%; box-sizing: border-box; }
++@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .oc-badge.oc-ai { color: #6d28d9; border-color: rgba(109, 40, 217, .45); } :root:not([data-theme="dark"]) .oc-ai-name { color: #5b21b6; } }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex ee8c618..5f71d84 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -197,22 +197,41 @@
// ── A room ──
function roomItem(m) {
-- var li = el('li', 'oc-msg'); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
++ var ai = m.ai_member_id != null;
++ var li = el('li', 'oc-msg' + (ai ? ' oc-msg-ai' : '')); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
-- var who = el('a', 'oc-name'); who.appendChild(wearing(m, m.display_name || m.username || 'someone'));
-- who.href = LIVE + '/@' + encodeURIComponent(m.username || '');
-- if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color);
-- li.appendChild(who);
-- var b = badge(m.user_role); if (b) li.appendChild(b);
++ if (ai) {
++ // An AI member of the room: its name and an AI badge, never a profile link.
++ li.appendChild(el('span', 'oc-name oc-ai-name', m.display_name || 'AI'));
++ li.appendChild(document.createTextNode(' '));
++ var ab = el('span', 'oc-badge oc-ai', 'AI'); ab.title = 'An AI member of this room'; li.appendChild(ab);
++ clearThinking();
++ } else {
++ var who = el('a', 'oc-name'); who.appendChild(wearing(m, m.display_name || m.username || 'someone'));
++ who.href = LIVE + '/@' + encodeURIComponent(m.username || '');
++ if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color);
++ li.appendChild(who);
++ var b = badge(m.user_role); if (b) li.appendChild(b);
++ }
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
-- if (cfg.moderate || (cfg.me && m.user_id === cfg.me)) {
++ if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
var x = el('button', null, '×'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message');
f.appendChild(x); li.appendChild(f);
}
return li;
}
++ // "Pantry is writing…" while a room's AI member answers (room_ai_typing), until its message arrives or 40 s pass.
++ var thinking = null, thinkingTimer = null;
++ function clearThinking() { if (thinking) { thinking.remove(); thinking = null; } if (thinkingTimer) { clearTimeout(thinkingTimer); thinkingTimer = null; } }
++ function showThinking(name) {
++ clearThinking();
++ thinking = el('li', 'oc-msg oc-msg-ai oc-thinking', (name || 'The AI member') + ' is writing\u2026');
++ thinking.setAttribute('aria-live', 'polite');
++ feed.appendChild(thinking);
++ thinkingTimer = setTimeout(clearThinking, 40000);
++ }
function startRoom() {
var live = document.getElementById('oc-live');
var ws = null, delay = 1000, opened = false;
@@@ -233,6 +252,8 @@
latest = Math.max(latest, Number(m.message.id) || 0);
append(roomItem(m.message), m.message.id);
if (!document.hidden && cfg.me) fetch(api + '/read', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ last_id: latest }) }).catch(function () {});
++ } else if (m.type === 'room_ai_typing' && m.room === cfg.room) {
++ showThinking(m.name);
} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
} else if (m.type === 'room_error' && m.message) {
ddiff --git a/server/ai/client.js b/server/ai/client.js
iindex eaa6ad6..6a583b7 100644
---- a/server/ai/client.js
++++ b/server/ai/client.js
@@@ -37,10 +37,11 @@ function enabled() {
/**
* Run a workflow and return its `output` object, or null: disabled, a refusal, a failed run, or a
-- * transport error all answer null — exactly Live's ai-service.structured() contract.
++ * transport error all answer null — exactly Live's ai-service.structured() contract. `force` runs it even with
++ * the insight job switched off (CHAT_AI_ENABLED): a room's AI members have their own switch (CHAT_AI_MEMBERS).
*/
async function structured(workflow, input, opts = {}) {
-- if (!enabled()) return null;
++ if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
try {
const { runs } = ai();
const body = await runs.create(workflow, input, {
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex c771525..0dbcdf1 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -54,6 +54,7 @@ const EXPORTS = [
['emotes.json', `SELECT * FROM emotes WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['sounds.json', `SELECT * FROM channel_sounds WHERE created_by_subject_id = ANY(@s) OR created_by = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['moderation_on_you.json', `SELECT * FROM moderation_actions WHERE target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
++ ['ai_members_added.json', `SELECT * FROM room_ai_members WHERE created_by_subject = ANY(@s) ORDER BY id DESC LIMIT ${LIMIT}`],
];
async function exportPart(d, subject) {
@@@ -92,6 +93,8 @@ const ERASE_AFTER_ROOMS = [
['tags', 'DELETE FROM user_tags WHERE user_id = ANY(@ids)'],
['moderator_roles', 'DELETE FROM channel_moderators WHERE user_id = ANY(@ids)'],
['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],
++ // An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
++ [null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
];
// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.
const ERASE_CHANNEL = [
ddiff --git a/server/config.js b/server/config.js
iindex 88d690e..56f7ca8 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -68,6 +68,10 @@ module.exports = {
scope: 'profile theme',
},
++ // OpenVibe.Food (server/rooms/food-context.js): a household's pantry and plan for a room's AI member, read
++ // server-side with Chat's service token. The loopback address in production.
++ food: { internalUrl: strip(process.env.CHAT_FOOD_INTERNAL_URL || 'http://127.0.0.1:4970') },
++
// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
web: {
baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),
@@@ -179,6 +183,10 @@ module.exports = {
scope: 'ai.run.create ai.run.read',
waitMs: Math.max(0, int(process.env.CHAT_AI_WAIT_MS, 60000)),
tickMs: Math.max(1000, int(process.env.CHAT_AI_TICK_MS, 45000)),
++ // A room's AI members (server/rooms/ai-members.js): on unless CHAT_AI_MEMBERS=0, independent of the insight job
++ // above (CHAT_AI_ENABLED); each room answers at most this many times a UTC day.
++ members: process.env.CHAT_AI_MEMBERS !== '0',
++ memberRepliesPerDay: Math.max(1, int(process.env.CHAT_AI_MEMBER_REPLIES_PER_DAY, 100)),
},
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
ddiff --git a/server/live-context.js b/server/live-context.js
iindex 27e8269..9fb6122 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -244,6 +244,7 @@ async function upsertUser(row) {
const userSelect = 'SELECT * FROM ctx_users';
async function getUserById(id) { if (!id) return null; const u = await db.get(`${userSelect} WHERE id = ?`, [id]) || null; if (!u) ensureUsers([id]).catch(() => {}); return u; }
async function getUserByUsername(name) { return name ? (await db.get(`${userSelect} WHERE lower(username) = lower(?)`, [String(name)]) || null) : null; }
++async function getUserBySubject(subject) { return subject ? (await db.get(`${userSelect} WHERE subject_id = ?`, [String(subject)]) || null) : null; }
async function getUserByDisplayName(name) { return name ? (await db.get(`${userSelect} WHERE lower(display_name) = lower(?)`, [String(name)]) || null) : null; }
async function subjectFor(userId) { return await db.subjectFor(userId); }
@@@ -941,7 +942,7 @@ module.exports = {
start, stop, sync, tick, warm, syncStatus,
// identity + users
authenticate, authFailureReason, upsertUser, invalidateUser, subjectFor,
-- getUserById, getUserByUsername, getUserByDisplayName, ensureUsers, ensureUserByUsername,
++ getUserById, getUserByUsername, getUserBySubject, getUserByDisplayName, ensureUsers, ensureUserByUsername,
// streams + channels
getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
nnew file mode 100644
iindex 0000000..e009287
---- /dev/null
++++ b/server/rooms/ai-members.js
@@@ -0,0 +1,194 @@
++'use strict';
++/**
++ * AI members of a room (migrations/0012_room_ai_members.sql). The owner's ask (2026-09-28, Q14 and Q15): friend
++ * groups and couples with AI members in their chat, and a Food household's chat with an AI member that knows the
++ * pantry.
++ *
++ * - A room's managers (its owner, or chat staff) add up to MAX_PER_ROOM: a name, a lowercase @handle, how they want
++ * it to be (persona) and the room's notes for it (memory). A linked service (Food, for a household) can give it
++ * shared context; see contextFor().
++ * - When someone who may post writes @handle, that member answers: the room's last TRANSCRIPT messages, its notes
++ * and the shared context go to OpenVibe.AI (`chat.member.reply`, Chat's own service token, attributed to the
++ * room), and the answer is posted as a room message with ai_member_id set. Only the first member named answers;
++ * an AI member never answers an AI member.
++ * - Limits: one answer at a time per room, CHAT_AI_MEMBER_REPLIES_PER_DAY a room (100), and 20 an hour from one
++ * person. Past them, or with AI unavailable, the member says so once instead of answering.
++ * - Off with CHAT_AI_MEMBERS=0, and without the OAuth client secret (no token to call AI with).
++ *
++ * Room messages written by an AI member have user_id 0 and the member's name (rooms.js MSG_SELECT joins it).
++ */
++const db = require('../db/database');
++const config = require('../config');
++const aiClient = require('../ai/client');
++
++const MAX_PER_ROOM = 3;
++const TRANSCRIPT = 30;
++const LIMITS = { name: 24, persona: 1000, memory: 2000 };
++const NAME_RE = /^[\p{L}\p{N}][\p{L}\p{N} ._'-]{0,22}[\p{L}\p{N}]$/u;
++const PER_PERSON_HOUR = 20;
++
++const inFlight = new Set(); // room ids with an answer being written
++const personHour = new Map(); // `${room}:${user}` → [timestamps]
++
++const handleOf = (name) => String(name || '').toLowerCase().normalize('NFKD').replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 24);
++const clean = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);
++
++class AiMemberError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
++const fail = (status, code, message) => { throw new AiMemberError(status, code, message); };
++
++const enabled = () => Boolean(config.ai.members && config.oauth.clientSecret);
++
++async function list(room) {
++ return await db.all('SELECT * FROM room_ai_members WHERE room_id = ? ORDER BY id', [room.id]);
++}
++async function byId(room, id) {
++ return await db.get('SELECT * FROM room_ai_members WHERE room_id = ? AND id = ?', [room.id, Number(id) || 0]) || null;
++}
++
++function validate({ name, persona, memory } = {}, { partial = false } = {}) {
++ const out = {};
++ if (!partial || name !== undefined) {
++ const n = clean(name, LIMITS.name + 1).replace(/\s+/g, ' ');
++ if (!NAME_RE.test(n) || n.length > LIMITS.name) fail(422, 'rooms.ai_name', 'An AI member\'s name is 2 to 24 letters, digits, spaces, dots, dashes or apostrophes');
++ out.name = n;
++ out.handle = handleOf(n);
++ if (out.handle.length < 2) fail(422, 'rooms.ai_name', 'An AI member\'s name needs at least two letters or digits');
++ }
++ if (persona !== undefined) out.persona = clean(persona, LIMITS.persona);
++ if (memory !== undefined) out.memory = clean(memory, LIMITS.memory);
++ return out;
++}
++
++/** Add one. `can` is rooms.access() for `user`: only a room's managers add, edit or remove AI members. */
++async function add(room, user, can, input = {}) {
++ if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');
++ if (room.kind === 'system') fail(422, 'rooms.ai_kind', 'Announcement rooms have no AI members');
++ const v = validate(input);
++ if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);
++ if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
++ const link = input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;
++ const r = await db.run(
++ 'INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
++ [room.id, v.name, v.handle, v.persona || '', v.memory || '', link ? link.service : null, link ? link.resource : null, (user && user.subject_id) || null]);
++ return await byId(room, r.lastInsertRowid);
++}
++
++async function update(room, can, id, input = {}) {
++ if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');
++ const m = await byId(room, id);
++ if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');
++ const v = validate(input, { partial: true });
++ if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
++ const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);
++ await db.run('UPDATE room_ai_members SET name = ?, handle = ?, persona = ?, memory = ?, enabled = ?, updated_at = ov_now() WHERE id = ?',
++ [v.name || m.name, v.handle || m.handle, v.persona !== undefined ? v.persona : m.persona, v.memory !== undefined ? v.memory : m.memory, enabledFlag, m.id]);
++ return await byId(room, m.id);
++}
++
++async function remove(room, can, id) {
++ if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner removes AI members');
++ await db.run('DELETE FROM room_ai_members WHERE room_id = ? AND id = ?', [room.id, Number(id) || 0]);
++}
++
++/** The first enabled member a message names (@handle, as a word), or null. */
++function mentioned(members, text) {
++ const t = String(text || '').toLowerCase();
++ let best = null;
++ for (const m of members) {
++ if (!Number(m.enabled)) continue;
++ const re = new RegExp(`(^|[^\\p{L}\\p{N}_])@${m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}_])`, 'u');
++ const hit = re.exec(t);
++ if (hit && (!best || hit.index < best.at)) best = { member: m, at: hit.index };
++ }
++ return best ? best.member : null;
++}
++
++/**
++ * Shared context a linked service gives the member, as text, or null. Food (a household's pantry and meal plan) is
++ * read from Food's internal API with Chat's service token: see server/rooms/food-context.js.
++ */
++async function contextFor(member) {
++ if (member.link_service !== 'food' || !member.link_resource) return null;
++ try { return await require('./food-context').contextOf(member.link_resource); } catch { return null; }
++}
++
++function personOk(roomId, userId, now) {
++ const key = `${roomId}:${userId}`;
++ const recent = (personHour.get(key) || []).filter((t) => now - t < 3_600_000);
++ if (recent.length >= PER_PERSON_HOUR) { personHour.set(key, recent); return false; }
++ recent.push(now);
++ personHour.set(key, recent);
++ if (personHour.size > 5000) personHour.delete(personHour.keys().next().value);
++ return true;
++}
++
++async function countReply(room) {
++ const day = new Date().toISOString().slice(0, 10);
++ const row = await db.get(
++ `INSERT INTO room_ai_usage (room_id, day, replies) VALUES (?, ?, 1)
++ ON CONFLICT (room_id, day) DO UPDATE SET replies = room_ai_usage.replies + 1 RETURNING replies`, [room.id, day]);
++ return Number(row && row.replies) || 1;
++}
++async function repliesToday(room) {
++ const row = await db.get('SELECT replies FROM room_ai_usage WHERE room_id = ? AND day = ?', [room.id, new Date().toISOString().slice(0, 10)]);
++ return Number(row && row.replies) || 0;
++}
++
++/** The room's recent messages as plain lines, oldest first, for the prompt. */
++function transcriptOf(rows) {
++ return rows.map((m) => `${(m.display_name || m.username || 'someone').slice(0, 40)}${m.ai_member_id ? ' (AI)' : ''}: ${String(m.message || '').replace(/\s+/g, ' ').slice(0, 400)}`).join('\n').slice(-12000);
++}
++
++/**
++ * After a person's message is stored and broadcast (rooms.post). Never throws: an AI member that cannot answer
++ * stays quiet or says why. `deps` lets tests inject the poster and the broadcast.
++ */
++async function afterPost(room, message, user, deps = {}) {
++ if (!enabled() || !message || message.ai_member_id || !user) return null;
++ const members = await list(room);
++ if (!members.length) return null;
++ const member = mentioned(members, message.message);
++ if (!member) return null;
++ const rooms = deps.rooms || require('./rooms');
++ const broadcast = deps.broadcast || ((frame) => require('../chat/chat-server').broadcastToRoom(room.id, frame));
++ const say = async (text) => {
++ const posted = await rooms.postAsAi(room, member, text);
++ broadcast({ type: 'room_message', room: room.slug, message: posted });
++ return posted;
++ };
++ if (inFlight.has(room.id)) return null;
++ if (!personOk(room.id, user.id, Date.now())) return null;
++ if (await repliesToday(room) >= config.ai.memberRepliesPerDay) {
++ return await say(`I have answered ${config.ai.memberRepliesPerDay} times in this room today, which is my limit. Ask me again tomorrow.`);
++ }
++ inFlight.add(room.id);
++ try {
++ broadcast({ type: 'room_ai_typing', room: room.slug, name: member.name });
++ const history = await rooms.history(room, { limit: TRANSCRIPT });
++ const context = await contextFor(member);
++ const out = await aiClient.structured('chat.member.reply', {
++ ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),
++ memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',
++ transcript: transcriptOf(history) || '(no earlier messages)',
++ asked_by: (user.display_name || user.username || 'someone').slice(0, 80), message: String(message.message).slice(0, 2000),
++ }, { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}` });
++ const text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';
++ if (!text) return await say('I could not answer just now. Try again in a minute.');
++ await countReply(room);
++ return await say(text);
++ } catch (err) {
++ console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);
++ return null;
++ } finally {
++ inFlight.delete(room.id);
++ }
++}
++
++const toWire = (m) => ({
++ id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),
++ link: m.link_service ? { service: m.link_service, resource: m.link_resource } : null, created_at: m.created_at, updated_at: m.updated_at,
++});
++
++function _reset() { inFlight.clear(); personHour.clear(); }
++
++module.exports = { MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, mentioned, handleOf, afterPost, transcriptOf, toWire, _reset };
ddiff --git a/server/rooms/food-context.js b/server/rooms/food-context.js
nnew file mode 100644
iindex 0000000..c077a43
---- /dev/null
++++ b/server/rooms/food-context.js
@@@ -0,0 +1,49 @@
++'use strict';
++/**
++ * Shared context for an AI member linked to an OpenVibe.Food household (room_ai_members.link_service = 'food',
++ * link_resource = the household's plan id): what the household has in its cupboards and what it planned, as text for
++ * the prompt. Read from Food's internal API with Chat's own service token (audience openvibe.food, capability
++ * food.household.read, which Network grants Chat), at most once a minute per household.
++ *
++ * GET {CHAT_FOOD_INTERNAL_URL}/internal/households/:plan/chat-context → { text, source }
++ *
++ * Nothing is cached past a minute: a pantry changes as people cook. Without the client secret it answers null.
++ */
++const config = require('../config');
++
++const TTL_MS = 60_000;
++const cache = new Map(); // plan id → { at, value }
++let tokens = null;
++
++function tokenClient() {
++ if (tokens) return tokens;
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ tokens = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.food',
++ scope: 'food.household.read',
++ });
++ return tokens;
++}
++
++async function contextOf(planId, { fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
++ if (!config.oauth.clientSecret || !/^pln_[0-9A-Z]{26}$/.test(String(planId))) return null;
++ const hit = cache.get(planId);
++ if (hit && now - hit.at < TTL_MS) return hit.value;
++ const token = await tokenClient().getToken();
++ const res = await fetchImpl(`${config.food.internalUrl}/internal/households/${encodeURIComponent(planId)}/chat-context`, {
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
++ signal: AbortSignal.timeout(5000),
++ });
++ const body = res.ok ? await res.json().catch(() => null) : null;
++ const value = body && typeof body.text === 'string' && body.text.trim() ? { text: body.text.slice(0, 4000), source: String(body.source || 'OpenVibe.Food household').slice(0, 80) } : null;
++ cache.set(planId, { at: now, value });
++ if (cache.size > 500) cache.delete(cache.keys().next().value);
++ return value;
++}
++
++function _reset() { cache.clear(); tokens = null; }
++
++module.exports = { contextOf, _reset, _setTokens(t) { tokens = t; } };
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex d888002..47e370d 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -269,11 +269,31 @@ async function post(room, user, text, { now = Date.now() } = {}) {
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
});
++ const row = await messageById(id);
++ // A message naming one of the room's AI members (@handle) gets its answer after this one is out.
++ setImmediate(() => { require('./ai-members').afterPost(room, row, user).catch(() => {}); });
++ return row;
++}
++
++/** A message from one of the room's AI members (server/rooms/ai-members.js): user_id 0, the member's id. */
++async function postAsAi(room, member, text, { now = Date.now() } = {}) {
++ const message = String(text || '').trim().slice(0, MAX_TEXT);
++ const id = await db.tx(async () => {
++ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, ai_member_id) VALUES (?, 0, NULL, ?, ?)', [room.id, message, member.id]);
++ await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
++ await announceMessage(room, { subject_id: null, username: member.handle, display_name: member.name }, Number(r.lastInsertRowid), message, now);
++ return Number(r.lastInsertRowid);
++ });
return await messageById(id);
}
--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, u.username, u.display_name, u.profile_color, u.role AS user_role
-- FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id`;
++// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id,
++ CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle ELSE u.username END AS username,
++ CASE WHEN m.ai_member_id IS NOT NULL THEN a.name ELSE u.display_name END AS display_name,
++ u.profile_color, u.role AS user_role
++ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id AND m.ai_member_id IS NULL
++ LEFT JOIN room_ai_members a ON a.id = m.ai_member_id`;
async function messageById(id) { return await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }
/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */
@@@ -415,6 +435,7 @@ async function attachments(room) {
function _reset() { _rate.clear(); }
module.exports = {
++ postAsAi,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
attach, detach, attachments, joinRoleOf, unreadTotal,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex bb17e1a..49846e6 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -158,9 +158,11 @@ router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
try {
const name = String((req.body && req.body.username) || '').trim();
-- let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await ctx.getUserByUsername(name) : null;
-- if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
-- if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
++ const subject = String((req.body && req.body.subject) || '').replace(/^user:/, '').trim();
++ let target = null;
++ if (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) target = await ctx.getUserBySubject(subject);
++ else if (/^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.getUserByUsername(name) || await ctx.ensureUserByUsername(name).catch(() => null);
++ if (!target) return res.status(404).json({ error: subject ? 'No such OpenVibe account' : `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
const role = await rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));
if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
await applyRoomChange(room);
@@@ -188,6 +190,35 @@ router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly,
try { res.json({ ok: true, ...await rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }
});
++// ── AI members (server/rooms/ai-members.js) ──
++// Anyone who may read the room sees who its AI members are; its managers see and change how they are set up.
++const aiMembers = require('./ai-members');
++function aiSend(res, err) {
++ if (err instanceof aiMembers.AiMemberError) return res.status(err.status).json({ error: err.message, code: err.code });
++ return send(res, err);
++}
++router.get('/:slug/ai-members', optionalAuth, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ const can = await rooms.access(room, req.user || null);
++ const list = (await aiMembers.list(room)).map(aiMembers.toWire).map((m) => (can.manage ? m : { id: m.id, name: m.name, handle: m.handle, enabled: m.enabled }));
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ ai_members: list, enabled: aiMembers.enabled(), max: aiMembers.MAX_PER_ROOM });
++});
++router.post('/:slug/ai-members', requireAuth, personOnly, manageLimit, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ try {
++ const m = await aiMembers.add(room, req.user, await rooms.access(room, req.user), req.body || {});
++ res.status(201).json(aiMembers.toWire(m));
++ } catch (err) { aiSend(res, err); }
++});
++router.patch('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ try { res.json(aiMembers.toWire(await aiMembers.update(room, await rooms.access(room, req.user), req.params.id, req.body || {}))); } catch (err) { aiSend(res, err); }
++});
++router.delete('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ try { await aiMembers.remove(room, await rooms.access(room, req.user), req.params.id); res.status(204).end(); } catch (err) { aiSend(res, err); }
++});
++
module.exports = router;
module.exports.callOf = callOf;
module.exports.applyRoomChange = applyRoomChange;
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 907726a..c6543d1 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -415,6 +415,7 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
// ── Rooms (server/rooms/) ──
const rooms = require('../rooms/rooms');
++ const aiMembers = require('../rooms/ai-members');
const roomRoutes = require('../rooms/routes');
const roomsOrError = (fn) => async (req, res, next) => {
try { return await fn(req, res, next); } catch (err) {
@@@ -428,8 +429,12 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;
const roomMessage = (m, can, slug, me) => {
const t = hhmm(m.created_at);
-- const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
-- return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
++ const ai = m.ai_member_id != null;
++ const del = (can.moderate || (me && !ai && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
++ const who = ai
++ ? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
++ : `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@@ -518,15 +523,17 @@ ${people}
const yourRole = me && YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : '';
const isCall = room.kind === 'call';
const callReady = isCall && !!require('../config').calls.enabled;
++ const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];
++ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';
await page(req, res, 200, {
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
-- page: { view: 'room', room: room.slug, kind: room.kind, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
++ page: { view: 'room', room: room.slug, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: `<p><a href="/rooms">← Rooms</a></p>
<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
--${error}
++${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
${composer}${tools}`,
@@@ -590,6 +597,19 @@ ${composer}${tools}`,
: `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;
const addRole = rooms.joinRoleOf(room);
const attachments = can.manage ? await rooms.attachments(room) : [];
++ const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
++ const aiForm = (a) => `<form class="oc-form oc-ai-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
++${a ? '' : '<h3>Add an AI member</h3>'}
++<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label>
++<label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>
++<label>Notes it always knows <textarea name="memory" rows="3" maxlength="2000" placeholder="Lee is vegetarian. We shop on Saturdays. Budget: $80 a week.">${a ? esc(a.memory) : ''}</textarea></label>
++${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label>` : ''}
++<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;
++ const aiSection = can.manage && room.kind !== 'system' ? `<h2 id="oc-ai">AI members</h2>
++${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}
++<p class="oc-muted">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI. It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>
++${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-link">Remove ${esc(a.name)}</button></form></section>`).join('')}
++${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
const communityUrl = config.web.communityUrl;
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
@@@ -606,6 +626,7 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room
<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>
<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span>${seen(m)} ${roleForm(m)}</li>`).join('')}</ul>
++${aiSection}
${attachList}`,
});
});
@@@ -642,6 +663,24 @@ ${attachList}`,
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
++ // ── AI members: add, change, remove (the room's managers; server/rooms/ai-members.js decides) ──
++ const aiMemberForm = (fn) => roomsOrError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const room = await rooms.bySlug(req.params.slug);
++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
++ req.roomBack = `/r/${room.slug}/settings`;
++ try {
++ await fn(room, actor, await rooms.access(room, actor.user), req.body || {}, req);
++ } catch (err) {
++ if (err instanceof aiMembers.AiMemberError) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(err.message)}#oc-ai`);
++ throw err;
++ }
++ res.redirect(303, `/r/${room.slug}/settings?saved=1`);
++ });
++ router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory }); }));
++ router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1' }); }));
++ router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));
++
router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = await rooms.bySlug(req.params.slug);
ddiff --git a/test/fixtures/n-1/client.json b/test/fixtures/n-1/client.json
iindex 1d72eb6..9eea913 100644
---- a/test/fixtures/n-1/client.json
++++ b/test/fixtures/n-1/client.json
@@@ -1,32 +1,32 @@
{
"service": "chat",
-- "release": "143e52e371b2a659c0a407635f60fd256a4e619a",
-- "recorded_at": "2026-10-10T16:02:05.083Z",
++ "release": "e5199ddcd410bde4c6c8bcbad2d22985f517338a",
++ "recorded_at": "2026-10-16T17:41:47.755Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"clients": {
-- "live": "92f003c2a75833950b875e8c314a59b55e9d9502"
++ "live": "0b3fcc74ab8cb735b419949b6fb58ea7b03521c7"
},
"manifest": {
"service": "chat",
-- "release": "143e52e371b2",
-- "released_at": "2026-10-10T13:50:03.000Z",
-- "booted_at": "2026-10-10T16:01:59.563Z",
-- "contracts_version": "0.129.0",
++ "release": "e5199ddcd410",
++ "released_at": "2026-10-15T17:43:52.000Z",
++ "booted_at": "2026-10-16T17:41:42.474Z",
++ "contracts_version": "0.189.0",
"packages": {
-- "openvibe-shared": "3.0.0",
-- "openvibe-sdk": "0.38.1",
-- "openvibe-contracts": "0.129.0"
++ "openvibe-shared": "4.2.1",
++ "openvibe-sdk": "0.46.0",
++ "openvibe-contracts": "0.189.0"
},
"min_client_release": null,
"mixed_version_window_hours": 24,
"components": {
"shell": {
"kind": "script",
-- "version": "143e52e371b2"
++ "version": "e5199ddcd410"
},
"server": {
"kind": "server",
-- "version": "143e52e371b2"
++ "version": "e5199ddcd410"
}
},
"assets": {},
@@@ -1530,7 +1530,7 @@
},
{
"method": "GET",
-- "path": "/web/chat.css?v=f8b0b316ea",
++ "path": "/web/chat.css?v=ae2a2fde57",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -1550,6 +1550,17 @@
"page /"
]
},
++ {
++ "method": "GET",
++ "path": "/shared/showcase.css?v=2b67c3ab851a",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
{
"method": "GET",
"path": "/shared/theme-loader.js?v=ee4a6754d0f7",
@@@ -1563,7 +1574,7 @@
},
{
"method": "GET",
-- "path": "/shared/navbar.js?v=8a1438c1d7b3",
++ "path": "/shared/navbar.js?v=71c87996efd0",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -1574,7 +1585,7 @@
},
{
"method": "GET",
-- "path": "/shared/footer.js?v=0eafb8254aa5",
++ "path": "/shared/footer.js?v=a8d71a10e2e2",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -1641,6 +1652,30 @@
"page /"
]
},
++ {
++ "method": "GET",
++ "path": "/auth/login?next=%2Fmessages",
++ "auth": "anon",
++ "accept": "html",
++ "status": 302,
++ "kind": "html",
++ "location": "/oauth/authorize",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/auth/login?next=%2Fsettings",
++ "auth": "anon",
++ "accept": "html",
++ "status": 302,
++ "kind": "html",
++ "location": "/oauth/authorize",
++ "from": [
++ "page /"
++ ]
++ },
{
"method": "GET",
"path": "/sitemap.xml",
@@@ -1723,7 +1758,7 @@
},
{
"method": "GET",
-- "path": "/shared/shipped.js?v=bed628bacc75",
++ "path": "/shared/shipped.js?v=67bc1a154821",
"auth": "anon",
"accept": "html",
"status": 200,
ddiff --git a/test/fixtures/n-1/worker.json b/test/fixtures/n-1/worker.json
iindex 58b1fab..b3f2bfb 100644
---- a/test/fixtures/n-1/worker.json
++++ b/test/fixtures/n-1/worker.json
@@@ -1,7 +1,7 @@
{
"service": "chat",
-- "release": "143e52e371b2a659c0a407635f60fd256a4e619a",
-- "recorded_at": "2026-10-10T16:02:05.083Z",
++ "release": "e5199ddcd410bde4c6c8bcbad2d22985f517338a",
++ "recorded_at": "2026-10-16T17:41:47.755Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"engine": "postgresql",
"migrations": [
@@@ -32,6 +32,22 @@
{
"file": "0007_ticket_conversations.sql",
"sql": "-- phase: expand\n-- OpenVibe.Help's per-ticket conversation (capability chat.ticket.write,\n-- chat.ticket-conversation@1, added in openvibe-contracts 0.100.0). One conversation per\n-- (ticket id, calling service); two services on one ticket get two conversations and neither\n-- can read the other's. Messages carry the author's kind, subject and body (plain text, never\n-- HTML) and the author's own created_at; Chat orders by its own id (recorded_at is when it\n-- arrived). Service tokens are loopback-only and never granted to an app or an agent.\nCREATE TABLE IF NOT EXISTS ticket_conversations (\n id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n ticket_id text COLLATE \"C\" NOT NULL,\n service text COLLATE \"C\" NOT NULL,\n created_at text COLLATE \"C\" NOT NULL DEFAULT ov_now(),\n UNIQUE (ticket_id, service)\n);\nCREATE TABLE IF NOT EXISTS ticket_messages (\n id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n conversation_id bigint NOT NULL REFERENCES ticket_conversations(id) ON DELETE CASCADE,\n author_kind text COLLATE \"C\" NOT NULL CHECK (author_kind IN ('person', 'agent', 'staff')),\n author text COLLATE \"C\" NOT NULL,\n body text COLLATE \"C\" NOT NULL, -- the author's text, as sent; Chat never renders it\n created_at text COLLATE \"C\" NOT NULL, -- the author's clock (contract), not Chat's ordering\n recorded_at text COLLATE \"C\" NOT NULL DEFAULT ov_now()\n);\nCREATE INDEX IF NOT EXISTS idx_ticket_messages_conversation ON ticket_messages(conversation_id, id);\n"
++ },
++ {
++ "file": "0008_sdk_outbox.sql",
++ "sql": "-- phase: expand\n--\n-- SDK outbox rows use a JSONB envelope and millisecond timestamps. Keep events_outbox through the\n-- N-1 window; a later contract migration drops it after rollback no longer needs the old relay.\nCREATE TABLE IF NOT EXISTS service_outbox (\n id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n event_id text NOT NULL UNIQUE,\n envelope jsonb NOT NULL,\n traceparent text,\n created_at bigint NOT NULL,\n attempts integer NOT NULL DEFAULT 0,\n next_attempt_at bigint NOT NULL DEFAULT 0,\n sent_at bigint,\n seq bigint,\n rejected_at bigint,\n last_error text\n);\nCREATE INDEX IF NOT EXISTS service_outbox_due ON service_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;\nCREATE INDEX IF NOT EXISTS service_outbox_sent ON service_outbox (sent_at) WHERE sent_at IS NOT NULL;\n\n-- The old writer stores ISO 8601 UTC. Accept ov_now()'s UTC SQLite format as well.\nINSERT INTO service_outbox (event_id, envelope, created_at, attempts)\nSELECT event_id, event::jsonb,\n COALESCE((EXTRACT(EPOCH FROM ov_ts(created_at) AT TIME ZONE 'UTC') * 1000)::bigint, 0),\n attempts\nFROM events_outbox WHERE sent_at IS NULL\nON CONFLICT (event_id) DO NOTHING;\n"
++ },
++ {
++ "file": "0009_drop_live_mirror_outbox.sql",
++ "sql": "-- phase: contract\n-- after: 0006\n--\n-- The Live read mirror is retired (2026-10-05). 0006 dropped the twelve capture triggers that fed\n-- live_mirror_outbox and deferred the table itself; since then nothing reads or writes it\n-- (test/no-live-mirror.test.js keeps it that way). This drops the idle table. It waits for 0006's N-1\n-- window, so a rollback to the release before 0006 still finds the table.\nDROP TABLE IF EXISTS live_mirror_outbox;\n"
++ },
++ {
++ "file": "0010_control_log.sql",
++ "sql": "-- phase: expand\n-- ADR-048 amendment 2026-10-15 (server/registry/resource-control.js): the first answer to each control request\n-- OpenVibe.Services sent for a person's room, per (caller, idempotency_key), so a retry is never applied twice.\nCREATE TABLE chat_control_log (\n caller text COLLATE \"C\" NOT NULL, -- the service that asked (svc:services)\n idempotency_key text COLLATE \"C\" NOT NULL,\n owner_subject text COLLATE \"C\" NOT NULL, -- the room's owner the request named\n digest text COLLATE \"C\" NOT NULL, -- the request without its delivery fields\n response text NOT NULL,\n created_at bigint NOT NULL,\n PRIMARY KEY (caller, idempotency_key)\n);\n"
++ },
++ {
++ "file": "0011_telemetry.sql",
++ "sql": "-- phase: expand\n-- Request telemetry (openvibe-sdk/telemetry telemetrySchema()): one row per aggregated sample, pruned after 30 days (server/index.js).\nCREATE TABLE IF NOT EXISTS telemetry_samples (\n id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n at timestamptz NOT NULL,\n service text NOT NULL,\n project text,\n subject text,\n resource text,\n provider text,\n node text,\n cell text,\n region text,\n operation text NOT NULL,\n latency_ms double precision,\n queue_delay_ms double precision,\n ttfb_ms double precision,\n throughput_per_second double precision,\n bytes bigint,\n status text,\n cache_status text,\n cost_estimate double precision,\n route_epoch bigint,\n trace_id text,\n extra jsonb NOT NULL DEFAULT '{}'::jsonb\n);\nCREATE INDEX IF NOT EXISTS telemetry_samples_at_idx ON telemetry_samples (at);\nCREATE INDEX IF NOT EXISTS telemetry_samples_service_idx ON telemetry_samples (service, operation, at);\n"
}
],
"ledger": {
@@@ -90,6 +106,7 @@
"INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
"INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code, created_by_subject_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"INSERT INTO chat_ai_summaries (scope, subject_id, \"window\", overview, memory_json, timeline_json, message_count, window_message_count, last_message_id, window_label, window_start, window_end, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, ov_now()) ON CONFLICT(scope, subject_id, \"window\") DO UPDATE SET overview = excluded.overview, memory_json = excluded.memory_json, timeline_json = excluded.timeline_json, message_count = excluded.message_count, window_message_count = excluded.window_message_count, last_message_id = excluded.last_message_id, window_label = excluded.window_label, window_start = excluded.window_start, window_end = excluded.window_end, updated_at = ov_now()",
++ "INSERT INTO chat_control_log (caller, idempotency_key, owner_subject, digest, response, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING",
"INSERT INTO chat_ingress_applied (principal, family, key, body_hash, result, applied_at) VALUES ($1, $2, $3, $4, '{}', $5) ON CONFLICT DO NOTHING",
"INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
"INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id",
@@@ -105,7 +122,6 @@
"INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO dm_participants (conversation_id, user_id, subject_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
"INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
-- "INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES ($1, $2, $3, $4)",
"INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by) VALUES ($1, $2, $3, $4, $5, $6)",
"INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id) VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id",
@@@ -120,7 +136,9 @@
"INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
"INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id",
++ "INSERT INTO service_outbox (event_id, envelope, traceparent, created_at) VALUES ($1, $2, $3, $4)",
"INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
++ "INSERT INTO telemetry_samples (at, service, project, subject, resource, provider, node, cell, region, operation, latency_ms, queue_delay_ms, ttfb_ms, throughput_per_second, bytes, status, cache_status, cost_estimate, route_epoch, trace_id, extra) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21), ($22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42), ($43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63), ($64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84), ($85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100, $101, $102, $103, $104, $105), ($106, $107, $108, $109, $110, $111, $112, $113, $114, $115, $116, $117, $118, $119, $120, $121, $122, $123, $124, $125, $126), ($127, $128, $129, $130, $131, $132, $133, $134, $135, $136, $137, $138, $139, $140, $141, $142, $143, $144, $145, $146, $147), ($148, $149, $150, $151, $152, $153, $154, $155, $156, $157, $158, $159, $160, $161, $162, $163, $164, $165, $166, $167, $168), ($169, $170, $171, $172, $173, $174, $175, $176, $177, $178, $179, $180, $181, $182, $183, $184, $185, $186, $187, $188, $189), ($190, $191, $192, $193, $194, $195, $196, $197, $198, $199, $200, $201, $202, $203, $204, $205, $206, $207, $208, $209, $210), ($211, $212, $213, $214, $215, $216, $217, $218, $219, $220, $221, $222, $223, $224, $225, $226, $227, $228, $229, $230, $231), ($232, $233, $234, $235, $236, $237, $238, $239, $240, $241, $242, $243, $244, $245, $246, $247, $248, $249, $250, $251, $252), ($253, $254, $255, $256, $257, $258, $259, $260, $261, $262, $263, $264, $265, $266, $267, $268, $269, $270, $271, $272, $273), ($274, $275, $276, $277, $278, $279, $280, $281, $282, $283, $284, $285, $286, $287, $288, $289, $290, $291, $292, $293, $294), ($295, $296, $297, $298, $299, $300, $301, $302, $303, $304, $305, $306, $307, $308, $309, $310, $311, $312, $313, $314, $315), ($316, $317, $318, $319, $320, $321, $322, $323, $324, $325, $326, $327, $328, $329, $330, $331, $332, $333, $334, $335, $336), ($337, $338, $339, $340, $341, $342, $343, $344, $345, $346, $347, $348, $349, $350, $351, $352, $353, $354, $355, $356, $357), ($358, $359, $360, $361, $362, $363, $364, $365, $366, $367, $368, $369, $370, $371, $372, $373, $374, $375, $376, $377, $378), ($379, $380, $381, $382, $383, $384, $385, $386, $387, $388, $389, $390, $391, $392, $393, $394, $395, $396, $397, $398, $399), ($400, $401, $402, $403, $404, $405, $406, $407, $408, $409, $410, $411, $412, $413, $414, $415, $416, $417, $418, $419, $420), ($421, $422, $423, $424, $425, $426, $427, $428, $429, $430, $431, $432, $433, $434, $435, $436, $437, $438, $439, $440, $441), ($442, $443, $444, $445, $446, $447, $448, $449, $450, $451, $452, $453, $454, $455, $456, $457, $458, $459, $460, $461, $462), ($463, $464, $465, $466, $467, $468, $469, $470, $471, $472, $473, $474, $475, $476, $477, $478, $479, $480, $481, $482, $483), ($484, $485, $486, $487, $488, $489, $490, $491, $492, $493, $494, $495, $496, $497, $498, $499, $500, $501, $502, $503, $504), ($505, $506, $507, $508, $509, $510, $511, $512, $513, $514, $515, $516, $517, $518, $519, $520, $521, $522, $523, $524, $525), ($526, $527, $528, $529, $530, $531, $532, $533, $534, $535, $536, $537, $538, $539, $540, $541, $542, $543, $544, $545, $546), ($547, $548, $549, $550, $551, $552, $553, $554, $555, $556, $557, $558, $559, $560, $561, $562, $563, $564, $565, $566, $567), ($568, $569, $570, $571, $572, $573, $574, $575, $576, $577, $578, $579, $580, $581, $582, $583, $584, $585, $586, $587, $588), ($589, $590, $591, $592, $593, $594, $595, $596, $597, $598, $599, $600, $601, $602, $603, $604, $605, $606, $607, $608, $609), ($610, $611, $612, $613, $614, $615, $616, $617, $618, $619, $620, $621, $622, $623, $624, $625, $626, $627, $628, $629, $630), ($631, $632, $633, $634, $635, $636, $637, $638, $639, $640, $641, $642, $643, $644, $645, $646, $647, $648, $649, $650, $651), ($652, $653, $654, $655, $656, $657, $658, $659, $660, $661, $662, $663, $664, $665, $666, $667, $668, $669, $670, $671, $672), ($673, $674, $675, $676, $677, $678, $679, $680, $681, $682, $683, $684, $685, $686, $687, $688, $689, $690, $691, $692, $693), ($694, $695, $696, $697, $698, $699, $700, $701, $702, $703, $704, $705, $706, $707, $708, $709, $710, $711, $712, $713, $714), ($715, $716, $717, $718, $719, $720, $721, $722, $723, $724, $725, $726, $727, $728, $729, $730, $731, $732, $733, $734, $735), ($736, $737, $738, $739, $740, $741, $742, $743, $744, $745, $746, $747, $748, $749, $750, $751, $752, $753, $754, $755, $756), ($757, $758, $759, $760, $761, $762, $763, $764, $765, $766, $767, $768, $769, $770, $771, $772, $773, $774, $775, $776, $777), ($778, $779, $780, $781, $782, $783, $784, $785, $786, $787, $788, $789, $790, $791, $792, $793, $794, $795, $796, $797, $798), ($799, $800, $801, $802, $803, $804, $805, $806, $807, $808, $809, $810, $811, $812, $813, $814, $815, $816, $817, $818, $819), ($820, $821, $822, $823, $824, $825, $826, $827, $828, $829, $830, $831, $832, $833, $834, $835, $836, $837, $838, $839, $840), ($841, $842, $843, $844, $845, $846, $847, $848, $849, $850, $851, $852, $853, $854, $855, $856, $857, $858, $859, $860, $861), ($862, $863, $864, $865, $866, $867, $868, $869, $870, $871, $872, $873, $874, $875, $876, $877, $878, $879, $880, $881, $882), ($883, $884, $885, $886, $887, $888, $889, $890, $891, $892, $893, $894, $895, $896, $897, $898, $899, $900, $901, $902, $903), ($904, $905, $906, $907, $908, $909, $910, $911, $912, $913, $914, $915, $916, $917, $918, $919, $920, $921, $922, $923, $924), ($925, $926, $927, $928, $929, $930, $931, $932, $933, $934, $935, $936, $937, $938, $939, $940, $941, $942, $943, $944, $945), ($946, $947, $948, $949, $950, $951, $952, $953, $954, $955, $956, $957, $958, $959, $960, $961, $962, $963, $964, $965, $966), ($967, $968, $969, $970, $971, $972, $973, $974, $975, $976, $977, $978, $979, $980, $981, $982, $983, $984, $985, $986, $987), ($988, $989, $990, $991, $992, $993, $994, $995, $996, $997, $998, $999, $1000, $1001, $1002, $1003, $1004, $1005, $1006, $1007, $1008), ($1009, $1010, $1011, $1012, $1013, $1014, $1015, $1016, $1017, $1018, $1019, $1020, $1021, $1022, $1023, $1024, $1025, $1026, $1027, $1028, $1029), ($1030, $1031, $1032, $1033, $1034, $1035, $1036, $1037, $1038, $1039, $1040, $1041, $1042, $1043, $1044, $1045, $1046, $1047, $1048, $1049, $1050), ($1051, $1052, $1053, $1054, $1055, $1056, $1057, $1058, $1059, $1060, $1061, $1062, $1063, $1064, $1065, $1066, $1067, $1068, $1069, $1070, $1071), ($1072, $1073, $1074, $1075, $1076, $1077, $1078, $1079, $1080, $1081, $1082, $1083, $1084, $1085, $1086, $1087, $1088, $1089, $1090, $1091, $1092), ($1093, $1094, $1095, $1096, $1097, $1098, $1099, $1100, $1101, $1102, $1103, $1104, $1105, $1106, $1107, $1108, $1109, $1110, $1111, $1112, $1113), ($1114, $1115, $1116, $1117, $1118, $1119, $1120, $1121, $1122, $1123, $1124, $1125, $1126, $1127, $1128, $1129, $1130, $1131, $1132, $1133, $1134), ($1135, $1136, $1137, $1138, $1139, $1140, $1141, $1142, $1143, $1144, $1145, $1146, $1147, $1148, $1149, $1150, $1151, $1152, $1153, $1154, $1155), ($1156, $1157, $1158, $1159, $1160, $1161, $1162, $1163, $1164, $1165, $1166, $1167, $1168, $1169, $1170, $1171, $1172, $1173, $1174, $1175, $1176), ($1177, $1178, $1179, $1180, $1181, $1182, $1183, $1184, $1185, $1186, $1187, $1188, $1189, $1190, $1191, $1192, $1193, $1194, $1195, $1196, $1197), ($1198, $1199, $1200, $1201, $1202, $1203, $1204, $1205, $1206, $1207, $1208, $1209, $1210, $1211, $1212, $1213, $1214, $1215, $1216, $1217, $1218), ($1219, $1220, $1221, $1222, $1223, $1224, $1225, $1226, $1227, $1228, $1229, $1230, $1231, $1232, $1233, $1234, $1235, $1236, $1237, $1238, $1239), ($1240, $1241, $1242, $1243, $1244, $1245, $1246, $1247, $1248, $1249, $1250, $1251, $1252, $1253, $1254, $1255, $1256, $1257, $1258, $1259, $1260), ($1261, $1262, $1263, $1264, $1265, $1266, $1267, $1268, $1269, $1270, $1271, $1272, $1273, $1274, $1275, $1276, $1277, $1278, $1279, $1280, $1281), ($1282, $1283, $1284, $1285, $1286, $1287, $1288, $1289, $1290, $1291, $1292, $1293, $1294, $1295, $1296, $1297, $1298, $1299, $1300, $1301, $1302), ($1303, $1304, $1305, $1306, $1307, $1308, $1309, $1310, $1311, $1312, $1313, $1314, $1315, $1316, $1317, $1318, $1319, $1320, $1321, $1322, $1323), ($1324, $1325, $1326, $1327, $1328, $1329, $1330, $1331, $1332, $1333, $1334, $1335, $1336, $1337, $1338, $1339, $1340, $1341, $1342, $1343, $1344), ($1345, $1346, $1347, $1348, $1349, $1350, $1351, $1352, $1353, $1354, $1355, $1356, $1357, $1358, $1359, $1360, $1361, $1362, $1363, $1364, $1365), ($1366, $1367, $1368, $1369, $1370, $1371, $1372, $1373, $1374, $1375, $1376, $1377, $1378, $1379, $1380, $1381, $1382, $1383, $1384, $1385, $1386)",
"INSERT INTO ticket_conversations (ticket_id, service) VALUES ($1, $2) ON CONFLICT DO NOTHING",
"INSERT INTO ticket_messages (conversation_id, author_kind, author, body, created_at) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES ($1, $2, $3, $4) ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at WHERE excluded.valid_after_ms > token_revocations.valid_after_ms",
@@@ -236,6 +254,7 @@
"SELECT created_at FROM ctx_users WHERE id = $1",
"SELECT created_at FROM room_messages WHERE room_id = $1 AND user_id = $2 ORDER BY id DESC LIMIT 1",
"SELECT delivered FROM chat_ingress_applied WHERE principal = $1 AND family = $2 AND key = $3",
++ "SELECT digest, response FROM chat_control_log WHERE caller = $1 AND idempotency_key = $2",
"SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = $1",
"SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code",
"SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_name FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id LEFT JOIN ctx_users up ON e.user_id = up.id WHERE ((e.channel_owner_id = $1) OR (e.channel_owner_id IS NULL AND e.user_id = $2)) AND e.is_approved = 1 ORDER BY code",
@@@ -261,6 +280,7 @@
"SELECT id, blocker_id AS o FROM dm_blocks WHERE blocked_id = $1",
"SELECT id, created_at FROM ticket_conversations WHERE ticket_id = $1 AND service = $2",
"SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1",
++ "SELECT id, slug, owner_subject, archived_at FROM rooms WHERE slug = $1",
"SELECT id, state FROM calls WHERE state IN ('pending', 'ringing', 'active')",
"SELECT id, stream_id FROM chat_messages WHERE is_deleted = 0 AND auto_delete_at IS NOT NULL AND auto_delete_at <= ov_now() ORDER BY auto_delete_at ASC LIMIT $1",
"SELECT id, stream_id, channel_user_id FROM chat_messages WHERE id = $1 AND is_deleted = 0",
@@@ -286,8 +306,6 @@
"SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE s.id = $1",
"SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE s.user_id = $1 ORDER BY s.created_at DESC LIMIT $2",
"SELECT sent_at FROM account_data_events WHERE id = $1",
-- "SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100",
-- "SELECT slug, name, visibility, owner_subject, created_at FROM rooms WHERE archived_at IS NULL AND slug > $1 COLLATE \"C\" ORDER BY slug COLLATE \"C\" LIMIT $2",
"SELECT subject_id FROM ctx_users WHERE id = $1",
"SELECT table_name FROM information_schema.columns WHERE table_schema = current_schema() AND column_name = 'id'",
"SELECT u.id FROM network_blocks b JOIN ctx_users u ON u.subject_id = b.blocked_subject WHERE b.blocker_subject = $1 AND b.active = 1",
@@@ -344,8 +362,6 @@
"UPDATE dm_participants SET subject_id = $1 WHERE subject_id = $2",
"UPDATE dm_participants SET user_id = $1 WHERE conversation_id = $2 AND user_id = $3",
"UPDATE emotes SET media_url = $1, media_asset_id = $2 WHERE id = $3",
-- "UPDATE events_outbox SET attempts = attempts + 1, last_error = $1 WHERE seq = $2",
-- "UPDATE events_outbox SET sent_at = $1, attempts = attempts + 1, last_error = NULL WHERE seq = $2",
"UPDATE network_blocks SET blocked_subject = $1 WHERE blocked_subject = $2 AND blocker_subject = $3",
"UPDATE network_blocks SET blocker_subject = $1 WHERE blocker_subject = $2 AND blocked_subject = $3",
"UPDATE pending_ip_messages SET status = $1, reviewed_by = $2 WHERE id = $3",
@@@ -361,6 +377,7 @@
"UPDATE room_messages SET is_deleted = 1, deleted_by = $1 WHERE id = $2",
"UPDATE room_messages SET subject_id = $1 WHERE subject_id = $2",
"UPDATE room_messages SET user_id = $1 WHERE user_id = $2",
++ "UPDATE rooms SET archived_at = $1 WHERE id = $2 AND archived_at IS NULL",
"UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = $1",
"UPDATE rooms SET name = $1, topic = $2, visibility = $3, slow_seconds = $4, join_role = $5 WHERE id = $6",
"UPDATE rooms SET owner_subject = $1 WHERE owner_subject = $2",
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 2889f47..b2b3baf 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'token_revocations', 'chat_control_log',
++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
const INDEXES = [
ddiff --git a/test/room-ai-members.test.js b/test/room-ai-members.test.js
nnew file mode 100644
iindex 0000000..1f19fcd
---- /dev/null
++++ b/test/room-ai-members.test.js
@@@ -0,0 +1,130 @@
++'use strict';
++/**
++ * AI members of a room (server/rooms/ai-members.js, migrations/0012_room_ai_members.sql): the room's owner adds one;
++ * writing @handle makes it answer through OpenVibe.AI's chat.member.reply with the recent messages and the room's
++ * notes, as a room message with ai_member_id and its name, live to the room's sockets with a "writing" frame first.
++ * Only managers change AI members; only the first one named answers; it never answers itself; the room's daily cap and
++ * the switch hold; members can be added by account subject; the openvibe.chat pages show and manage them.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++// Required after boot(): the server's config reads the environment boot() sets.
++let aiClient, aiMembers, config;
++
++const t = suite('room AI members');
++const SITE = 'https://openvibe.chat';
++let h, ann, bob, cat;
++// openvibe.chat pages: the session cookie; a form comes from the site itself; redirects are answers.
++const web = async (method, path, u, fields) => {
++ const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };
++ const r = await fetch(`${h.base}${path}`, { method, headers, body: fields ? new URLSearchParams(fields).toString() : undefined, redirect: 'manual' });
++ return { status: r.status, text: await r.text() };
++};
++const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
++const calls = [];
++const settle = (ms = 150) => new Promise((r) => setTimeout(r, ms));
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ aiClient = require('../server/ai/client');
++ aiMembers = require('../server/rooms/ai-members');
++ config = require('../server/config');
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ cat = h.addUser('cat', { subject: ids.newId('user') });
++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ aiClient._setClient({
++ runs: {
++ create: async (workflow, input, o) => {
++ calls.push({ workflow, input, o });
++ return { run: { status: 'succeeded', output: { text: `Rice and lentils, ${input.asked_by}. (${input.memory || 'no notes'})` } } };
++ },
++ },
++ });
++ assert.strictEqual((await api('POST', '/', ann, { name: 'Our Kitchen', visibility: 'private' })).status, 201);
++ const added = await api('POST', '/our-kitchen/members', ann, { subject: `user:${h.live.users.get(bob.id).subject_id}` });
++ assert.strictEqual(added.status, 200, added.text);
++ assert.strictEqual(added.body.username, 'bob', 'a member can be added by account subject (what Food knows)');
++});
++
++t('only the room\'s owner adds, changes and removes AI members; names are checked', async () => {
++ assert.strictEqual((await api('POST', '/our-kitchen/ai-members', bob, { name: 'Pantry' })).status, 403);
++ assert.strictEqual((await api('POST', '/our-kitchen/ai-members', ann, { name: '!' })).status, 422);
++ const r = await api('POST', '/our-kitchen/ai-members', ann, { name: 'Pantry', persona: 'A cheerful cook', memory: 'Bob is vegetarian' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual([r.body.name, r.body.handle, r.body.memory], ['Pantry', 'pantry', 'Bob is vegetarian']);
++ assert.strictEqual((await api('POST', '/our-kitchen/ai-members', ann, { name: 'pantry' })).status, 409, 'one @pantry per room');
++ const seen = await api('GET', '/our-kitchen/ai-members', bob);
++ assert.deepStrictEqual(Object.keys(seen.body.ai_members[0]).sort(), ['enabled', 'handle', 'id', 'name'], 'members see who it is, not its set-up');
++ assert.strictEqual((await api('GET', '/our-kitchen/ai-members', ann)).body.ai_members[0].persona, 'A cheerful cook');
++ assert.strictEqual((await api('PATCH', `/our-kitchen/ai-members/${r.body.id}`, bob, { memory: 'x' })).status, 403);
++});
++
++t('@pantry answers with the recent messages and the room\'s notes, live, attributed to the room', async () => {
++ const bobWs = await h.ws({ token: bob.token }); bobWs.sendJson({ type: 'join_room', room: 'our-kitchen' });
++ await bobWs.next((m) => m.type === 'room_joined');
++ await api('POST', '/our-kitchen/messages', ann, { message: 'home late tonight' });
++ const asked = await api('POST', '/our-kitchen/messages', bob, { message: 'hey @Pantry what can we cook?' });
++ assert.strictEqual(asked.status, 201);
++ await bobWs.next((m) => m.type === 'room_ai_typing' && m.name === 'Pantry');
++ const reply = await bobWs.next((m) => m.type === 'room_message' && m.message.ai_member_id);
++ assert.deepStrictEqual([reply.message.display_name, reply.message.username, reply.message.user_id], ['Pantry', 'pantry', 0]);
++ assert.strictEqual(reply.message.message, 'Rice and lentils, Bob. (Bob is vegetarian)', 'asked_by is the display name');
++ const call = calls[calls.length - 1];
++ assert.strictEqual(call.workflow, 'chat.member.reply');
++ assert.ok(call.input.transcript.includes('Ann: home late tonight') && call.input.transcript.includes('Bob: hey @Pantry'), call.input.transcript);
++ assert.deepStrictEqual([call.input.ai_name, call.input.persona, call.input.room], ['Pantry', 'A cheerful cook', 'Our Kitchen']);
++ assert.deepStrictEqual(call.o.attribution, { service: 'chat', type: 'room', id: String(reply.message.room_id), label: 'Our Kitchen' });
++ const hist = (await api('GET', '/our-kitchen/messages', ann)).body.messages;
++ assert.strictEqual(hist[hist.length - 1].display_name, 'Pantry', 'the history names it');
++ bobWs.close();
++});
++
++t('no answer without a mention, to itself, or past the room\'s day; mentions are whole words', async () => {
++ const before = calls.length;
++ await api('POST', '/our-kitchen/messages', ann, { message: 'the pantry is empty' });
++ await api('POST', '/our-kitchen/messages', ann, { message: 'mail me at [email protected]' });
++ await settle();
++ assert.strictEqual(calls.length, before, 'no @pantry, no answer');
++ const members = await aiMembers.list({ id: (await h.db.get("SELECT id FROM rooms WHERE slug = 'our-kitchen'")).id });
++ assert.strictEqual(aiMembers.mentioned(members, '@pantry, hi'), members[0]);
++ assert.strictEqual(aiMembers.mentioned(members, 'x@pantry'), null);
++
++ const keep = config.ai.memberRepliesPerDay;
++ config.ai.memberRepliesPerDay = 1;
++ try {
++ await api('POST', '/our-kitchen/messages', ann, { message: '@pantry again?' });
++ await settle();
++ const last = (await api('GET', '/our-kitchen/messages', ann)).body.messages.pop();
++ assert.match(last.message, /my limit/, 'past the day\'s cap it says so instead of calling AI');
++ assert.strictEqual(calls.length, before);
++ } finally { config.ai.memberRepliesPerDay = keep; }
++});
++
++t('the switch: CHAT_AI_MEMBERS=0 keeps every AI member quiet', async () => {
++ const before = calls.length;
++ config.ai.members = false;
++ try {
++ await api('POST', '/our-kitchen/messages', ann, { message: '@pantry are you there?' });
++ await settle();
++ assert.strictEqual(calls.length, before);
++ } finally { config.ai.members = true; }
++});
++
++t('the pages: AI messages are badged and not profile links; settings add and remove AI members', async () => {
++ const page = await web('GET', '/r/our-kitchen', ann);
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('<span class="oc-name oc-ai-name">Pantry</span> <span class="oc-badge oc-ai"'), 'badged');
++ assert.ok(page.text.includes('<code>@pantry</code>'), 'the room says how to ask it');
++ const settings = await web('GET', '/r/our-kitchen/settings', ann);
++ assert.ok(settings.text.includes('id="oc-ai"') && settings.text.includes('Notes it always knows'));
++ const add = await web('POST', '/r/our-kitchen/ai-members', ann, { name: 'Coach', persona: 'Keeps us honest' });
++ assert.strictEqual(add.status, 303, add.text.slice(0, 200));
++ const coach = (await api('GET', '/our-kitchen/ai-members', ann)).body.ai_members.find((m) => m.handle === 'coach');
++ assert.ok(coach);
++ assert.strictEqual((await web('POST', `/r/our-kitchen/ai-members/${coach.id}/delete`, ann, { go: '1' })).status, 303);
++ assert.ok(!(await api('GET', '/our-kitchen/ai-members', ann)).body.ai_members.some((m) => m.handle === 'coach'));
++});
++
++t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });