openvibers/openvibe.chat Commits
Chat: a room's memory, kept between summaries (#504)
Changes
nnew file mode 100644@@@ -0,0 +1,23 @@++-- phase: expand++-- A room's memory (plan 2026-11-10, part 3): what the summariser may keep about a room between++-- summaries — running topics, standing facts ("the stream is on Fridays"), decisions and asks. Each++-- entry names the room messages it came from (ids only; never a copy of deleted text), when it was++-- learned and a confidence. A moderator may correct or remove any entry; a person may remove an entry++-- made from their own messages. It goes with the account export and deletion.++CREATE TABLE IF NOT EXISTS room_memory (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,++ kind text COLLATE "C" NOT NULL DEFAULT 'fact' CHECK (kind IN ('topic', 'fact', 'decision', 'request')),++ text text COLLATE "C" NOT NULL CHECK (char_length(text) BETWEEN 3 AND 400),++ source_message_ids jsonb NOT NULL DEFAULT '[]'::jsonb,++ source_subjects jsonb NOT NULL DEFAULT '[]'::jsonb,++ confidence integer NOT NULL DEFAULT 50 CHECK (confidence BETWEEN 0 AND 100),++ learned_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ learned_by text COLLATE "C" NOT NULL DEFAULT 'ai',++ updated_at text COLLATE "C",++ updated_by text COLLATE "C",++ removed_at text COLLATE "C",++ removed_by text COLLATE "C"++);++CREATE INDEX IF NOT EXISTS idx_room_memory_room ON room_memory (room_id, removed_at, id DESC);++CREATE INDEX IF NOT EXISTS idx_room_memory_room_text ON room_memory (room_id, text);@@@ -24,6 +24,7 @@ const db = require('../db/database');const aiClient = require('./client');const extractive = require('./extractive');const consent = require('../net/consent');++const memory = require('../rooms/memory');// ── Tunables (Live's) ────────────────────────────────────────────────────────const TICK_MS = config.ai.tickMs;@@@ -254,7 +255,9 @@ async function _refreshRoom(room) {if (!rows.length) return false;const now = Date.now();const { label: windowLabel, start: windowStart } = _windowOf(rows, now);-- const priorMemory = prior ? (prior.memory_json || '') : '';++ // The room's memory is part of what the summariser reads: standing facts from earlier summaries.++ const shared = await memory.forPrompt(room).catch(() => '');++ const priorMemory = [prior ? (prior.memory_json || '') : '', shared].filter(Boolean).join('\n');let priorTl = [];try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }@@@ -283,7 +286,18 @@ async function _refreshRoom(room) {people: _people(rows),topics_json: JSON.stringify(_topics(rows)),});-- console.log(`[ChatAI] room ${room.slug} refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);++ // Keep what the summary learned about the room, with the messages it came from. A removed entry is not++ // recreated (memory.remember skips known and removed text).++ let remembered = 0;++ if (parsed.memory) {++ remembered = await memory.remember(room, {++ memory: parsed.memory,++ source_message_ids: rows.slice(-30).map((r) => r.id),++ source_subjects: [...new Set(rows.map((r) => r.subject_id).filter(Boolean))],++ confidence: 45,++ }).catch((e) => { console.warn(`[ChatAI] room ${room.slug} memory:`, e.message); return 0; });++ }++ console.log(`[ChatAI] room ${room.slug} refreshed (${newCount} new, ${rows.length} consenting, ${remembered} remembered, window=${windowLabel})`);return true;}@@@ -179,6 +179,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settinapp.use('/api/v1/rooms', require('./chat/play-queue-routes').service);// A room's AI insight (plan 2026-11-10): public, one shape for Global and, in part 2, every room.app.use('/api/v1/rooms', require('./chat/room-insight-routes'));++ // A room's memory (part 3): what the summariser keeps between summaries, for its members.++ app.use('/api/v1/rooms', require('./chat/memory-routes'));app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve@@@ -64,6 +64,8 @@ const EXPORTS = [['ai_members_added.json', `SELECT * FROM room_ai_members WHERE created_by_subject = ANY(@s) ORDER BY id DESC LIMIT ${LIMIT}`],// Work they put on a workspace room's queue (server/rooms/workspace.js), with its results.['room_tasks.json', `SELECT t.id, r.slug AS room, t.title, t.details, t.kind, t.status, t.via, t.result, t.result_url, t.sources, t.error, t.created_at, t.finished_at FROM room_tasks t JOIN rooms r ON r.id = t.room_id WHERE t.requested_subject = ANY(@s) OR t.requested_by = ANY(@ids) ORDER BY t.id DESC LIMIT ${LIMIT}`],++ // What rooms remember from their messages, and what they wrote themselves (server/rooms/memory.js).++ ['room_memory.json', `SELECT rm.id, r.slug AS room, rm.kind, rm.text, rm.confidence, rm.learned_at, rm.learned_by FROM room_memory rm JOIN rooms r ON r.id = rm.room_id WHERE rm.learned_by = ANY(@s) OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(rm.source_subjects) x WHERE x = ANY(@s)) ORDER BY rm.id DESC LIMIT ${LIMIT}`],// Hooks they made into a workspace room (server/rooms/hooks.js), without their secrets.['room_hooks.json', `SELECT h.id, r.slug AS room, h.label, h.queue_kind, h.deliveries, h.last_at, h.created_at FROM room_hooks h JOIN rooms r ON r.id = h.room_id WHERE h.created_by = ANY(@ids) ORDER BY h.created_at DESC LIMIT ${LIMIT}`],['vibe_usage.json', `SELECT * FROM vibe_usage WHERE user_id = ANY(@ids) ORDER BY day DESC LIMIT ${LIMIT}`],@@@ -150,6 +152,7 @@ const ERASE_AFTER_ROOMS = [['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],['activity', 'DELETE FROM chat_activity WHERE user_id = ANY(@ids) OR actor_user_id = ANY(@ids)'],++ ['room_memory', "DELETE FROM room_memory WHERE learned_by = ANY(@s) OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(room_memory.source_subjects) x WHERE x = ANY(@s))"],['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],// Friends and conversation view preferences from the Chat app shell.['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++/**++ * A room's memory (plan 2026-11-10, part 3): GET/POST/PATCH/DELETE /api/v1/rooms/:id/memory.++ *++ * GET /:id/memory members: the room's active entries, newest first, with sources++ * GET /:id/memory?mine=1 the same (the person's own view)++ * POST /:id/memory a moderator adds an entry { text, kind?, confidence?, source_message_ids? }++ * PATCH /:id/memory/:entryId a moderator corrects it { text?, kind?, confidence? }++ * DELETE /:id/memory/:entryId a moderator removes any; a person removes one from their own messages++ * DELETE /:id/memory?mine=1 a person forgets everything learned from their own messages here++ *++ * Reads need a signed-in person who may read the room (a public room: anyone signed in; a private one:++ * its members and staff), because memory is what the room keeps about its people. Writes are a moderator's,++ * except the person's own removal. A DM and a chat with Vibe are not rooms here.++ */++const express = require('express');++const router = express.Router();++const rooms = require('../rooms/rooms');++const memory = require('../rooms/memory');++const { optionalAuth } = require('../auth/auth');++++function send(res, status, body) { return res.status(status).json(body); }++++async function resolve(req, res) {++ const id = String(req.params.id || '');++ const room = await rooms.bySlug(id);++ if (!room || Number(room.assistant) === 1) { send(res, 404, { error: 'Room memory not found' }); return null; }++ if (!req.user) { send(res, 401, { error: 'Sign in to read what this room remembers' }); return null; }++ const access = await rooms.access(room, req.user);++ // Members and staff only: memory is what the room keeps about its people, not the public feed.++ if (!access.read || (!access.role && !access.moderate)) { send(res, 404, { error: 'Room memory not found' }); return null; }++ return { room, access };++}++++function fail(res, err) {++ if (err instanceof memory.MemoryError || err instanceof rooms.RoomError) return send(res, err.status, { error: err.message, code: err.code });++ console.error('[RoomMemory]', err && err.message);++ return send(res, 500, { error: 'Something went wrong' });++}++++router.get('/:id/memory', optionalAuth, async (req, res) => {++ try {++ const x = await resolve(req, res); if (!x) return;++ const entries = await memory.forRoom(x.room, { subject: req.user.subject_id || null });++ res.set('Cache-Control', 'private, no-store');++ return res.json({ room: { slug: x.room.slug, name: x.room.name }, entries, kinds: memory.KINDS,++ can_edit: !!x.access.moderate, me: req.user.subject_id || null });++ } catch (err) { return fail(res, err); }++});++++router.post('/:id/memory', optionalAuth, async (req, res) => {++ try {++ const x = await resolve(req, res); if (!x) return;++ if (!x.access.moderate) return send(res, 403, { error: 'Only a moderator adds to the room\'s memory' });++ const entry = await memory.add(x.room, req.user, req.body || {});++ return res.status(201).json({ entry });++ } catch (err) { return fail(res, err); }++});++++router.patch('/:id/memory/:entryId', optionalAuth, async (req, res) => {++ try {++ const x = await resolve(req, res); if (!x) return;++ if (!x.access.moderate) return send(res, 403, { error: 'Only a moderator corrects the room\'s memory' });++ const entry = await memory.correct(x.room, req.user, Number(req.params.entryId), req.body || {});++ return res.json({ entry });++ } catch (err) { return fail(res, err); }++});++++router.delete('/:id/memory', optionalAuth, async (req, res) => {++ try {++ if (String(req.query.mine || '') !== '1') return send(res, 400, { error: 'Pass ?mine=1 to forget what was learned from your messages' });++ const x = await resolve(req, res); if (!x) return;++ if (!req.user.subject_id) return send(res, 403, { error: 'Only a signed-in person has messages here' });++ return res.json(await memory.removeMine(x.room, req.user.subject_id));++ } catch (err) { return fail(res, err); }++});++++router.delete('/:id/memory/:entryId', optionalAuth, async (req, res) => {++ try {++ const x = await resolve(req, res); if (!x) return;++ return res.json(await memory.remove(x.room, req.user, Number(req.params.entryId), { canModerate: !!x.access.moderate }));++ } catch (err) { return fail(res, err); }++});++++module.exports = router;@@@ -282,7 +282,7 @@ function workspacePrompt(member, room) {}/** The member's instructions for chat.member.turn (the same rules as AI's chat.member.reply template, plus tools). */--function systemPrompt(member, room) {++function systemPrompt(member, room, roomMemory = '') {if (Number(room.workspace) === 1) return workspacePrompt(member, room);return [`You are ${member.name}, an AI member of the chat room "${String(room.name || room.slug).slice(0, 120)}" on OpenVibe.Chat. The people in the room added you to their conversation.`,@@@ -295,6 +295,7 @@ function systemPrompt(member, room) {'- Never invent facts about the people, their plans or their things beyond what the messages, notes, context and tools say. When you do not know, say so and ask.','- You can only write in this chat and use these tools. Do not claim to have done anything else (bought, booked, sent, saved, set a reminder).','- No threats, nothing sexual involving minors, and never anyone\'s private details. Swearing in jest is fine if the room does it.',++ roomMemory ? `What this room remembers between conversations (shared notes, use when they help, do not recite them):\n${roomMemory}` : '',].filter((l, i, a) => l !== '' || a[i - 1] !== '').join('\n');}@@@ -321,7 +322,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, texconst tz = timezoneOf(timezone);const prompt = userPrompt({ member, context, transcript, askedBy, text });const messages = [-- { role: 'system', content: vibe ? vibePrompt(member, askedBy, memory, { timezone: tz, ownTools: tools.some((t) => t.function && t.function.name === 'my_day') }) : systemPrompt(member, room) },++ { role: 'system', content: vibe ? vibePrompt(member, askedBy, memory, { timezone: tz, ownTools: tools.some((t) => t.function && t.function.name === 'my_day') }) : systemPrompt(member, room, await require('./memory').forPrompt(room).catch(() => '')) },// Pictures the person sent (picturesFor): the model sees them with the text.{ role: 'user', content: images.length ? [{ type: 'text', text: `${prompt}\n\n(${images.length === 1 ? 'The picture they sent is' : 'The pictures they sent are'} attached.)` }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : prompt },];nnew file mode 100644@@@ -0,0 +1,185 @@++'use strict';++/**++ * A room's memory (plan 2026-11-10, part 3): what the summariser may keep about a room between summaries.++ *++ * forPrompt(room) the shared notes a summariser or an AI member reads, one line each++ * forRoom(room) the page's read: active entries, newest first, with their source messages++ * remember(room, { text, kind, … }) the summariser's write (deduplicated, capped)++ * add / correct / remove a moderator's hand; a person's own removal (their messages sourced it)++ *++ * An entry names the room messages it came from (ids) and the subjects of their authors, so a person can++ * remove what was learned from their own lines, and the account export/deletion can carry it. Removed++ * entries keep their row (soft delete) so a summariser does not recreate them from an old batch.++ */++const db = require('../db/database');++++const KINDS = ['topic', 'fact', 'decision', 'request'];++const MAX_TEXT = 400;++const MAX_ACTIVE = 80;++const MAX_SOURCES = 30;++const PROMPT_LIMIT = 12;++++class MemoryError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++const fail = (status, code, message) => { throw new MemoryError(status, code, message); };++const TEXT_MIN = 3;++++function _cleanText(text) {++ return String(text == null ? '' : text).replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);++}++function _kind(kind) { return KINDS.includes(kind) ? kind : 'fact'; }++function _ids(value) {++ if (!Array.isArray(value)) return [];++ return [...new Set(value.map(Number).filter((n) => Number.isInteger(n) && n > 0))].slice(0, MAX_SOURCES);++}++function _confidence(value, fallback = 50) {++ const n = Number(value);++ return Number.isFinite(n) ? Math.max(0, Math.min(100, Math.round(n))) : fallback;++}++function _norm(text) { return String(text || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); }++function _parseJson(raw) { if (Array.isArray(raw)) return raw; try { const v = JSON.parse(raw || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }++++/** A page's entry: sources are resolved now, so a deleted message never comes back as an entry's text. */++async function _shape(row, sources, subject = null) {++ return {++ id: Number(row.id),++ kind: row.kind,++ text: row.text,++ confidence: Number(row.confidence) || 0,++ learned_at: row.learned_at || null,++ learned_by: row.learned_by || 'ai',++ updated_at: row.updated_at || null,++ updated_by: row.updated_by || null,++ mine: !!(subject && _parseJson(row.source_subjects).includes(subject)),++ sources: (sources || []).map((s) => ({++ id: Number(s.id), author: s.username || 'someone', at: s.created_at || null,++ text: String(s.message || '').slice(0, 300),++ })),++ };++}++++/** Resolve the stored source ids against the live room messages (deleted and AI-member lines are gone). */++async function _sourcesFor(rows) {++ const ids = [...new Set((rows || []).flatMap((r) => _ids(_parseJson(r.source_message_ids))))];++ if (!ids.length) return new Map();++ const marks = ids.map(() => '?').join(',');++ const found = await db.all(`SELECT m.id, m.message, m.created_at, u.username++ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id++ WHERE m.id IN (${marks}) AND m.is_deleted = 0 AND m.ai_member_id IS NULL`, ids) || [];++ const byRoom = new Map();++ for (const m of found) byRoom.set(Number(m.id), m);++ return byRoom;++}++++/** The active entries of one room, newest first, with their source messages. */++async function forRoom(room, { limit = 50, subject = null } = {}) {++ const rows = await db.all('SELECT * FROM room_memory WHERE room_id = ? AND removed_at IS NULL ORDER BY learned_at DESC, id DESC LIMIT ?',++ [room.id, Math.min(200, Math.max(1, Number(limit) || 50))]) || [];++ const byId = await _sourcesFor(rows);++ return Promise.all(rows.map((r) => _shape(r, _ids(_parseJson(r.source_message_ids)).map((id) => byId.get(id)).filter(Boolean), subject)));++}++++/** What a summariser or an AI member in the room reads. Empty when there is nothing worth saying. */++async function forPrompt(room, { limit = PROMPT_LIMIT } = {}) {++ const rows = await db.all(`SELECT kind, text FROM room_memory++ WHERE room_id = ? AND removed_at IS NULL AND confidence >= 30 ORDER BY learned_at DESC, id DESC LIMIT ?`,++ [room.id, Math.max(1, limit)]) || [];++ return rows.map((r) => `- [${r.kind}] ${r.text}`).join('\n');++}++++/**++ * The summariser's write: split the model's memory line into short entries, skip any already known (or++ * removed) for this room, and keep at most MAX_ACTIVE active. The window's message ids and their authors'++ * subjects are the sources; confidence is the summary's, not a quoted line's.++ */++async function remember(room, { memory, source_message_ids: sourceIds = [], source_subjects: sourceSubjects = [], confidence = 40, learned_by: learnedBy = 'ai' } = {}) {++ const lines = String(memory || '')++ .split(/\n+|(?<=[.!?])\s+/)++ .map((s) => s.replace(/^[-•*]\s*/, '').replace(/\s+/g, ' ').trim())++ .filter((s) => s.length >= 12 && s.length <= MAX_TEXT)++ .slice(0, 20);++ if (!lines.length) return 0;++ const ids = _ids(sourceIds);++ const subjects = [...new Set((Array.isArray(sourceSubjects) ? sourceSubjects : []).map(String).filter((s) => /^usr_/.test(s)))];++ const existing = new Set((await db.all('SELECT text FROM room_memory WHERE room_id = ?', [room.id]) || []).map((r) => _norm(r.text)));++ let added = 0;++ for (const text of lines) {++ const key = _norm(text);++ if (!key || existing.has(key)) continue;++ const active = (await db.get('SELECT COUNT(*) AS n FROM room_memory WHERE room_id = ? AND removed_at IS NULL', [room.id]) || {}).n || 0;++ if (Number(active) >= MAX_ACTIVE) break;++ await db.run(`INSERT INTO room_memory (room_id, kind, text, source_message_ids, source_subjects, confidence, learned_by)++ VALUES (?, 'fact', ?, ?::jsonb, ?::jsonb, ?, ?)`,++ [room.id, text, JSON.stringify(ids), JSON.stringify(subjects), _confidence(confidence, 40), String(learnedBy).slice(0, 80)]);++ existing.add(key);++ added++;++ }++ return added;++}++++/** A moderator's entry (or a correction of an AI one). */++async function add(room, actor, { text, kind = 'fact', confidence = 80, source_message_ids: sourceIds = [] } = {}) {++ const t = _cleanText(text);++ if (t.length < TEXT_MIN) fail(422, 'memory.text', 'An entry needs a few words');++ const ids = _ids(sourceIds);++ const subjects = ids.length++ ? (await db.all(`SELECT subject_id FROM room_messages WHERE id IN (${ids.map(() => '?').join(',')}) AND room_id = ? AND is_deleted = 0`, [...ids, room.id]) || []).map((r) => r.subject_id).filter(Boolean)++ : [];++ const res = await db.run(`INSERT INTO room_memory (room_id, kind, text, source_message_ids, source_subjects, confidence, learned_by)++ VALUES (?, ?, ?, ?::jsonb, ?::jsonb, ?, ?)`,++ [room.id, _kind(kind), t, JSON.stringify(ids), JSON.stringify([...new Set(subjects)]), _confidence(confidence, 80), actor && actor.subject_id ? actor.subject_id : 'moderator']);++ return await _one(room, Number(res.lastInsertRowid));++}++++async function _one(room, id) {++ const row = await db.get('SELECT * FROM room_memory WHERE id = ? AND room_id = ?', [id, room.id]);++ if (!row) return null;++ const byId = await _sourcesFor([row]);++ return _shape(row, _ids(_parseJson(row.source_message_ids)).map((sid) => byId.get(sid)).filter(Boolean));++}++++/** A moderator corrects the words, kind or confidence of an entry. */++async function correct(room, actor, id, patch = {}) {++ const row = await db.get('SELECT * FROM room_memory WHERE id = ? AND room_id = ? AND removed_at IS NULL', [id, room.id]);++ if (!row) fail(404, 'memory.not_found', 'No such entry');++ const text = patch.text !== undefined ? _cleanText(patch.text) : row.text;++ if (text.length < TEXT_MIN) fail(422, 'memory.text', 'An entry needs a few words');++ const kind = patch.kind !== undefined ? _kind(patch.kind) : row.kind;++ const confidence = patch.confidence !== undefined ? _confidence(patch.confidence, row.confidence) : row.confidence;++ await db.run('UPDATE room_memory SET text = ?, kind = ?, confidence = ?, updated_at = ov_now(), updated_by = ? WHERE id = ?',++ [text, kind, confidence, actor && actor.subject_id ? actor.subject_id : 'moderator', row.id]);++ return await _one(room, row.id);++}++++/**++ * Remove an entry. A moderator removes any; a person removes one made from their own messages. Soft:++ * the row stays so a later summary does not bring the same words back.++ */++async function remove(room, actor, id, { canModerate = false } = {}) {++ const row = await db.get('SELECT * FROM room_memory WHERE id = ? AND room_id = ? AND removed_at IS NULL', [id, room.id]);++ if (!row) fail(404, 'memory.not_found', 'No such entry');++ const mine = !!(actor && actor.subject_id && _parseJson(row.source_subjects).includes(actor.subject_id));++ if (!canModerate && !mine) fail(403, 'memory.not_yours', 'Only a room moderator, or a person whose messages it came from, removes this');++ await db.run('UPDATE room_memory SET removed_at = ov_now(), removed_by = ? WHERE id = ?',++ [actor && actor.subject_id ? actor.subject_id : 'moderator', row.id]);++ return { removed: true };++}++++/** A person's "forget what was learned from my messages" in one room. */++async function removeMine(room, subject) {++ if (!/^usr_/.test(String(subject || ''))) return { removed: 0 };++ const rows = await db.all('SELECT id, source_subjects FROM room_memory WHERE room_id = ? AND removed_at IS NULL', [room.id]) || [];++ let removed = 0;++ for (const r of rows) {++ if (!_parseJson(r.source_subjects).includes(subject)) continue;++ await db.run('UPDATE room_memory SET removed_at = ov_now(), removed_by = ? WHERE id = ?', [subject, r.id]);++ removed++;++ }++ return { removed };++}++++module.exports = {++ KINDS, MAX_TEXT, MemoryError,++ forRoom, forPrompt, remember, add, correct, remove, removeMine, _shape, _norm,++};@@@ -894,7 +894,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/aconst roomRoutes = require('../rooms/routes');const roomsOrError = (fn) => async (req, res, next) => {try { return await fn(req, res, next); } catch (err) {-- if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);++ if (err instanceof rooms.RoomError || err instanceof require('../rooms/memory').MemoryError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);return next(err);}};@@@ -1275,7 +1275,8 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';body: roomShell(`${ui.head({ title: `AI members in #${esc(room.name)}`, back: { href: await channelUrl(room, '/settings'), label: 'Server settings' } })}${saved}${error}${can.manage ? `<section class="oc-set"><h2>AI insight</h2><form class="oc-form oc-set-form" method="post" action="/r/${esc(room.slug)}/settings"><input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Summarise this channel<small>An overview and timeline of the channel, written for someone arriving now. Only messages from people who allowed AI use of them are read.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>--<p><button type="submit">Save</button></p></form></section>` : ''}++<p><button type="submit">Save</button></p></form>++<p><a href="/r/${esc(room.slug)}/memory">What this channel remembers</a></p></section>` : ''}${aiSection}`),});}@@@ -1301,7 +1302,8 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room<input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">AI insight<small>An overview and timeline of this room, written for someone arriving now. Only messages from people who allowed AI use of them are read; a private room is off until you turn this on.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(room.assistant) !== 1 ? `<input type="hidden" name="has_workspace" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Work together here<small>A work queue beside the conversation, alerts from other systems and a shared screen</small></span><input type="checkbox" class="oc-switch" name="workspace" value="1"${rooms.isWorkspace(room) ? ' checked' : ''}></label>` : ''}${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}--<p><button type="submit">Save</button></p></form></section>` : ''}++<p><button type="submit">Save</button></p></form>++<p><a href="/r/${esc(room.slug)}/memory">What this room remembers</a> <span class="oc-muted oc-small">— the notes the AI keeps between summaries; you can correct or remove them.</span></p></section>` : ''}<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2><p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p><form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>@@@ -1319,6 +1321,101 @@ ${attachList}`),});});++ // ── What this room remembers (plan 2026-11-10, part 3): the summariser's shared notes, with where each++ // one came from, a moderator's correction/removal and a person's own "forget mine". Plain markup. ──++ router.get('/r/:slug/memory', async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room || Number(room.assistant) === 1) return res.redirect(303, '/rooms');++ const can = await rooms.access(room, actor.user);++ if (!can.read || (!can.role && !can.moderate)) return res.redirect(303, '/rooms');++ const memory = require('../rooms/memory');++ const entries = await memory.forRoom(room, { subject: actor.user.subject_id || null });++ const saved = req.query.saved ? notice('ok', 'Saved.') : '';++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ const mineServers = (await require('../rooms/servers').list(actor.user)).mine;++ const listed = await rooms.list(actor.user);++ const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);++ const kindOptions = (cur) => ['topic', 'fact', 'decision', 'request'].map((k) => `<option value="${k}"${k === cur ? ' selected' : ''}>${k}</option>`).join('');++ const entryView = (e) => `<li class="oc-card oc-memory-entry">++<details class="oc-fold"><summary><span><span class="oc-badge">${esc(e.kind)}</span> ${esc(e.text)} <span class="oc-muted oc-small">${Number(e.confidence) || 0}% · learned ${esc(ui.when(e.learned_at).text || '')}</span></span>${IC.chevron}</summary>++${e.sources.length ? `<ul class="oc-muted oc-small">${e.sources.map((m) => `<li><strong>${esc(m.author)}</strong> <time>${esc(ui.when(m.at).text || '')}</time>: ${esc(m.text)}</li>`).join('')}</ul>` : '<p class="oc-muted oc-small">Its source messages are gone.</p>'}++${can.moderate ? `<form class="oc-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/memory/${Number(e.id)}/edit">++<label>Words <input name="text" maxlength="400" value="${esc(e.text)}"></label>++<label>Kind <select name="kind">${kindOptions(e.kind)}</select></label>++<label>Confidence <input name="confidence" type="number" min="0" max="100" value="${Number(e.confidence) || 0}"></label>++<button type="submit">Save</button></form>++<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/memory/${Number(e.id)}/remove"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove</span></button></form>`++: e.mine ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/memory/${Number(e.id)}/remove"><button type="submit" class="oc-link">Remove this (it came from my messages)</button></form>` : ''}++</details></li>`;++ const addForm = can.moderate ? `<section class="oc-set"><h2>Add something it should remember</h2><form class="oc-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/memory">++<label>What <input name="text" required minlength="3" maxlength="400" placeholder="The stream is on Fridays"></label>++<label>Kind <select name="kind">${kindOptions('fact')}</select></label>++<label>Confidence <input name="confidence" type="number" min="0" max="100" value="80"></label>++<button type="submit">Add</button></form></section>` : '';++ const forgetMine = entries.some((e) => e.mine) ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/memory/forget-mine"><button type="submit" class="oc-link">Forget everything learned from my messages in this room</button></form>` : '';++ const body = shell.shell({++ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),++ side: shell.sideRooms({++ mine: listed.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),++ public: listed.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),++ you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' },++ }),++ sideLabel: 'Rooms',++ main: `${ui.head({ title: 'What this room remembers', sub: room.name, back: { href: `/r/${room.slug}`, label: 'Back to the room' } })}${saved}${error}++<p class="oc-muted">The notes the AI keeps about this room between summaries: running topics, standing facts, decisions and asks, each with the messages it came from. A moderator corrects or removes any; anyone can remove what was learned from their own messages.</p>++${forgetMine}++${addForm}++${entries.length ? `<ul class="oc-list">${entries.map(entryView).join('')}</ul>` : '<p class="oc-muted">Nothing yet. The notes build up as the room talks and the summariser runs.</p>'}`,++ mainLabel: 'What this room remembers',++ });++ await page(req, res, 200, {++ actor, title: `What ${room.name} remembers`, path: `/r/${room.slug}/memory`, active: 'rooms', robots: 'noindex, nofollow', shell: true, footer: 'none',++ body,++ });++ });++++ router.post('/r/:slug/memory', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/memory`;++ if (!(await rooms.access(room, actor.user)).moderate) return res.redirect(303, `/r/${room.slug}/memory?error=${encodeURIComponent('Only a moderator adds to the room\'s memory')}`);++ const b = req.body || {};++ await require('../rooms/memory').add(room, actor.user, { text: b.text, kind: b.kind, confidence: b.confidence });++ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);++ }));++++ router.post('/r/:slug/memory/:id/edit', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/memory`;++ if (!(await rooms.access(room, actor.user)).moderate) return res.redirect(303, `/r/${room.slug}/memory?error=${encodeURIComponent('Only a moderator corrects the room\'s memory')}`);++ const b = req.body || {};++ await require('../rooms/memory').correct(room, actor.user, Number(req.params.id), { text: b.text, kind: b.kind, confidence: b.confidence });++ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);++ }));++++ router.post('/r/:slug/memory/:id/remove', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/memory`;++ const can = await rooms.access(room, actor.user);++ await require('../rooms/memory').remove(room, actor.user, Number(req.params.id), { canModerate: !!can.moderate });++ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);++ }));++++ router.post('/r/:slug/memory/forget-mine', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/memory`;++ await require('../rooms/memory').removeMine(room, actor.user.subject_id);++ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);++ }));++function roleHelp(kind) {if (kind === 'call') return 'Speakers, moderators and the owner talk in the call; participants listen and write in the chat; viewers listen and read.';if (kind === 'system') return 'Only the owner and moderators post; everyone else reads (viewers).';@@@ -39,6 +39,7 @@ const TABLES = ['party_pool_settings', 'party_pool_machines', 'party_pool_holds', // 0064: OpenVibe's own computers for parties'link_previews', 'link_preview_cache', // 0065: address previews beside the message, and the address cache'dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)++ 'room_memory', // 0071: what a room keeps between summaries];const INDEXES = ['idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Part 3 of the room insight (plan 2026-11-10): what a room remembers between summaries. The summariser++ * writes short entries from its memory line with the messages they came from; members read them; a++ * moderator corrects or removes any; a person removes every entry learned from their own messages; the++ * same rows go with the account export and deletion.++ */++const assert = require('assert');++const { boot, suite, sqliteNow } = require('./helpers');++++const t = suite('room-memory');++const arr = (v) => (Array.isArray(v) ? v : JSON.parse(v || '[]'));++let h, db, rooms, chatAi, aiClient, memory, owner, alice, bob, pub, priv;++++async function msg(roomId, user, text) {++ return (await db.run(++ `INSERT INTO room_messages (room_id, user_id, subject_id, message, created_at) VALUES (?, ?, ?, ?, ?)`,++ [roomId, user.id, user.subject_id || null, text, sqliteNow()]++ )).lastInsertRowid;++}++++t('boot', async () => {++ h = await boot({ env: { CHAT_AI_ENABLED: '1' } });++ db = require('../server/db/database');++ rooms = require('../server/rooms/rooms');++ chatAi = require('../server/ai/chat-ai');++ aiClient = require('../server/ai/client');++ memory = require('../server/rooms/memory');++ owner = h.addUser('memowner', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C1' });++ alice = h.addUser('memalice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C2' });++ bob = h.addUser('membob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C3' });++ h.consent.allowed.add(alice.subject_id);++ h.consent.allowed.add(bob.subject_id);++ await h.ctx.sync();++ pub = await rooms.create(owner, { name: 'Memory Room', slug: 'memory-room', visibility: 'public' });++ priv = await rooms.create(owner, { name: 'Quiet Memory', slug: 'quiet-memory', visibility: 'private' });++ await rooms.join(pub, alice);++ await rooms.join(pub, bob);++ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'succeeded', output: {++ recent_overview: 'the room is making plans',++ memory: 'The stream is on Fridays. Lee is vegetarian.',++ timeline: [],++ } } }) } });++});++++t('the summariser keeps the memory line, with its source messages and a confidence', async () => {++ const id = await msg(pub.id, alice, 'remember the stream is on Fridays');++ await chatAi._refreshRoom({ id: pub.id, slug: pub.slug, max_id: id });++ const rows = await db.all('SELECT * FROM room_memory WHERE room_id = ? AND removed_at IS NULL ORDER BY id', [pub.id]);++ assert.strictEqual(rows.length, 2, JSON.stringify(rows.map((r) => r.text)));++ assert.ok(rows.some((r) => r.text === 'The stream is on Fridays.'));++ assert.ok(rows.every((r) => Number(r.confidence) > 0));++ assert.ok(rows.every((r) => arr(r.source_message_ids).includes(Number(id))));++ assert.ok(rows.every((r) => arr(r.source_subjects).includes(alice.subject_id)));++});++++t('members read the entries with their sources; signed out is 401 and a private room is 404', async () => {++ let r = await h.http('GET', '/api/v1/rooms/memory-room/memory', { token: alice.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.body.entries.length >= 2);++ const e = r.body.entries.find((x) => x.text === 'The stream is on Fridays.');++ assert.ok(e, JSON.stringify(r.body.entries));++ assert.ok(e.sources.some((s) => s.text.includes('remember the stream')));++++ r = await h.http('GET', '/api/v1/rooms/memory-room/memory');++ assert.strictEqual(r.status, 401);++++ r = await h.http('GET', '/api/v1/rooms/quiet-memory/memory', { token: alice.token });++ assert.strictEqual(r.status, 404, 'not a member of the private room');++});++++t('a moderator corrects and removes an entry; a person removes only what came from them', async () => {++ const all = await h.http('GET', '/api/v1/rooms/memory-room/memory', { token: owner.token });++ const friday = all.body.entries.find((x) => x.text === 'The stream is on Fridays.');++ const veg = all.body.entries.find((x) => x.text === 'Lee is vegetarian.');++++ let r = await h.http('PATCH', `/api/v1/rooms/memory-room/memory/${friday.id}`, { token: owner.token, body: { text: 'The stream is on Friday nights.', kind: 'fact', confidence: 90 } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.entry.text, 'The stream is on Friday nights.');++++ // Bob may not remove an entry learned from Alice's messages.++ r = await h.http('DELETE', `/api/v1/rooms/memory-room/memory/${veg.id}`, { token: bob.token });++ assert.strictEqual(r.status, 403, r.text);++++ // Alice can: it came from her line.++ r = await h.http('DELETE', `/api/v1/rooms/memory-room/memory/${veg.id}`, { token: alice.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.removed, true);++++ // Her one call forgets everything else sourced from her.++ r = await h.http('DELETE', '/api/v1/rooms/memory-room/memory?mine=1', { token: alice.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.body.removed >= 1);++ const left = await h.http('GET', '/api/v1/rooms/memory-room/memory', { token: alice.token });++ assert.deepStrictEqual(left.body.entries, []);++});++++t('a moderator adds an entry by hand, and the room prompt carries the notes', async () => {++ let r = await h.http('POST', '/api/v1/rooms/memory-room/memory', { token: owner.token, body: { text: 'The weekly stream moved to Saturday', kind: 'fact', confidence: 95 } });++ assert.strictEqual(r.status, 201, r.text);++ const added = r.body.entry;++ assert.strictEqual(added.text, 'The weekly stream moved to Saturday');++ const prompt = await memory.forPrompt(pub);++ assert.ok(prompt.includes('The weekly stream moved to Saturday'), prompt);++++ const pageHtml = await fetch(`${h.base}/r/memory-room/memory`, { headers: { cookie: `ov_token=${owner.token}` } }).then((x) => x.text());++ assert.ok(pageHtml.includes('What this room remembers'), 'the memory page renders');++ assert.ok(pageHtml.includes('The weekly stream moved to Saturday'), 'the page shows the notes');++++ r = await h.http('POST', '/api/v1/rooms/memory-room/memory', { token: alice.token, body: { text: 'not allowed' } });++ assert.strictEqual(r.status, 403);++ r = await h.http('DELETE', `/api/v1/rooms/memory-room/memory/${added.id}`, { token: owner.token });++ assert.strictEqual(r.body.removed, true);++});++++t('the account export carries the memory and deletion removes it', async () => {++ const account = require('../server/chat/account-data');++ const d = db.getDb();++ await db.run(`INSERT INTO room_memory (room_id, kind, text, source_message_ids, source_subjects, confidence, learned_by)++ VALUES (?, 'fact', 'Lee is vegetarian.', '[]'::jsonb, ?::jsonb, 60, 'ai')`, [pub.id, JSON.stringify([alice.subject_id])]);++ const part = await account.exportPart(d, alice.subject_id);++ const file = part.files.find((f) => f.name === 'room_memory.json');++ assert.ok(file, part.files.map((f) => f.name).join(','));++ assert.ok(file.content.some((r) => r.text === 'Lee is vegetarian.'), JSON.stringify(file.content));++ await account.erase(d, [alice.subject_id]);++ const left = await db.all("SELECT * FROM room_memory WHERE room_id = ? AND source_subjects @> ?::jsonb", [pub.id, JSON.stringify([alice.subject_id])]);++ assert.deepStrictEqual(left, []);++});++++t.run(async () => { try { chatAi.stop(); } catch { /* */ } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+-- phase: expand
+-- A room's memory (plan 2026-11-10, part 3): what the summariser may keep about a room between
+-- summaries — running topics, standing facts ("the stream is on Fridays"), decisions and asks. Each
+-- entry names the room messages it came from (ids only; never a copy of deleted text), when it was
+-- learned and a confidence. A moderator may correct or remove any entry; a person may remove an entry
+-- made from their own messages. It goes with the account export and deletion.
+CREATE TABLE IF NOT EXISTS room_memory (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+ kind text COLLATE "C" NOT NULL DEFAULT 'fact' CHECK (kind IN ('topic', 'fact', 'decision', 'request')),
+ text text COLLATE "C" NOT NULL CHECK (char_length(text) BETWEEN 3 AND 400),
+ source_message_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
+ source_subjects jsonb NOT NULL DEFAULT '[]'::jsonb,
+ confidence integer NOT NULL DEFAULT 50 CHECK (confidence BETWEEN 0 AND 100),
+ learned_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ learned_by text COLLATE "C" NOT NULL DEFAULT 'ai',
+ updated_at text COLLATE "C",
+ updated_by text COLLATE "C",
+ removed_at text COLLATE "C",
+ removed_by text COLLATE "C"
+);
+CREATE INDEX IF NOT EXISTS idx_room_memory_room ON room_memory (room_id, removed_at, id DESC);
+CREATE INDEX IF NOT EXISTS idx_room_memory_room_text ON room_memory (room_id, text);
@@ -24,6 +24,7 @@ const db = require('../db/database');
@@ -24,6 +24,7 @@ const db = require('../db/database');
const aiClient = require('./client');
const aiClient = require('./client');
const extractive = require('./extractive');
const extractive = require('./extractive');
const consent = require('../net/consent');
const consent = require('../net/consent');
+const memory = require('../rooms/memory');
// ── Tunables (Live's) ────────────────────────────────────────────────────────
// ── Tunables (Live's) ────────────────────────────────────────────────────────
const TICK_MS = config.ai.tickMs;
const TICK_MS = config.ai.tickMs;
@@ -254,7 +255,9 @@ async function _refreshRoom(room) {
@@ -254,7 +255,9 @@ async function _refreshRoom(room) {
if (!rows.length) return false;
if (!rows.length) return false;
const now = Date.now();
const now = Date.now();
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
- const priorMemory = prior ? (prior.memory_json || '') : '';
+ // The room's memory is part of what the summariser reads: standing facts from earlier summaries.
+ const shared = await memory.forPrompt(room).catch(() => '');
+ const priorMemory = [prior ? (prior.memory_json || '') : '', shared].filter(Boolean).join('\n');
let priorTl = [];
let priorTl = [];
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
@@ -283,7 +286,18 @@ async function _refreshRoom(room) {
@@ -283,7 +286,18 @@ async function _refreshRoom(room) {
people: _people(rows),
people: _people(rows),
topics_json: JSON.stringify(_topics(rows)),
topics_json: JSON.stringify(_topics(rows)),
});
});
- console.log(`[ChatAI] room ${room.slug} refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);
+ // Keep what the summary learned about the room, with the messages it came from. A removed entry is not
+ // recreated (memory.remember skips known and removed text).
+ let remembered = 0;
+ if (parsed.memory) {
+ remembered = await memory.remember(room, {
+ memory: parsed.memory,
+ source_message_ids: rows.slice(-30).map((r) => r.id),
+ source_subjects: [...new Set(rows.map((r) => r.subject_id).filter(Boolean))],
+ confidence: 45,
+ }).catch((e) => { console.warn(`[ChatAI] room ${room.slug} memory:`, e.message); return 0; });
+ }
+ console.log(`[ChatAI] room ${room.slug} refreshed (${newCount} new, ${rows.length} consenting, ${remembered} remembered, window=${windowLabel})`);
return true;
return true;
}
}
@@ -179,6 +179,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -179,6 +179,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
app.use('/api/v1/rooms', require('./chat/play-queue-routes').service);
app.use('/api/v1/rooms', require('./chat/play-queue-routes').service);
// A room's AI insight (plan 2026-11-10): public, one shape for Global and, in part 2, every room.
// A room's AI insight (plan 2026-11-10): public, one shape for Global and, in part 2, every room.
app.use('/api/v1/rooms', require('./chat/room-insight-routes'));
app.use('/api/v1/rooms', require('./chat/room-insight-routes'));
+ // A room's memory (part 3): what the summariser keeps between summaries, for its members.
+ app.use('/api/v1/rooms', require('./chat/memory-routes'));
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
@@ -64,6 +64,8 @@ const EXPORTS = [
@@ -64,6 +64,8 @@ const EXPORTS = [
['ai_members_added.json', `SELECT * FROM room_ai_members WHERE created_by_subject = ANY(@s) 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}`],
// Work they put on a workspace room's queue (server/rooms/workspace.js), with its results.
// Work they put on a workspace room's queue (server/rooms/workspace.js), with its results.
['room_tasks.json', `SELECT t.id, r.slug AS room, t.title, t.details, t.kind, t.status, t.via, t.result, t.result_url, t.sources, t.error, t.created_at, t.finished_at FROM room_tasks t JOIN rooms r ON r.id = t.room_id WHERE t.requested_subject = ANY(@s) OR t.requested_by = ANY(@ids) ORDER BY t.id DESC LIMIT ${LIMIT}`],
['room_tasks.json', `SELECT t.id, r.slug AS room, t.title, t.details, t.kind, t.status, t.via, t.result, t.result_url, t.sources, t.error, t.created_at, t.finished_at FROM room_tasks t JOIN rooms r ON r.id = t.room_id WHERE t.requested_subject = ANY(@s) OR t.requested_by = ANY(@ids) ORDER BY t.id DESC LIMIT ${LIMIT}`],
+ // What rooms remember from their messages, and what they wrote themselves (server/rooms/memory.js).
+ ['room_memory.json', `SELECT rm.id, r.slug AS room, rm.kind, rm.text, rm.confidence, rm.learned_at, rm.learned_by FROM room_memory rm JOIN rooms r ON r.id = rm.room_id WHERE rm.learned_by = ANY(@s) OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(rm.source_subjects) x WHERE x = ANY(@s)) ORDER BY rm.id DESC LIMIT ${LIMIT}`],
// Hooks they made into a workspace room (server/rooms/hooks.js), without their secrets.
// Hooks they made into a workspace room (server/rooms/hooks.js), without their secrets.
['room_hooks.json', `SELECT h.id, r.slug AS room, h.label, h.queue_kind, h.deliveries, h.last_at, h.created_at FROM room_hooks h JOIN rooms r ON r.id = h.room_id WHERE h.created_by = ANY(@ids) ORDER BY h.created_at DESC LIMIT ${LIMIT}`],
['room_hooks.json', `SELECT h.id, r.slug AS room, h.label, h.queue_kind, h.deliveries, h.last_at, h.created_at FROM room_hooks h JOIN rooms r ON r.id = h.room_id WHERE h.created_by = ANY(@ids) ORDER BY h.created_at DESC LIMIT ${LIMIT}`],
['vibe_usage.json', `SELECT * FROM vibe_usage WHERE user_id = ANY(@ids) ORDER BY day DESC LIMIT ${LIMIT}`],
['vibe_usage.json', `SELECT * FROM vibe_usage WHERE user_id = ANY(@ids) ORDER BY day DESC LIMIT ${LIMIT}`],
@@ -150,6 +152,7 @@ const ERASE_AFTER_ROOMS = [
@@ -150,6 +152,7 @@ const ERASE_AFTER_ROOMS = [
['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],
['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],
['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],
['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],
['activity', 'DELETE FROM chat_activity WHERE user_id = ANY(@ids) OR actor_user_id = ANY(@ids)'],
['activity', 'DELETE FROM chat_activity WHERE user_id = ANY(@ids) OR actor_user_id = ANY(@ids)'],
+ ['room_memory', "DELETE FROM room_memory WHERE learned_by = ANY(@s) OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(room_memory.source_subjects) x WHERE x = ANY(@s))"],
['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
// Friends and conversation view preferences from the Chat app shell.
// Friends and conversation view preferences from the Chat app shell.
['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],
['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * A room's memory (plan 2026-11-10, part 3): GET/POST/PATCH/DELETE /api/v1/rooms/:id/memory.
+ *
+ * GET /:id/memory members: the room's active entries, newest first, with sources
+ * GET /:id/memory?mine=1 the same (the person's own view)
+ * POST /:id/memory a moderator adds an entry { text, kind?, confidence?, source_message_ids? }
+ * PATCH /:id/memory/:entryId a moderator corrects it { text?, kind?, confidence? }
+ * DELETE /:id/memory/:entryId a moderator removes any; a person removes one from their own messages
+ * DELETE /:id/memory?mine=1 a person forgets everything learned from their own messages here
+ *
+ * Reads need a signed-in person who may read the room (a public room: anyone signed in; a private one:
+ * its members and staff), because memory is what the room keeps about its people. Writes are a moderator's,
+ * except the person's own removal. A DM and a chat with Vibe are not rooms here.
+ */
+const express = require('express');
+const router = express.Router();
+const rooms = require('../rooms/rooms');
+const memory = require('../rooms/memory');
+const { optionalAuth } = require('../auth/auth');
+
+function send(res, status, body) { return res.status(status).json(body); }
+
+async function resolve(req, res) {
+ const id = String(req.params.id || '');
+ const room = await rooms.bySlug(id);
+ if (!room || Number(room.assistant) === 1) { send(res, 404, { error: 'Room memory not found' }); return null; }
+ if (!req.user) { send(res, 401, { error: 'Sign in to read what this room remembers' }); return null; }
+ const access = await rooms.access(room, req.user);
+ // Members and staff only: memory is what the room keeps about its people, not the public feed.
+ if (!access.read || (!access.role && !access.moderate)) { send(res, 404, { error: 'Room memory not found' }); return null; }
+ return { room, access };
+}
+
+function fail(res, err) {
+ if (err instanceof memory.MemoryError || err instanceof rooms.RoomError) return send(res, err.status, { error: err.message, code: err.code });
+ console.error('[RoomMemory]', err && err.message);
+ return send(res, 500, { error: 'Something went wrong' });
+}
+
+router.get('/:id/memory', optionalAuth, async (req, res) => {
+ try {
+ const x = await resolve(req, res); if (!x) return;
+ const entries = await memory.forRoom(x.room, { subject: req.user.subject_id || null });
+ res.set('Cache-Control', 'private, no-store');
+ return res.json({ room: { slug: x.room.slug, name: x.room.name }, entries, kinds: memory.KINDS,
+ can_edit: !!x.access.moderate, me: req.user.subject_id || null });
+ } catch (err) { return fail(res, err); }
+});
+
+router.post('/:id/memory', optionalAuth, async (req, res) => {
+ try {
+ const x = await resolve(req, res); if (!x) return;
+ if (!x.access.moderate) return send(res, 403, { error: 'Only a moderator adds to the room\'s memory' });
+ const entry = await memory.add(x.room, req.user, req.body || {});
+ return res.status(201).json({ entry });
+ } catch (err) { return fail(res, err); }
+});
+
+router.patch('/:id/memory/:entryId', optionalAuth, async (req, res) => {
+ try {
+ const x = await resolve(req, res); if (!x) return;
+ if (!x.access.moderate) return send(res, 403, { error: 'Only a moderator corrects the room\'s memory' });
+ const entry = await memory.correct(x.room, req.user, Number(req.params.entryId), req.body || {});
+ return res.json({ entry });
+ } catch (err) { return fail(res, err); }
+});
+
+router.delete('/:id/memory', optionalAuth, async (req, res) => {
+ try {
+ if (String(req.query.mine || '') !== '1') return send(res, 400, { error: 'Pass ?mine=1 to forget what was learned from your messages' });
+ const x = await resolve(req, res); if (!x) return;
+ if (!req.user.subject_id) return send(res, 403, { error: 'Only a signed-in person has messages here' });
+ return res.json(await memory.removeMine(x.room, req.user.subject_id));
+ } catch (err) { return fail(res, err); }
+});
+
+router.delete('/:id/memory/:entryId', optionalAuth, async (req, res) => {
+ try {
+ const x = await resolve(req, res); if (!x) return;
+ return res.json(await memory.remove(x.room, req.user, Number(req.params.entryId), { canModerate: !!x.access.moderate }));
+ } catch (err) { return fail(res, err); }
+});
+
+module.exports = router;
@@ -282,7 +282,7 @@ function workspacePrompt(member, room) {
@@ -282,7 +282,7 @@ function workspacePrompt(member, room) {
}
}
/** The member's instructions for chat.member.turn (the same rules as AI's chat.member.reply template, plus tools). */
/** The member's instructions for chat.member.turn (the same rules as AI's chat.member.reply template, plus tools). */
-function systemPrompt(member, room) {
+function systemPrompt(member, room, roomMemory = '') {
if (Number(room.workspace) === 1) return workspacePrompt(member, room);
if (Number(room.workspace) === 1) return workspacePrompt(member, room);
return [
return [
`You are ${member.name}, an AI member of the chat room "${String(room.name || room.slug).slice(0, 120)}" on OpenVibe.Chat. The people in the room added you to their conversation.`,
`You are ${member.name}, an AI member of the chat room "${String(room.name || room.slug).slice(0, 120)}" on OpenVibe.Chat. The people in the room added you to their conversation.`,
@@ -295,6 +295,7 @@ function systemPrompt(member, room) {
@@ -295,6 +295,7 @@ function systemPrompt(member, room) {
'- Never invent facts about the people, their plans or their things beyond what the messages, notes, context and tools say. When you do not know, say so and ask.',
'- Never invent facts about the people, their plans or their things beyond what the messages, notes, context and tools say. When you do not know, say so and ask.',
'- You can only write in this chat and use these tools. Do not claim to have done anything else (bought, booked, sent, saved, set a reminder).',
'- You can only write in this chat and use these tools. Do not claim to have done anything else (bought, booked, sent, saved, set a reminder).',
'- No threats, nothing sexual involving minors, and never anyone\'s private details. Swearing in jest is fine if the room does it.',
'- No threats, nothing sexual involving minors, and never anyone\'s private details. Swearing in jest is fine if the room does it.',
+ roomMemory ? `What this room remembers between conversations (shared notes, use when they help, do not recite them):\n${roomMemory}` : '',
].filter((l, i, a) => l !== '' || a[i - 1] !== '').join('\n');
].filter((l, i, a) => l !== '' || a[i - 1] !== '').join('\n');
}
}
@@ -321,7 +322,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
@@ -321,7 +322,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
const tz = timezoneOf(timezone);
const tz = timezoneOf(timezone);
const prompt = userPrompt({ member, context, transcript, askedBy, text });
const prompt = userPrompt({ member, context, transcript, askedBy, text });
const messages = [
const messages = [
- { role: 'system', content: vibe ? vibePrompt(member, askedBy, memory, { timezone: tz, ownTools: tools.some((t) => t.function && t.function.name === 'my_day') }) : systemPrompt(member, room) },
+ { role: 'system', content: vibe ? vibePrompt(member, askedBy, memory, { timezone: tz, ownTools: tools.some((t) => t.function && t.function.name === 'my_day') }) : systemPrompt(member, room, await require('./memory').forPrompt(room).catch(() => '')) },
// Pictures the person sent (picturesFor): the model sees them with the text.
// Pictures the person sent (picturesFor): the model sees them with the text.
{ role: 'user', content: images.length ? [{ type: 'text', text: `${prompt}\n\n(${images.length === 1 ? 'The picture they sent is' : 'The pictures they sent are'} attached.)` }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : prompt },
{ role: 'user', content: images.length ? [{ type: 'text', text: `${prompt}\n\n(${images.length === 1 ? 'The picture they sent is' : 'The pictures they sent are'} attached.)` }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : prompt },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+'use strict';
+/**
+ * A room's memory (plan 2026-11-10, part 3): what the summariser may keep about a room between summaries.
+ *
+ * forPrompt(room) the shared notes a summariser or an AI member reads, one line each
+ * forRoom(room) the page's read: active entries, newest first, with their source messages
+ * remember(room, { text, kind, … }) the summariser's write (deduplicated, capped)
+ * add / correct / remove a moderator's hand; a person's own removal (their messages sourced it)
+ *
+ * An entry names the room messages it came from (ids) and the subjects of their authors, so a person can
+ * remove what was learned from their own lines, and the account export/deletion can carry it. Removed
+ * entries keep their row (soft delete) so a summariser does not recreate them from an old batch.
+ */
+const db = require('../db/database');
+
+const KINDS = ['topic', 'fact', 'decision', 'request'];
+const MAX_TEXT = 400;
+const MAX_ACTIVE = 80;
+const MAX_SOURCES = 30;
+const PROMPT_LIMIT = 12;
+
+class MemoryError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+const fail = (status, code, message) => { throw new MemoryError(status, code, message); };
+const TEXT_MIN = 3;
+
+function _cleanText(text) {
+ return String(text == null ? '' : text).replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
+}
+function _kind(kind) { return KINDS.includes(kind) ? kind : 'fact'; }
+function _ids(value) {
+ if (!Array.isArray(value)) return [];
+ return [...new Set(value.map(Number).filter((n) => Number.isInteger(n) && n > 0))].slice(0, MAX_SOURCES);
+}
+function _confidence(value, fallback = 50) {
+ const n = Number(value);
+ return Number.isFinite(n) ? Math.max(0, Math.min(100, Math.round(n))) : fallback;
+}
+function _norm(text) { return String(text || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); }
+function _parseJson(raw) { if (Array.isArray(raw)) return raw; try { const v = JSON.parse(raw || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }
+
+/** A page's entry: sources are resolved now, so a deleted message never comes back as an entry's text. */
+async function _shape(row, sources, subject = null) {
+ return {
+ id: Number(row.id),
+ kind: row.kind,
+ text: row.text,
+ confidence: Number(row.confidence) || 0,
+ learned_at: row.learned_at || null,
+ learned_by: row.learned_by || 'ai',
+ updated_at: row.updated_at || null,
+ updated_by: row.updated_by || null,
+ mine: !!(subject && _parseJson(row.source_subjects).includes(subject)),
+ sources: (sources || []).map((s) => ({
+ id: Number(s.id), author: s.username || 'someone', at: s.created_at || null,
+ text: String(s.message || '').slice(0, 300),
+ })),
+ };
+}
+
+/** Resolve the stored source ids against the live room messages (deleted and AI-member lines are gone). */
+async function _sourcesFor(rows) {
+ const ids = [...new Set((rows || []).flatMap((r) => _ids(_parseJson(r.source_message_ids))))];
+ if (!ids.length) return new Map();
+ const marks = ids.map(() => '?').join(',');
+ const found = await db.all(`SELECT m.id, m.message, m.created_at, u.username
+ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id
+ WHERE m.id IN (${marks}) AND m.is_deleted = 0 AND m.ai_member_id IS NULL`, ids) || [];
+ const byRoom = new Map();
+ for (const m of found) byRoom.set(Number(m.id), m);
+ return byRoom;
+}
+
+/** The active entries of one room, newest first, with their source messages. */
+async function forRoom(room, { limit = 50, subject = null } = {}) {
+ const rows = await db.all('SELECT * FROM room_memory WHERE room_id = ? AND removed_at IS NULL ORDER BY learned_at DESC, id DESC LIMIT ?',
+ [room.id, Math.min(200, Math.max(1, Number(limit) || 50))]) || [];
+ const byId = await _sourcesFor(rows);
+ return Promise.all(rows.map((r) => _shape(r, _ids(_parseJson(r.source_message_ids)).map((id) => byId.get(id)).filter(Boolean), subject)));
+}
+
+/** What a summariser or an AI member in the room reads. Empty when there is nothing worth saying. */
+async function forPrompt(room, { limit = PROMPT_LIMIT } = {}) {
+ const rows = await db.all(`SELECT kind, text FROM room_memory
+ WHERE room_id = ? AND removed_at IS NULL AND confidence >= 30 ORDER BY learned_at DESC, id DESC LIMIT ?`,
+ [room.id, Math.max(1, limit)]) || [];
+ return rows.map((r) => `- [${r.kind}] ${r.text}`).join('\n');
+}
+
+/**
+ * The summariser's write: split the model's memory line into short entries, skip any already known (or
+ * removed) for this room, and keep at most MAX_ACTIVE active. The window's message ids and their authors'
+ * subjects are the sources; confidence is the summary's, not a quoted line's.
+ */
+async function remember(room, { memory, source_message_ids: sourceIds = [], source_subjects: sourceSubjects = [], confidence = 40, learned_by: learnedBy = 'ai' } = {}) {
+ const lines = String(memory || '')
+ .split(/\n+|(?<=[.!?])\s+/)
+ .map((s) => s.replace(/^[-•*]\s*/, '').replace(/\s+/g, ' ').trim())
+ .filter((s) => s.length >= 12 && s.length <= MAX_TEXT)
+ .slice(0, 20);
+ if (!lines.length) return 0;
+ const ids = _ids(sourceIds);
+ const subjects = [...new Set((Array.isArray(sourceSubjects) ? sourceSubjects : []).map(String).filter((s) => /^usr_/.test(s)))];
+ const existing = new Set((await db.all('SELECT text FROM room_memory WHERE room_id = ?', [room.id]) || []).map((r) => _norm(r.text)));
+ let added = 0;
+ for (const text of lines) {
+ const key = _norm(text);
+ if (!key || existing.has(key)) continue;
+ const active = (await db.get('SELECT COUNT(*) AS n FROM room_memory WHERE room_id = ? AND removed_at IS NULL', [room.id]) || {}).n || 0;
+ if (Number(active) >= MAX_ACTIVE) break;
+ await db.run(`INSERT INTO room_memory (room_id, kind, text, source_message_ids, source_subjects, confidence, learned_by)
+ VALUES (?, 'fact', ?, ?::jsonb, ?::jsonb, ?, ?)`,
+ [room.id, text, JSON.stringify(ids), JSON.stringify(subjects), _confidence(confidence, 40), String(learnedBy).slice(0, 80)]);
+ existing.add(key);
+ added++;
+ }
+ return added;
+}
+
+/** A moderator's entry (or a correction of an AI one). */
+async function add(room, actor, { text, kind = 'fact', confidence = 80, source_message_ids: sourceIds = [] } = {}) {
+ const t = _cleanText(text);
+ if (t.length < TEXT_MIN) fail(422, 'memory.text', 'An entry needs a few words');
+ const ids = _ids(sourceIds);
+ const subjects = ids.length
+ ? (await db.all(`SELECT subject_id FROM room_messages WHERE id IN (${ids.map(() => '?').join(',')}) AND room_id = ? AND is_deleted = 0`, [...ids, room.id]) || []).map((r) => r.subject_id).filter(Boolean)
+ : [];
+ const res = await db.run(`INSERT INTO room_memory (room_id, kind, text, source_message_ids, source_subjects, confidence, learned_by)
+ VALUES (?, ?, ?, ?::jsonb, ?::jsonb, ?, ?)`,
+ [room.id, _kind(kind), t, JSON.stringify(ids), JSON.stringify([...new Set(subjects)]), _confidence(confidence, 80), actor && actor.subject_id ? actor.subject_id : 'moderator']);
+ return await _one(room, Number(res.lastInsertRowid));
+}
+
+async function _one(room, id) {
+ const row = await db.get('SELECT * FROM room_memory WHERE id = ? AND room_id = ?', [id, room.id]);
+ if (!row) return null;
+ const byId = await _sourcesFor([row]);
+ return _shape(row, _ids(_parseJson(row.source_message_ids)).map((sid) => byId.get(sid)).filter(Boolean));
+}
+
+/** A moderator corrects the words, kind or confidence of an entry. */
+async function correct(room, actor, id, patch = {}) {
+ const row = await db.get('SELECT * FROM room_memory WHERE id = ? AND room_id = ? AND removed_at IS NULL', [id, room.id]);
+ if (!row) fail(404, 'memory.not_found', 'No such entry');
+ const text = patch.text !== undefined ? _cleanText(patch.text) : row.text;
+ if (text.length < TEXT_MIN) fail(422, 'memory.text', 'An entry needs a few words');
+ const kind = patch.kind !== undefined ? _kind(patch.kind) : row.kind;
+ const confidence = patch.confidence !== undefined ? _confidence(patch.confidence, row.confidence) : row.confidence;
+ await db.run('UPDATE room_memory SET text = ?, kind = ?, confidence = ?, updated_at = ov_now(), updated_by = ? WHERE id = ?',
+ [text, kind, confidence, actor && actor.subject_id ? actor.subject_id : 'moderator', row.id]);
+ return await _one(room, row.id);
+}
+
+/**
+ * Remove an entry. A moderator removes any; a person removes one made from their own messages. Soft:
+ * the row stays so a later summary does not bring the same words back.
+ */
+async function remove(room, actor, id, { canModerate = false } = {}) {
+ const row = await db.get('SELECT * FROM room_memory WHERE id = ? AND room_id = ? AND removed_at IS NULL', [id, room.id]);
+ if (!row) fail(404, 'memory.not_found', 'No such entry');
+ const mine = !!(actor && actor.subject_id && _parseJson(row.source_subjects).includes(actor.subject_id));
+ if (!canModerate && !mine) fail(403, 'memory.not_yours', 'Only a room moderator, or a person whose messages it came from, removes this');
+ await db.run('UPDATE room_memory SET removed_at = ov_now(), removed_by = ? WHERE id = ?',
+ [actor && actor.subject_id ? actor.subject_id : 'moderator', row.id]);
+ return { removed: true };
+}
+
+/** A person's "forget what was learned from my messages" in one room. */
+async function removeMine(room, subject) {
+ if (!/^usr_/.test(String(subject || ''))) return { removed: 0 };
+ const rows = await db.all('SELECT id, source_subjects FROM room_memory WHERE room_id = ? AND removed_at IS NULL', [room.id]) || [];
+ let removed = 0;
+ for (const r of rows) {
+ if (!_parseJson(r.source_subjects).includes(subject)) continue;
+ await db.run('UPDATE room_memory SET removed_at = ov_now(), removed_by = ? WHERE id = ?', [subject, r.id]);
+ removed++;
+ }
+ return { removed };
+}
+
+module.exports = {
+ KINDS, MAX_TEXT, MemoryError,
+ forRoom, forPrompt, remember, add, correct, remove, removeMine, _shape, _norm,
+};
@@ -894,7 +894,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -894,7 +894,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
const roomRoutes = require('../rooms/routes');
const roomRoutes = require('../rooms/routes');
const roomsOrError = (fn) => async (req, res, next) => {
const roomsOrError = (fn) => async (req, res, next) => {
try { return await fn(req, res, next); } catch (err) {
try { return await fn(req, res, next); } catch (err) {
- if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);
+ if (err instanceof rooms.RoomError || err instanceof require('../rooms/memory').MemoryError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);
return next(err);
return next(err);
}
}
};
};
@@ -1275,7 +1275,8 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
@@ -1275,7 +1275,8 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
body: roomShell(`${ui.head({ title: `AI members in #${esc(room.name)}`, back: { href: await channelUrl(room, '/settings'), label: 'Server settings' } })}${saved}${error}
body: roomShell(`${ui.head({ title: `AI members in #${esc(room.name)}`, back: { href: await channelUrl(room, '/settings'), label: 'Server settings' } })}${saved}${error}
${can.manage ? `<section class="oc-set"><h2>AI insight</h2><form class="oc-form oc-set-form" method="post" action="/r/${esc(room.slug)}/settings">
${can.manage ? `<section class="oc-set"><h2>AI insight</h2><form class="oc-form oc-set-form" method="post" action="/r/${esc(room.slug)}/settings">
<input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Summarise this channel<small>An overview and timeline of the channel, written for someone arriving now. Only messages from people who allowed AI use of them are read.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>
<input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Summarise this channel<small>An overview and timeline of the channel, written for someone arriving now. Only messages from people who allowed AI use of them are read.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>
-<p><button type="submit">Save</button></p></form></section>` : ''}
+<p><button type="submit">Save</button></p></form>
+<p><a href="/r/${esc(room.slug)}/memory">What this channel remembers</a></p></section>` : ''}
${aiSection}`),
${aiSection}`),
});
});
}
}
@@ -1301,7 +1302,8 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room
@@ -1301,7 +1302,8 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room
<input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">AI insight<small>An overview and timeline of this room, written for someone arriving now. Only messages from people who allowed AI use of them are read; a private room is off until you turn this on.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>
<input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">AI insight<small>An overview and timeline of this room, written for someone arriving now. Only messages from people who allowed AI use of them are read; a private room is off until you turn this on.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>
${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(room.assistant) !== 1 ? `<input type="hidden" name="has_workspace" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Work together here<small>A work queue beside the conversation, alerts from other systems and a shared screen</small></span><input type="checkbox" class="oc-switch" name="workspace" value="1"${rooms.isWorkspace(room) ? ' checked' : ''}></label>` : ''}
${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(room.assistant) !== 1 ? `<input type="hidden" name="has_workspace" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Work together here<small>A work queue beside the conversation, alerts from other systems and a shared screen</small></span><input type="checkbox" class="oc-switch" name="workspace" value="1"${rooms.isWorkspace(room) ? ' checked' : ''}></label>` : ''}
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
-<p><button type="submit">Save</button></p></form></section>` : ''}
+<p><button type="submit">Save</button></p></form>
+<p><a href="/r/${esc(room.slug)}/memory">What this room remembers</a> <span class="oc-muted oc-small">— the notes the AI keeps between summaries; you can correct or remove them.</span></p></section>` : ''}
<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2>
<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2>
<p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p>
<p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p>
<form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>
<form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>
@@ -1319,6 +1321,101 @@ ${attachList}`),
@@ -1319,6 +1321,101 @@ ${attachList}`),
});
});
});
});
+ // ── What this room remembers (plan 2026-11-10, part 3): the summariser's shared notes, with where each
+ // one came from, a moderator's correction/removal and a person's own "forget mine". Plain markup. ──
+ router.get('/r/:slug/memory', async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || Number(room.assistant) === 1) return res.redirect(303, '/rooms');
+ const can = await rooms.access(room, actor.user);
+ if (!can.read || (!can.role && !can.moderate)) return res.redirect(303, '/rooms');
+ const memory = require('../rooms/memory');
+ const entries = await memory.forRoom(room, { subject: actor.user.subject_id || null });
+ const saved = req.query.saved ? notice('ok', 'Saved.') : '';
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
+ const listed = await rooms.list(actor.user);
+ const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
+ const kindOptions = (cur) => ['topic', 'fact', 'decision', 'request'].map((k) => `<option value="${k}"${k === cur ? ' selected' : ''}>${k}</option>`).join('');
+ const entryView = (e) => `<li class="oc-card oc-memory-entry">
+<details class="oc-fold"><summary><span><span class="oc-badge">${esc(e.kind)}</span> ${esc(e.text)} <span class="oc-muted oc-small">${Number(e.confidence) || 0}% · learned ${esc(ui.when(e.learned_at).text || '')}</span></span>${IC.chevron}</summary>
+${e.sources.length ? `<ul class="oc-muted oc-small">${e.sources.map((m) => `<li><strong>${esc(m.author)}</strong> <time>${esc(ui.when(m.at).text || '')}</time>: ${esc(m.text)}</li>`).join('')}</ul>` : '<p class="oc-muted oc-small">Its source messages are gone.</p>'}
+${can.moderate ? `<form class="oc-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/memory/${Number(e.id)}/edit">
+<label>Words <input name="text" maxlength="400" value="${esc(e.text)}"></label>
+<label>Kind <select name="kind">${kindOptions(e.kind)}</select></label>
+<label>Confidence <input name="confidence" type="number" min="0" max="100" value="${Number(e.confidence) || 0}"></label>
+<button type="submit">Save</button></form>
+<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/memory/${Number(e.id)}/remove"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove</span></button></form>`
+: e.mine ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/memory/${Number(e.id)}/remove"><button type="submit" class="oc-link">Remove this (it came from my messages)</button></form>` : ''}
+</details></li>`;
+ const addForm = can.moderate ? `<section class="oc-set"><h2>Add something it should remember</h2><form class="oc-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/memory">
+<label>What <input name="text" required minlength="3" maxlength="400" placeholder="The stream is on Fridays"></label>
+<label>Kind <select name="kind">${kindOptions('fact')}</select></label>
+<label>Confidence <input name="confidence" type="number" min="0" max="100" value="80"></label>
+<button type="submit">Add</button></form></section>` : '';
+ const forgetMine = entries.some((e) => e.mine) ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/memory/forget-mine"><button type="submit" class="oc-link">Forget everything learned from my messages in this room</button></form>` : '';
+ const body = shell.shell({
+ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
+ side: shell.sideRooms({
+ mine: listed.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
+ public: listed.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
+ you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' },
+ }),
+ sideLabel: 'Rooms',
+ main: `${ui.head({ title: 'What this room remembers', sub: room.name, back: { href: `/r/${room.slug}`, label: 'Back to the room' } })}${saved}${error}
+<p class="oc-muted">The notes the AI keeps about this room between summaries: running topics, standing facts, decisions and asks, each with the messages it came from. A moderator corrects or removes any; anyone can remove what was learned from their own messages.</p>
+${forgetMine}
+${addForm}
+${entries.length ? `<ul class="oc-list">${entries.map(entryView).join('')}</ul>` : '<p class="oc-muted">Nothing yet. The notes build up as the room talks and the summariser runs.</p>'}`,
+ mainLabel: 'What this room remembers',
+ });
+ await page(req, res, 200, {
+ actor, title: `What ${room.name} remembers`, path: `/r/${room.slug}/memory`, active: 'rooms', robots: 'noindex, nofollow', shell: true, footer: 'none',
+ body,
+ });
+ });
+
+ router.post('/r/:slug/memory', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/memory`;
+ if (!(await rooms.access(room, actor.user)).moderate) return res.redirect(303, `/r/${room.slug}/memory?error=${encodeURIComponent('Only a moderator adds to the room\'s memory')}`);
+ const b = req.body || {};
+ await require('../rooms/memory').add(room, actor.user, { text: b.text, kind: b.kind, confidence: b.confidence });
+ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);
+ }));
+
+ router.post('/r/:slug/memory/:id/edit', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/memory`;
+ if (!(await rooms.access(room, actor.user)).moderate) return res.redirect(303, `/r/${room.slug}/memory?error=${encodeURIComponent('Only a moderator corrects the room\'s memory')}`);
+ const b = req.body || {};
+ await require('../rooms/memory').correct(room, actor.user, Number(req.params.id), { text: b.text, kind: b.kind, confidence: b.confidence });
+ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);
+ }));
+
+ router.post('/r/:slug/memory/:id/remove', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/memory`;
+ const can = await rooms.access(room, actor.user);
+ await require('../rooms/memory').remove(room, actor.user, Number(req.params.id), { canModerate: !!can.moderate });
+ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);
+ }));
+
+ router.post('/r/:slug/memory/forget-mine', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/memory`;
+ await require('../rooms/memory').removeMine(room, actor.user.subject_id);
+ return res.redirect(303, `/r/${room.slug}/memory?saved=1`);
+ }));
+
function roleHelp(kind) {
function roleHelp(kind) {
if (kind === 'call') return 'Speakers, moderators and the owner talk in the call; participants listen and write in the chat; viewers listen and read.';
if (kind === 'call') return 'Speakers, moderators and the owner talk in the call; participants listen and write in the chat; viewers listen and read.';
if (kind === 'system') return 'Only the owner and moderators post; everyone else reads (viewers).';
if (kind === 'system') return 'Only the owner and moderators post; everyone else reads (viewers).';
@@ -39,6 +39,7 @@ const TABLES = [
@@ -39,6 +39,7 @@ const TABLES = [
'party_pool_settings', 'party_pool_machines', 'party_pool_holds', // 0064: OpenVibe's own computers for parties
'party_pool_settings', 'party_pool_machines', 'party_pool_holds', // 0064: OpenVibe's own computers for parties
'link_previews', 'link_preview_cache', // 0065: address previews beside the message, and the address cache
'link_previews', 'link_preview_cache', // 0065: address previews beside the message, and the address cache
'dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)
'dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)
+ 'room_memory', // 0071: what a room keeps between summaries
];
];
const INDEXES = [
const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Part 3 of the room insight (plan 2026-11-10): what a room remembers between summaries. The summariser
+ * writes short entries from its memory line with the messages they came from; members read them; a
+ * moderator corrects or removes any; a person removes every entry learned from their own messages; the
+ * same rows go with the account export and deletion.
+ */
+const assert = require('assert');
+const { boot, suite, sqliteNow } = require('./helpers');
+
+const t = suite('room-memory');
+const arr = (v) => (Array.isArray(v) ? v : JSON.parse(v || '[]'));
+let h, db, rooms, chatAi, aiClient, memory, owner, alice, bob, pub, priv;
+
+async function msg(roomId, user, text) {
+ return (await db.run(
+ `INSERT INTO room_messages (room_id, user_id, subject_id, message, created_at) VALUES (?, ?, ?, ?, ?)`,
+ [roomId, user.id, user.subject_id || null, text, sqliteNow()]
+ )).lastInsertRowid;
+}
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_AI_ENABLED: '1' } });
+ db = require('../server/db/database');
+ rooms = require('../server/rooms/rooms');
+ chatAi = require('../server/ai/chat-ai');
+ aiClient = require('../server/ai/client');
+ memory = require('../server/rooms/memory');
+ owner = h.addUser('memowner', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C1' });
+ alice = h.addUser('memalice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C2' });
+ bob = h.addUser('membob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C3' });
+ h.consent.allowed.add(alice.subject_id);
+ h.consent.allowed.add(bob.subject_id);
+ await h.ctx.sync();
+ pub = await rooms.create(owner, { name: 'Memory Room', slug: 'memory-room', visibility: 'public' });
+ priv = await rooms.create(owner, { name: 'Quiet Memory', slug: 'quiet-memory', visibility: 'private' });
+ await rooms.join(pub, alice);
+ await rooms.join(pub, bob);
+ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'succeeded', output: {
+ recent_overview: 'the room is making plans',
+ memory: 'The stream is on Fridays. Lee is vegetarian.',
+ timeline: [],
+ } } }) } });
+});
+
+t('the summariser keeps the memory line, with its source messages and a confidence', async () => {
+ const id = await msg(pub.id, alice, 'remember the stream is on Fridays');
+ await chatAi._refreshRoom({ id: pub.id, slug: pub.slug, max_id: id });
+ const rows = await db.all('SELECT * FROM room_memory WHERE room_id = ? AND removed_at IS NULL ORDER BY id', [pub.id]);
+ assert.strictEqual(rows.length, 2, JSON.stringify(rows.map((r) => r.text)));
+ assert.ok(rows.some((r) => r.text === 'The stream is on Fridays.'));
+ assert.ok(rows.every((r) => Number(r.confidence) > 0));
+ assert.ok(rows.every((r) => arr(r.source_message_ids).includes(Number(id))));
+ assert.ok(rows.every((r) => arr(r.source_subjects).includes(alice.subject_id)));
+});
+
+t('members read the entries with their sources; signed out is 401 and a private room is 404', async () => {
+ let r = await h.http('GET', '/api/v1/rooms/memory-room/memory', { token: alice.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.body.entries.length >= 2);
+ const e = r.body.entries.find((x) => x.text === 'The stream is on Fridays.');
+ assert.ok(e, JSON.stringify(r.body.entries));
+ assert.ok(e.sources.some((s) => s.text.includes('remember the stream')));
+
+ r = await h.http('GET', '/api/v1/rooms/memory-room/memory');
+ assert.strictEqual(r.status, 401);
+
+ r = await h.http('GET', '/api/v1/rooms/quiet-memory/memory', { token: alice.token });
+ assert.strictEqual(r.status, 404, 'not a member of the private room');
+});
+
+t('a moderator corrects and removes an entry; a person removes only what came from them', async () => {
+ const all = await h.http('GET', '/api/v1/rooms/memory-room/memory', { token: owner.token });
+ const friday = all.body.entries.find((x) => x.text === 'The stream is on Fridays.');
+ const veg = all.body.entries.find((x) => x.text === 'Lee is vegetarian.');
+
+ let r = await h.http('PATCH', `/api/v1/rooms/memory-room/memory/${friday.id}`, { token: owner.token, body: { text: 'The stream is on Friday nights.', kind: 'fact', confidence: 90 } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.entry.text, 'The stream is on Friday nights.');
+
+ // Bob may not remove an entry learned from Alice's messages.
+ r = await h.http('DELETE', `/api/v1/rooms/memory-room/memory/${veg.id}`, { token: bob.token });
+ assert.strictEqual(r.status, 403, r.text);
+
+ // Alice can: it came from her line.
+ r = await h.http('DELETE', `/api/v1/rooms/memory-room/memory/${veg.id}`, { token: alice.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.removed, true);
+
+ // Her one call forgets everything else sourced from her.
+ r = await h.http('DELETE', '/api/v1/rooms/memory-room/memory?mine=1', { token: alice.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.body.removed >= 1);
+ const left = await h.http('GET', '/api/v1/rooms/memory-room/memory', { token: alice.token });
+ assert.deepStrictEqual(left.body.entries, []);
+});
+
+t('a moderator adds an entry by hand, and the room prompt carries the notes', async () => {
+ let r = await h.http('POST', '/api/v1/rooms/memory-room/memory', { token: owner.token, body: { text: 'The weekly stream moved to Saturday', kind: 'fact', confidence: 95 } });
+ assert.strictEqual(r.status, 201, r.text);
+ const added = r.body.entry;
+ assert.strictEqual(added.text, 'The weekly stream moved to Saturday');
+ const prompt = await memory.forPrompt(pub);
+ assert.ok(prompt.includes('The weekly stream moved to Saturday'), prompt);
+
+ const pageHtml = await fetch(`${h.base}/r/memory-room/memory`, { headers: { cookie: `ov_token=${owner.token}` } }).then((x) => x.text());
+ assert.ok(pageHtml.includes('What this room remembers'), 'the memory page renders');
+ assert.ok(pageHtml.includes('The weekly stream moved to Saturday'), 'the page shows the notes');
+
+ r = await h.http('POST', '/api/v1/rooms/memory-room/memory', { token: alice.token, body: { text: 'not allowed' } });
+ assert.strictEqual(r.status, 403);
+ r = await h.http('DELETE', `/api/v1/rooms/memory-room/memory/${added.id}`, { token: owner.token });
+ assert.strictEqual(r.body.removed, true);
+});
+
+t('the account export carries the memory and deletion removes it', async () => {
+ const account = require('../server/chat/account-data');
+ const d = db.getDb();
+ await db.run(`INSERT INTO room_memory (room_id, kind, text, source_message_ids, source_subjects, confidence, learned_by)
+ VALUES (?, 'fact', 'Lee is vegetarian.', '[]'::jsonb, ?::jsonb, 60, 'ai')`, [pub.id, JSON.stringify([alice.subject_id])]);
+ const part = await account.exportPart(d, alice.subject_id);
+ const file = part.files.find((f) => f.name === 'room_memory.json');
+ assert.ok(file, part.files.map((f) => f.name).join(','));
+ assert.ok(file.content.some((r) => r.text === 'Lee is vegetarian.'), JSON.stringify(file.content));
+ await account.erase(d, [alice.subject_id]);
+ const left = await db.all("SELECT * FROM room_memory WHERE room_id = ? AND source_subjects @> ?::jsonb", [pub.id, JSON.stringify([alice.subject_id])]);
+ assert.deepStrictEqual(left, []);
+});
+
+t.run(async () => { try { chatAi.stop(); } catch { /* */ } });