openvibers/openvibe.chat Commits

Chat: a room's memory, kept between summaries (#504)

OP OpenVibers committed 3 days ago · 93755bc9c4 · 10 files, +548 −7

* Chat: a room's memory, kept between summaries

* Chat: keep the schema test in step with room memory

Changes

ddiff --git a/migrations/0071_room_memory.sql b/migrations/0071_room_memory.sql
nnew file mode 100644
iindex 0000000..ce9c968
---- /dev/null
++++ b/migrations/0071_room_memory.sql
@@@ -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);
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex d7cdb96..1745cb1 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -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;
}
ddiff --git a/server/app.js b/server/app.js
iindex 8639d8b..92f129b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -179,6 +179,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
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.
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
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 64c93a1..880b177 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -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)'],
ddiff --git a/server/chat/memory-routes.js b/server/chat/memory-routes.js
nnew file mode 100644
iindex 0000000..1c46c8d
---- /dev/null
++++ b/server/chat/memory-routes.js
@@@ -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;
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex f8a7e00..3c8a083 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -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, tex
const 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 },
];
ddiff --git a/server/rooms/memory.js b/server/rooms/memory.js
nnew file mode 100644
iindex 0000000..8415ea8
---- /dev/null
++++ b/server/rooms/memory.js
@@@ -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,
++};
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex e350d36..ff62991 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -894,7 +894,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
const 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).';
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 1dd96dc..bf208c1 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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',
ddiff --git a/test/room-memory.test.js b/test/room-memory.test.js
nnew file mode 100644
iindex 0000000..310d31d
---- /dev/null
++++ b/test/room-memory.test.js
@@@ -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 { /* */ } });