openvibers/openvibe.chat Commits
Compact personal chat memory by day week and month (#514)
Changes
@@@ -127,6 +127,14 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes thRoutes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`):`GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`,`/relay/:platform/:username`, `/timeline/:username`.++ - **Person chat memory** (`server/ai/person-memory.js`) is a separate default-off worker++ (`CHAT_PERSON_MEMORY_ENABLED=1`). It summarizes new messages into channel day records,++ folds days into weeks and weeks into months, and keeps an own-view cross-channel rollup.++ The `chat.person-memory` workflow in OpenVibe.AI routes model calls through Exchange;++ the worker checks its $0.25 UTC-day spend ledger before calls (maximum 25 calls, 450 output tokens each).++ `GET /api/v1/people/:username/memory?channel=&level=day|week|month` returns public++ fields; the person and channel owner see the private fields. A person's `DELETE` erases++ their records and opts out; `PUT .../preferences` with `{"opted_out":false}` opts back in.## How it fits the networknnew file mode 100644@@@ -0,0 +1,29 @@++-- phase: expand++CREATE TABLE person_chat_memory (++ user_id bigint NOT NULL,++ channel_user_id bigint NOT NULL DEFAULT 0,++ level text NOT NULL CHECK (level IN ('day', 'week', 'month')),++ period_start text NOT NULL,++ summary text NOT NULL DEFAULT '',++ topics jsonb NOT NULL DEFAULT '[]'::jsonb,++ highlights jsonb NOT NULL DEFAULT '[]'::jsonb,++ tone text NOT NULL DEFAULT '',++ catchphrases jsonb NOT NULL DEFAULT '[]'::jsonb,++ people jsonb NOT NULL DEFAULT '[]'::jsonb,++ message_count bigint NOT NULL DEFAULT 0,++ last_message_id bigint NOT NULL DEFAULT 0,++ updated_at text NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (user_id, channel_user_id, level, period_start)++);++CREATE INDEX person_chat_memory_channel ON person_chat_memory (channel_user_id, level, period_start DESC);++CREATE TABLE person_chat_memory_settings (++ user_id bigint PRIMARY KEY,++ opted_out bigint NOT NULL DEFAULT 0,++ resume_after_id bigint NOT NULL DEFAULT 0,++ updated_at text NOT NULL DEFAULT ov_now()++);++CREATE TABLE person_chat_memory_spend (++ day text PRIMARY KEY,++ spent_usd numeric(12,6) NOT NULL DEFAULT 0,++ calls bigint NOT NULL DEFAULT 0++);@@@ -370,6 +370,7 @@ async function _channelPass() {// ── PER-USER ─────────────────────────────────────────────────────────────────async function _refreshUser(uid, maxId) {++ if (await require('./person-memory').optedOut(uid)) return false;const prior = await db.getChatAiSummary('user', uid, 'rolling');const now = Date.now();@@@ -636,6 +637,7 @@ async function getGlobalInsight() {}async function getUserInsight(userId) {++ if (await require('./person-memory').optedOut(userId)) return null;const row = await db.getChatAiSummary('user', userId, 'rolling');return row ? _parseOverviews(row) : null;}@@@ -82,6 +82,8 @@ async function structured(workflow, input, opts = {}) {});const run = body && (body.run || body);_lastProblem = problemOf(run);++ // Meter completed failed runs too: providers can bill even when no output is returned.++ if (run && !run.synthetic && opts.onRun) await opts.onRun(run);if (!run || run.status !== 'succeeded' || run.synthetic) return null;return run.output || null;} catch (err) {nnew file mode 100644@@@ -0,0 +1,226 @@++'use strict';++++// Chat owns this data. Day records read new messages; week and month records read only++// the records immediately below them. The worker is serial and bounded on every tick.++const db = require('../db/database');++const consent = require('../net/consent');++const ai = require('./client');++++const CAP_USD = 0.25;++const RESERVE_USD = 0.01;++const ENABLED = process.env.CHAT_PERSON_MEMORY_ENABLED === '1';++const MAX_MESSAGES = 180;++const MIN_NEW_MESSAGES = 15;++++const date = (s) => String(s || '').slice(0, 10);++const week = (s) => {++ const d = new Date(`${date(s)}T00:00:00Z`);++ d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));++ const monday = d.toISOString().slice(0, 10);++ // Split a week at a month boundary so a monthly rollup never includes another month.++ return monday < month(s) ? month(s) : monday;++};++const month = (s) => `${date(s).slice(0, 7)}-01`;++const text = (s, n = 600) => String(s || '').trim().slice(0, n);++const array = (x, n = 8) => Array.isArray(x) ? x.map(v => text(v, 100)).filter(Boolean).slice(0, n) : [];++const json = (x) => { if (Array.isArray(x)) return x; try { return JSON.parse(x || '[]'); } catch { return []; } };++++async function optedOut(userId) {++ const row = await db.get('SELECT opted_out FROM person_chat_memory_settings WHERE user_id = ?', [userId]);++ return !!(row && Number(row.opted_out));++}++++async function forget(userId, optOut = true) {++ const last = await db.get('SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE user_id = ?', [userId]);++ const user = await db.get('SELECT username FROM ctx_users WHERE id = ?', [userId]);++ if (user?.username) {++ const linked = await db.all('SELECT user_id, channel_user_id, level, period_start, people FROM person_chat_memory WHERE people @> ?::jsonb',++ [JSON.stringify([user.username])]);++ for (const row of linked) {++ await db.run(`UPDATE person_chat_memory SET people = ?::jsonb WHERE user_id = ? AND channel_user_id = ? AND level = ? AND period_start = ?`,++ [JSON.stringify(json(row.people).filter(name => name !== user.username)), row.user_id, row.channel_user_id, row.level, row.period_start]);++ }++ }++ await db.run('DELETE FROM person_chat_memory WHERE user_id = ?', [userId]);++ // The former rolling cache must also go: otherwise the old profile remains visible.++ await db.run("DELETE FROM chat_ai_summaries WHERE scope = 'user' AND subject_id = ?", [userId]);++ await db.run("DELETE FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ?", [userId]);++ await db.run(`INSERT INTO person_chat_memory_settings (user_id, opted_out, resume_after_id, updated_at) VALUES (?, ?, ?, ov_now())++ ON CONFLICT (user_id) DO UPDATE SET opted_out = excluded.opted_out,++ resume_after_id = GREATEST(person_chat_memory_settings.resume_after_id, excluded.resume_after_id),++ updated_at = excluded.updated_at`, [userId, optOut ? 1 : 0, Number(last?.id || 0)]);++}++++async function spentToday() {++ const row = await db.get('SELECT spent_usd, calls FROM person_chat_memory_spend WHERE day = ?', [new Date().toISOString().slice(0, 10)]);++ return { usd: Number(row?.spent_usd || 0), calls: Number(row?.calls || 0) };++}++++async function summarize(source, prior, level, period) {++ const spent = await spentToday();++ if (spent.usd + RESERVE_USD > CAP_USD || spent.calls >= 25) return null;++ try {++ return await ai.structured('chat.person-memory', {++ level, period, prior_summary: text(prior?.summary, 500),++ sources_text: JSON.stringify(source.map(({ people, ...item }) => item)).slice(0, 15000),++ }, { onRun: async (run) => {++ const measured = Number(run.usage?.cost_usd);++ const cost = Number.isFinite(measured) && measured >= 0 ? measured : RESERVE_USD;++ await db.run(`INSERT INTO person_chat_memory_spend (day, spent_usd, calls) VALUES (?, ?, 1)++ ON CONFLICT (day) DO UPDATE SET spent_usd = person_chat_memory_spend.spent_usd + excluded.spent_usd, calls = person_chat_memory_spend.calls + 1`,++ [new Date().toISOString().slice(0, 10), Math.max(cost, 0.000001)]);++ } });++ } catch { return null; }++}++++function build(source, model, prior, count, lastId) {++ const fallback = text(prior?.summary, 500) || `${count} chat message${count === 1 ? '' : 's'} in this period`;++ const additions = (Array.isArray(model?.highlights) ? model.highlights : []).slice(0, 5).map(h => {++ const item = source[Number(h.index)];++ if (!item) return null;++ return { detail: text(h.detail, 180), stream_id: item.stream_id || null, at: item.timestamp || item.period_start || null };++ }).filter(Boolean);++ const highlights = [...json(prior?.highlights), ...additions].slice(-5);++ const people = [...new Set([...json(prior?.people), ...source.flatMap(s => json(s.people))])].slice(0, 8);++ return { summary: text(model?.summary || fallback, 500), topics: model ? array(model.topics, 5) : json(prior?.topics), highlights,++ tone: text(model?.tone || prior?.tone, 120), catchphrases: model ? array(model.catchphrases, 5) : json(prior?.catchphrases), people,++ message_count: count, last_message_id: lastId };++}++++async function save(userId, channelId, level, period, row) {++ await db.run(`INSERT INTO person_chat_memory++ (user_id, channel_user_id, level, period_start, summary, topics, highlights, tone, catchphrases, people, message_count, last_message_id, updated_at)++ VALUES (?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?::jsonb, ?::jsonb, ?, ?, ov_now())++ ON CONFLICT (user_id, channel_user_id, level, period_start) DO UPDATE SET++ summary = excluded.summary, topics = excluded.topics, highlights = excluded.highlights,++ tone = excluded.tone, catchphrases = excluded.catchphrases, people = excluded.people,++ message_count = excluded.message_count, last_message_id = excluded.last_message_id, updated_at = excluded.updated_at`,++ [userId, channelId, level, period, row.summary, JSON.stringify(row.topics), JSON.stringify(row.highlights), row.tone,++ JSON.stringify(row.catchphrases), JSON.stringify(row.people), row.message_count, row.last_message_id]);++}++++async function rollup(userId, channelId, level, period, childLevel) {++ const start = level === 'week' ? week(period) : month(period);++ const end = new Date(`${start}T00:00:00Z`);++ if (level === 'week') end.setUTCDate(end.getUTCDate() + (7 - ((end.getUTCDay() + 6) % 7)));++ else end.setUTCMonth(end.getUTCMonth() + 1);++ const rows = await db.all(`SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = ?++ AND period_start >= ? AND period_start < ? ORDER BY period_start`, [userId, channelId, childLevel, start, end.toISOString().slice(0, 10)]);++ if (!rows.length) return;++ const prior = await db.get('SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = ? AND period_start = ?', [userId, channelId, level, start]);++ const source = rows.map(r => ({ summary: r.summary, period_start: r.period_start, people: json(r.people) }));++ const model = await summarize(source, prior, level, start);++ const count = rows.reduce((n, r) => n + Number(r.message_count || 0), 0);++ await save(userId, channelId, level, start, build(source, model, prior, count, Math.max(...rows.map(r => Number(r.last_message_id || 0)))));++}++++async function processDay(c) {++ if (await optedOut(c.user_id)) return;++ const messages = await db.all(`SELECT id, message, timestamp, stream_id, subject_id, reply_to_id FROM chat_messages++ WHERE user_id = ? AND COALESCE(channel_user_id, 0) = ? AND left(timestamp, 10) = ? AND id > ?++ AND is_deleted = 0 AND is_filtered = 0 AND message_type = 'chat'++ ORDER BY id ASC LIMIT ?`, [c.user_id, c.channel_user_id, c.period_start, Math.max(Number(c.last_message_id || 0), Number(c.resume_after_id || 0)), MAX_MESSAGES]);++ const allowed = await consent.filterRows(messages);++ const prior = await db.get('SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = ? AND period_start = ?', [c.user_id, c.channel_user_id, 'day', c.period_start]);++ if (!allowed.length) {++ // A withdrawn or unverifiable consent erases every cached level and the old profile.++ await forget(c.user_id, false);++ return;++ }++ // Mentions can name a person who opted out; strip them from the model input.++ const source = allowed.map(m => ({ message: text(m.message, 200).replace(/@[A-Za-z0-9_]+/g, '@someone'), timestamp: m.timestamp, stream_id: m.stream_id }));++ const replyIds = [...new Set(allowed.map(m => Number(m.reply_to_id || 0)).filter(Boolean))];++ if (replyIds.length) {++ const peers = await db.all(`SELECT target.id, u.username, u.subject_id, COALESCE(s.opted_out, 0) AS opted_out++ FROM chat_messages target JOIN ctx_users u ON u.id = target.user_id++ LEFT JOIN person_chat_memory_settings s ON s.user_id = u.id++ WHERE target.id IN (${replyIds.map(() => '?').join(',')})`, replyIds);++ const consented = await consent.allowedSubjects(peers.map(p => p.subject_id));++ const names = peers.filter(p => !Number(p.opted_out) && consented.has(p.subject_id) && p.username)++ .map(p => String(p.username)).slice(0, 8);++ if (names.length) source[0].people = names;++ }++ const model = await summarize(source, prior, 'day', c.period_start);++ await save(c.user_id, c.channel_user_id, 'day', c.period_start,++ build(source, model, prior, Number(prior?.message_count || 0) + allowed.length, messages[messages.length - 1].id));++ // A closed day is due for rollup only after its final raw batch. This avoids five++ // extra model calls for each 180-message batch of a historical day.++ const settled = c.period_start < new Date().toISOString().slice(0, 10) && messages.length < MAX_MESSAGES;++ if (settled) {++ await rollup(c.user_id, c.channel_user_id, 'week', c.period_start, 'day');++ await rollup(c.user_id, c.channel_user_id, 'month', c.period_start, 'week');++ }++ if (c.channel_user_id !== -1) {++ const daily = await db.all("SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id >= 0 AND level = 'day' AND period_start = ? AND message_count > 0", [c.user_id, c.period_start]);++ const source = daily.map(r => ({ summary: r.summary, period_start: r.period_start, people: json(r.people) }));++ const priorAll = await db.get("SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = -1 AND level = 'day' AND period_start = ?", [c.user_id, c.period_start]);++ const allModel = settled ? await summarize(source, priorAll, 'day', c.period_start)++ : { summary: source.map(r => r.summary).filter(Boolean).join(' · ').slice(0, 500), topics: [], highlights: [], tone: '', catchphrases: [] };++ await save(c.user_id, -1, 'day', c.period_start, build(source, allModel, priorAll,++ daily.reduce((n, r) => n + Number(r.message_count), 0), Math.max(...daily.map(r => Number(r.last_message_id)))));++ if (settled) {++ await rollup(c.user_id, -1, 'week', c.period_start, 'day');++ await rollup(c.user_id, -1, 'month', c.period_start, 'week');++ }++ }++}++++async function tick() {++ if (!ENABLED) return;++ const candidates = await db.all(`SELECT m.user_id, COALESCE(m.channel_user_id, 0) AS channel_user_id,++ left(m.timestamp, 10) AS period_start, COALESCE(p.last_message_id, 0) AS last_message_id,++ COALESCE(s.resume_after_id, 0) AS resume_after_id++ FROM chat_messages m LEFT JOIN person_chat_memory p ON p.user_id = m.user_id++ AND p.channel_user_id = COALESCE(m.channel_user_id, 0) AND p.level = 'day' AND p.period_start = left(m.timestamp, 10)++ LEFT JOIN person_chat_memory_settings s ON s.user_id = m.user_id++ WHERE m.user_id IS NOT NULL AND m.id > COALESCE(p.last_message_id, 0)++ AND m.id > COALESCE(s.resume_after_id, 0)++ AND m.is_deleted = 0 AND m.is_filtered = 0 AND m.message_type = 'chat'++ AND COALESCE(s.opted_out, 0) = 0++ GROUP BY m.user_id, COALESCE(m.channel_user_id, 0), left(m.timestamp, 10), p.last_message_id, s.resume_after_id++ HAVING COUNT(*) >= ? OR left(m.timestamp, 10) < ?++ ORDER BY period_start DESC LIMIT 2`, [MIN_NEW_MESSAGES, new Date().toISOString().slice(0, 10)]);++ for (const c of candidates) await processDay(c);++}++++async function read(userId, { channelId = null, level = 'day', privateView = false, limit = 60 } = {}) {++ if (await optedOut(userId)) return [];++ const params = [userId, level];++ let where = '';++ if (channelId != null) { where = ' AND channel_user_id = ?'; params.push(channelId); }++ else { where = ' AND channel_user_id = -1'; }++ params.push(limit);++ const rows = await db.all(`SELECT * FROM person_chat_memory WHERE user_id = ? AND level = ?${where} AND message_count > 0 ORDER BY period_start DESC, channel_user_id LIMIT ?`, params);++ let allowedPeople = new Set();++ if (privateView) {++ const names = [...new Set(rows.flatMap(r => json(r.people)))];++ if (names.length) {++ const peers = await db.all(`SELECT u.username, u.subject_id, COALESCE(s.opted_out, 0) AS opted_out++ FROM ctx_users u LEFT JOIN person_chat_memory_settings s ON s.user_id = u.id++ WHERE u.username IN (${names.map(() => '?').join(',')})`, names);++ const consented = await consent.allowedSubjects(peers.map(p => p.subject_id));++ allowedPeople = new Set(peers.filter(p => !Number(p.opted_out) && consented.has(p.subject_id)).map(p => p.username));++ }++ }++ return rows.map(r => ({ level: r.level, period_start: r.period_start, channel_user_id: Number(r.channel_user_id),++ summary: r.summary, topics: json(r.topics), highlights: json(r.highlights), message_count: Number(r.message_count),++ ...(privateView ? { tone: r.tone, catchphrases: json(r.catchphrases), people: json(r.people).filter(name => allowedPeople.has(name)) } : {}) }));++}++++let timer = null;++let busy = false;++function start() {++ if (!ENABLED || timer) return;++ const due = async () => {++ if (busy) return;++ busy = true;++ try { await tick(); } catch (err) { console.warn('[PersonMemory]', err.message); }++ finally { busy = false; }++ };++ timer = setInterval(due, 60_000);++ timer.unref?.();++ due();++}++function stop() { if (timer) clearInterval(timer); timer = null; }++++module.exports = { ENABLED, CAP_USD, tick, start, stop, read, forget, optedOut, spentToday, week, month };@@@ -181,6 +181,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settinapp.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.use('/api/v1/people', require('./chat/person-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@@@ -45,6 +45,8 @@ async function liveIds(d, subjects) {const LIMIT = ROW_LIMIT + 1;const EXPORTS = [['chat_messages.json', `SELECT * FROM chat_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['person_chat_memory.json', `SELECT * FROM person_chat_memory WHERE user_id = ANY(@ids) ORDER BY period_start DESC LIMIT ${LIMIT}`],++ ['person_chat_memory_settings.json', `SELECT * FROM person_chat_memory_settings WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['vibe_canvases.json', `SELECT c.* FROM vibe_canvases c JOIN rooms r ON r.id = c.room_id WHERE r.owner_subject = ANY(@s) OR r.owner_id = ANY(@ids) ORDER BY c.updated_at DESC LIMIT ${LIMIT}`],['vibe_canvas_versions.json', `SELECT v.* FROM vibe_canvas_versions v JOIN vibe_canvases c ON c.id = v.canvas_id JOIN rooms r ON r.id = c.room_id WHERE r.owner_subject = ANY(@s) OR r.owner_id = ANY(@ids) ORDER BY v.created_at DESC LIMIT ${LIMIT}`],@@@ -139,6 +141,8 @@ const ERASE_AFTER_ROOMS = [// A person's own summaries and timeline are keyed by their Live id (scope 'user').['ai_summaries', "DELETE FROM chat_ai_summaries WHERE scope = 'user' AND subject_id = ANY(@ids)"],['timeline', "DELETE FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ANY(@ids)"],++ ['person_chat_memory', 'DELETE FROM person_chat_memory WHERE user_id = ANY(@ids)'],++ ['person_chat_memory_settings', 'DELETE FROM person_chat_memory_settings WHERE user_id = ANY(@ids)'],['tags', 'DELETE FROM user_tags WHERE user_id = ANY(@ids)'],['moderator_roles', 'DELETE FROM channel_moderators WHERE user_id = ANY(@ids)'],['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++const express = require('express');++const router = express.Router();++const ctx = require('../live-context');++const consent = require('../net/consent');++const memory = require('../ai/person-memory');++const { optionalAuth, requireAuth } = require('../auth/auth');++++async function person(req, res) {++ const username = String(req.params.username || '').trim();++ if (!/^[a-zA-Z0-9_]{1,60}$/.test(username)) { res.status(404).json({ error: 'Person not found' }); return null; }++ const user = await ctx.getUserByUsername(username);++ if (!user) { res.status(404).json({ error: 'Person not found' }); return null; }++ return user;++}++++router.get('/:username/memory', optionalAuth, async (req, res) => {++ try {++ const user = await person(req, res); if (!user) return;++ const level = String(req.query.level || 'day');++ if (!['day', 'week', 'month'].includes(level)) return res.status(400).json({ error: 'Invalid level' });++ let channelId = null;++ if (req.query.channel) {++ const channel = await ctx.getUserByUsername(String(req.query.channel));++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ channelId = Number(channel.id);++ }++ const self = Number(req.user?.id) === Number(user.id);++ if (channelId == null && !self) return res.status(400).json({ error: 'Select a channel' });++ const full = self || (channelId != null && Number(req.user?.id) === channelId);++ res.set('Cache-Control', 'private, no-store');++ // A withdrawn or unverifiable consent removes existing memory as well as blocking new work.++ if (!user.subject_id || !(await consent.allowedSubjects([user.subject_id])).has(user.subject_id)) {++ await memory.forget(user.id, false);++ return res.json({ username: user.username, level, channel: req.query.channel || null, entries: [], opted_out: false, enabled: memory.ENABLED });++ }++ return res.json({ username: user.username, level, channel: req.query.channel || null,++ entries: await memory.read(user.id, { channelId, level, privateView: full }),++ opted_out: await memory.optedOut(user.id), enabled: memory.ENABLED });++ } catch (err) { console.warn('[PersonMemory] read:', err.message); return res.status(500).json({ error: 'Memory unavailable' }); }++});++++router.delete('/:username/memory', requireAuth, async (req, res) => {++ try {++ const user = await person(req, res); if (!user) return;++ if (Number(req.user.id) !== Number(user.id)) return res.status(403).json({ error: 'Only this person can delete their memory' });++ await memory.forget(user.id, true);++ res.set('Cache-Control', 'no-store');++ return res.json({ deleted: true, opted_out: true });++ } catch (err) { return res.status(500).json({ error: 'Could not delete memory' }); }++});++++router.put('/:username/memory/preferences', requireAuth, async (req, res) => {++ try {++ const user = await person(req, res); if (!user) return;++ if (Number(req.user.id) !== Number(user.id)) return res.status(403).json({ error: 'Only this person can change memory preferences' });++ if (typeof req.body?.opted_out !== 'boolean') return res.status(400).json({ error: 'opted_out must be a boolean' });++ await memory.forget(user.id, req.body.opted_out);++ res.set('Cache-Control', 'no-store');++ return res.json({ opted_out: req.body.opted_out });++ } catch (err) { return res.status(500).json({ error: 'Could not change memory preferences' }); }++});++++module.exports = router;@@@ -88,6 +88,7 @@ async function start() {// The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1; it// writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).chatAi.start();++ require('./ai/person-memory').start();// Workspace rooms' queues (server/rooms/workspace.js): the OpenVibe.Actor tasks they started, followed every 10 s.require('./rooms/workspace').start();// OpenVibe's own computers for parties (server/rooms/party-pool.js): release holds that went idle and seat the@@@ -36,6 +36,7 @@ t('export, then erase and confirm', async () => {taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members','chat_stage_speakers','ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'dm_link_preview_settings', 'emotes', 'inbox_actions',++ 'person_chat_memory', 'person_chat_memory_settings','inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions', 'chat_activity','user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_canvases', 'vibe_canvas_versions', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],exempt: {nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++const assert = require('assert');++const { boot, suite } = require('./helpers');++++const t = suite('person-memory');++let h, db, memory, person, channel, outsider;++const yesterday = () => new Date(Date.now() - 86400000).toISOString().replace('T', ' ').replace('Z', '');++++t('boot', async () => {++ h = await boot({ env: { CHAT_PERSON_MEMORY_ENABLED: '1' } });++ db = require('../server/db/database');++ memory = require('../server/ai/person-memory');++ person = h.addUser('memoryperson', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B1' });++ channel = h.addUser('memorychannel', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2' });++ outsider = h.addUser('memoryoutsider', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B3' });++ h.consent.allowed.add(person.subject_id);++ await h.ctx.sync();++});++++t('new messages form one day, then week and month from the day', async () => {++ for (const message of ['hello there', 'we are playing a game']) {++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)++ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, message, yesterday()]);++ }++ await memory.tick();++ const day = await memory.read(person.id, { channelId: channel.id });++ assert.strictEqual(day.length, 1);++ assert.strictEqual(day[0].message_count, 2);++ assert.strictEqual(day[0].highlights.length, 0);++ assert.strictEqual((await memory.read(person.id, { channelId: channel.id, level: 'week' }))[0].message_count, 2);++ assert.strictEqual((await memory.read(person.id, { channelId: channel.id, level: 'month' }))[0].message_count, 2);++ assert.strictEqual((await memory.read(person.id))[0].message_count, 2);++ await db.run("UPDATE person_chat_memory SET summary = 'Alice discussed a game.' WHERE user_id = ? AND channel_user_id = ? AND level = 'day'", [person.id, channel.id]);++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)++ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'one more', yesterday()]);++ await memory.tick();++ const incremented = (await memory.read(person.id, { channelId: channel.id }))[0];++ assert.strictEqual(incremented.message_count, 3);++ assert.strictEqual(incremented.summary, 'Alice discussed a game.', 'a capped model call does not discard earlier memory');++});++++t('read exposes private fields only to the person or channel owner', async () => {++ const publicRead = await h.http('GET', `/api/v1/people/${person.username}/memory?channel=${channel.username}`);++ assert.strictEqual(publicRead.status, 200, publicRead.text);++ assert.strictEqual(publicRead.body.entries[0].tone, undefined);++ const ownRead = await h.http('GET', `/api/v1/people/${person.username}/memory?channel=${channel.username}`, { token: person.token });++ assert.strictEqual(ownRead.status, 200, ownRead.text);++ assert.ok(Object.hasOwn(ownRead.body.entries[0], 'tone'));++ const noChannel = await h.http('GET', `/api/v1/people/${person.username}/memory`, { token: outsider.token });++ assert.strictEqual(noChannel.status, 400);++});++++t('reply partners require their own consent and disappear after opt out', async () => {++ h.consent.allowed.add(channel.subject_id);++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)++ VALUES (?, ?, ?, ?, ?, ?)`, [channel.id, channel.id, channel.username, channel.subject_id, 'welcome', yesterday()]);++ const target = await db.get('SELECT MAX(id) AS id FROM chat_messages WHERE user_id = ?', [channel.id]);++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp, reply_to_id)++ VALUES (?, ?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'thanks', yesterday(), target.id]);++ await memory.tick();++ const visible = await memory.read(person.id, { channelId: channel.id, privateView: true });++ assert.ok(visible[0].people.includes(channel.username));++ await memory.forget(channel.id, true);++ const hidden = await memory.read(person.id, { channelId: channel.id, privateView: true });++ assert.ok(!hidden[0].people.includes(channel.username));++ const stored = await db.get("SELECT people FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = 'day'", [person.id, channel.id]);++ assert.ok(!JSON.stringify(stored.people).includes(channel.username), 'opt out also erases stored references');++});++++t('the Chat workflow records measured Exchange-backed run cost', async () => {++ const config = require('../server/config');++ const client = require('../server/ai/client');++ config.ai.enabled = true;++ client._setClient({ runs: { create: async (workflow, input) => {++ assert.strictEqual(workflow, 'chat.person-memory');++ assert.ok(['day', 'week', 'month'].includes(input.level));++ return { run: { status: 'succeeded', usage: { cost_usd: 0.002 }, output: {++ summary: 'A game and a win.', topics: ['game'], highlights: [], tone: 'brief', catchphrases: [],++ } } };++ } } });++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)++ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'we won', yesterday()]);++ await memory.tick();++ assert.strictEqual((await memory.read(person.id, { channelId: channel.id }))[0].summary, 'A game and a win.');++ const beforeFailure = await memory.spentToday();++ assert.ok(beforeFailure.usd >= 0.002);++ client._setClient({ runs: { create: async () => ({ run: { status: 'failed', usage: { cost_usd: 0.003 } } }) } });++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)++ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'another game', yesterday()]);++ await memory.tick();++ assert.strictEqual((await memory.read(person.id, { channelId: channel.id }))[0].summary, 'A game and a win.');++ assert.ok((await memory.spentToday()).usd >= beforeFailure.usd + 0.003, 'a billed failed run counts toward the cap');++ client._reset();++ config.ai.enabled = false;++});++++t('the person can erase and opt out; the worker does not rebuild it', async () => {++ const erased = await h.http('DELETE', `/api/v1/people/${person.username}/memory`, { token: person.token });++ assert.strictEqual(erased.status, 200, erased.text);++ assert.strictEqual(erased.body.opted_out, true);++ await memory.tick();++ assert.deepStrictEqual(await memory.read(person.id, { channelId: channel.id }), []);++ const outsiderDelete = await h.http('DELETE', `/api/v1/people/${person.username}/memory`, { token: outsider.token });++ assert.strictEqual(outsiderDelete.status, 403);++ const resume = await h.http('PUT', `/api/v1/people/${person.username}/memory/preferences`, { token: person.token, body: { opted_out: false } });++ assert.strictEqual(resume.status, 200, resume.text);++ await memory.tick();++ assert.deepStrictEqual(await memory.read(person.id, { channelId: channel.id }), [], 'old messages remain forgotten');++});++++t('withdrawn consent erases a newly built memory', async () => {++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)++ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'new day', yesterday()]);++ await memory.tick();++ assert.strictEqual((await memory.read(person.id, { channelId: channel.id }))[0].message_count, 1);++ h.consent.allowed.delete(person.subject_id);++ require('../server/net/consent')._reset();++ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)++ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'withdrawn', yesterday()]);++ await memory.tick();++ assert.deepStrictEqual(await memory.read(person.id, { channelId: channel.id }), []);++});++++t('close', async () => { memory.stop(); await h.close(); });@@@ -23,7 +23,8 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.const TABLES = ['vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import-- 'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',++ 'chat_messages', 'person_chat_memory', 'person_chat_memory_settings', 'person_chat_memory_spend',++ 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides','channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions','channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events','ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
@@ -127,6 +127,14 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -127,6 +127,14 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
Routes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`):
Routes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`):
`GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`,
`GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`,
`/relay/:platform/:username`, `/timeline/:username`.
`/relay/:platform/:username`, `/timeline/:username`.
+ - **Person chat memory** (`server/ai/person-memory.js`) is a separate default-off worker
+ (`CHAT_PERSON_MEMORY_ENABLED=1`). It summarizes new messages into channel day records,
+ folds days into weeks and weeks into months, and keeps an own-view cross-channel rollup.
+ The `chat.person-memory` workflow in OpenVibe.AI routes model calls through Exchange;
+ the worker checks its $0.25 UTC-day spend ledger before calls (maximum 25 calls, 450 output tokens each).
+ `GET /api/v1/people/:username/memory?channel=&level=day|week|month` returns public
+ fields; the person and channel owner see the private fields. A person's `DELETE` erases
+ their records and opts out; `PUT .../preferences` with `{"opted_out":false}` opts back in.
## How it fits the network
## How it fits the network
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+-- phase: expand
+CREATE TABLE person_chat_memory (
+ user_id bigint NOT NULL,
+ channel_user_id bigint NOT NULL DEFAULT 0,
+ level text NOT NULL CHECK (level IN ('day', 'week', 'month')),
+ period_start text NOT NULL,
+ summary text NOT NULL DEFAULT '',
+ topics jsonb NOT NULL DEFAULT '[]'::jsonb,
+ highlights jsonb NOT NULL DEFAULT '[]'::jsonb,
+ tone text NOT NULL DEFAULT '',
+ catchphrases jsonb NOT NULL DEFAULT '[]'::jsonb,
+ people jsonb NOT NULL DEFAULT '[]'::jsonb,
+ message_count bigint NOT NULL DEFAULT 0,
+ last_message_id bigint NOT NULL DEFAULT 0,
+ updated_at text NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (user_id, channel_user_id, level, period_start)
+);
+CREATE INDEX person_chat_memory_channel ON person_chat_memory (channel_user_id, level, period_start DESC);
+CREATE TABLE person_chat_memory_settings (
+ user_id bigint PRIMARY KEY,
+ opted_out bigint NOT NULL DEFAULT 0,
+ resume_after_id bigint NOT NULL DEFAULT 0,
+ updated_at text NOT NULL DEFAULT ov_now()
+);
+CREATE TABLE person_chat_memory_spend (
+ day text PRIMARY KEY,
+ spent_usd numeric(12,6) NOT NULL DEFAULT 0,
+ calls bigint NOT NULL DEFAULT 0
+);
@@ -370,6 +370,7 @@ async function _channelPass() {
@@ -370,6 +370,7 @@ async function _channelPass() {
// ── PER-USER ─────────────────────────────────────────────────────────────────
// ── PER-USER ─────────────────────────────────────────────────────────────────
async function _refreshUser(uid, maxId) {
async function _refreshUser(uid, maxId) {
+ if (await require('./person-memory').optedOut(uid)) return false;
const prior = await db.getChatAiSummary('user', uid, 'rolling');
const prior = await db.getChatAiSummary('user', uid, 'rolling');
const now = Date.now();
const now = Date.now();
@@ -636,6 +637,7 @@ async function getGlobalInsight() {
@@ -636,6 +637,7 @@ async function getGlobalInsight() {
}
}
async function getUserInsight(userId) {
async function getUserInsight(userId) {
+ if (await require('./person-memory').optedOut(userId)) return null;
const row = await db.getChatAiSummary('user', userId, 'rolling');
const row = await db.getChatAiSummary('user', userId, 'rolling');
return row ? _parseOverviews(row) : null;
return row ? _parseOverviews(row) : null;
}
}
@@ -82,6 +82,8 @@ async function structured(workflow, input, opts = {}) {
@@ -82,6 +82,8 @@ async function structured(workflow, input, opts = {}) {
});
});
const run = body && (body.run || body);
const run = body && (body.run || body);
_lastProblem = problemOf(run);
_lastProblem = problemOf(run);
+ // Meter completed failed runs too: providers can bill even when no output is returned.
+ if (run && !run.synthetic && opts.onRun) await opts.onRun(run);
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
return run.output || null;
return run.output || null;
} catch (err) {
} catch (err) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,226 @@
@@ -0,0 +1,226 @@
+'use strict';
+
+// Chat owns this data. Day records read new messages; week and month records read only
+// the records immediately below them. The worker is serial and bounded on every tick.
+const db = require('../db/database');
+const consent = require('../net/consent');
+const ai = require('./client');
+
+const CAP_USD = 0.25;
+const RESERVE_USD = 0.01;
+const ENABLED = process.env.CHAT_PERSON_MEMORY_ENABLED === '1';
+const MAX_MESSAGES = 180;
+const MIN_NEW_MESSAGES = 15;
+
+const date = (s) => String(s || '').slice(0, 10);
+const week = (s) => {
+ const d = new Date(`${date(s)}T00:00:00Z`);
+ d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
+ const monday = d.toISOString().slice(0, 10);
+ // Split a week at a month boundary so a monthly rollup never includes another month.
+ return monday < month(s) ? month(s) : monday;
+};
+const month = (s) => `${date(s).slice(0, 7)}-01`;
+const text = (s, n = 600) => String(s || '').trim().slice(0, n);
+const array = (x, n = 8) => Array.isArray(x) ? x.map(v => text(v, 100)).filter(Boolean).slice(0, n) : [];
+const json = (x) => { if (Array.isArray(x)) return x; try { return JSON.parse(x || '[]'); } catch { return []; } };
+
+async function optedOut(userId) {
+ const row = await db.get('SELECT opted_out FROM person_chat_memory_settings WHERE user_id = ?', [userId]);
+ return !!(row && Number(row.opted_out));
+}
+
+async function forget(userId, optOut = true) {
+ const last = await db.get('SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE user_id = ?', [userId]);
+ const user = await db.get('SELECT username FROM ctx_users WHERE id = ?', [userId]);
+ if (user?.username) {
+ const linked = await db.all('SELECT user_id, channel_user_id, level, period_start, people FROM person_chat_memory WHERE people @> ?::jsonb',
+ [JSON.stringify([user.username])]);
+ for (const row of linked) {
+ await db.run(`UPDATE person_chat_memory SET people = ?::jsonb WHERE user_id = ? AND channel_user_id = ? AND level = ? AND period_start = ?`,
+ [JSON.stringify(json(row.people).filter(name => name !== user.username)), row.user_id, row.channel_user_id, row.level, row.period_start]);
+ }
+ }
+ await db.run('DELETE FROM person_chat_memory WHERE user_id = ?', [userId]);
+ // The former rolling cache must also go: otherwise the old profile remains visible.
+ await db.run("DELETE FROM chat_ai_summaries WHERE scope = 'user' AND subject_id = ?", [userId]);
+ await db.run("DELETE FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ?", [userId]);
+ await db.run(`INSERT INTO person_chat_memory_settings (user_id, opted_out, resume_after_id, updated_at) VALUES (?, ?, ?, ov_now())
+ ON CONFLICT (user_id) DO UPDATE SET opted_out = excluded.opted_out,
+ resume_after_id = GREATEST(person_chat_memory_settings.resume_after_id, excluded.resume_after_id),
+ updated_at = excluded.updated_at`, [userId, optOut ? 1 : 0, Number(last?.id || 0)]);
+}
+
+async function spentToday() {
+ const row = await db.get('SELECT spent_usd, calls FROM person_chat_memory_spend WHERE day = ?', [new Date().toISOString().slice(0, 10)]);
+ return { usd: Number(row?.spent_usd || 0), calls: Number(row?.calls || 0) };
+}
+
+async function summarize(source, prior, level, period) {
+ const spent = await spentToday();
+ if (spent.usd + RESERVE_USD > CAP_USD || spent.calls >= 25) return null;
+ try {
+ return await ai.structured('chat.person-memory', {
+ level, period, prior_summary: text(prior?.summary, 500),
+ sources_text: JSON.stringify(source.map(({ people, ...item }) => item)).slice(0, 15000),
+ }, { onRun: async (run) => {
+ const measured = Number(run.usage?.cost_usd);
+ const cost = Number.isFinite(measured) && measured >= 0 ? measured : RESERVE_USD;
+ await db.run(`INSERT INTO person_chat_memory_spend (day, spent_usd, calls) VALUES (?, ?, 1)
+ ON CONFLICT (day) DO UPDATE SET spent_usd = person_chat_memory_spend.spent_usd + excluded.spent_usd, calls = person_chat_memory_spend.calls + 1`,
+ [new Date().toISOString().slice(0, 10), Math.max(cost, 0.000001)]);
+ } });
+ } catch { return null; }
+}
+
+function build(source, model, prior, count, lastId) {
+ const fallback = text(prior?.summary, 500) || `${count} chat message${count === 1 ? '' : 's'} in this period`;
+ const additions = (Array.isArray(model?.highlights) ? model.highlights : []).slice(0, 5).map(h => {
+ const item = source[Number(h.index)];
+ if (!item) return null;
+ return { detail: text(h.detail, 180), stream_id: item.stream_id || null, at: item.timestamp || item.period_start || null };
+ }).filter(Boolean);
+ const highlights = [...json(prior?.highlights), ...additions].slice(-5);
+ const people = [...new Set([...json(prior?.people), ...source.flatMap(s => json(s.people))])].slice(0, 8);
+ return { summary: text(model?.summary || fallback, 500), topics: model ? array(model.topics, 5) : json(prior?.topics), highlights,
+ tone: text(model?.tone || prior?.tone, 120), catchphrases: model ? array(model.catchphrases, 5) : json(prior?.catchphrases), people,
+ message_count: count, last_message_id: lastId };
+}
+
+async function save(userId, channelId, level, period, row) {
+ await db.run(`INSERT INTO person_chat_memory
+ (user_id, channel_user_id, level, period_start, summary, topics, highlights, tone, catchphrases, people, message_count, last_message_id, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?::jsonb, ?::jsonb, ?, ?, ov_now())
+ ON CONFLICT (user_id, channel_user_id, level, period_start) DO UPDATE SET
+ summary = excluded.summary, topics = excluded.topics, highlights = excluded.highlights,
+ tone = excluded.tone, catchphrases = excluded.catchphrases, people = excluded.people,
+ message_count = excluded.message_count, last_message_id = excluded.last_message_id, updated_at = excluded.updated_at`,
+ [userId, channelId, level, period, row.summary, JSON.stringify(row.topics), JSON.stringify(row.highlights), row.tone,
+ JSON.stringify(row.catchphrases), JSON.stringify(row.people), row.message_count, row.last_message_id]);
+}
+
+async function rollup(userId, channelId, level, period, childLevel) {
+ const start = level === 'week' ? week(period) : month(period);
+ const end = new Date(`${start}T00:00:00Z`);
+ if (level === 'week') end.setUTCDate(end.getUTCDate() + (7 - ((end.getUTCDay() + 6) % 7)));
+ else end.setUTCMonth(end.getUTCMonth() + 1);
+ const rows = await db.all(`SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = ?
+ AND period_start >= ? AND period_start < ? ORDER BY period_start`, [userId, channelId, childLevel, start, end.toISOString().slice(0, 10)]);
+ if (!rows.length) return;
+ const prior = await db.get('SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = ? AND period_start = ?', [userId, channelId, level, start]);
+ const source = rows.map(r => ({ summary: r.summary, period_start: r.period_start, people: json(r.people) }));
+ const model = await summarize(source, prior, level, start);
+ const count = rows.reduce((n, r) => n + Number(r.message_count || 0), 0);
+ await save(userId, channelId, level, start, build(source, model, prior, count, Math.max(...rows.map(r => Number(r.last_message_id || 0)))));
+}
+
+async function processDay(c) {
+ if (await optedOut(c.user_id)) return;
+ const messages = await db.all(`SELECT id, message, timestamp, stream_id, subject_id, reply_to_id FROM chat_messages
+ WHERE user_id = ? AND COALESCE(channel_user_id, 0) = ? AND left(timestamp, 10) = ? AND id > ?
+ AND is_deleted = 0 AND is_filtered = 0 AND message_type = 'chat'
+ ORDER BY id ASC LIMIT ?`, [c.user_id, c.channel_user_id, c.period_start, Math.max(Number(c.last_message_id || 0), Number(c.resume_after_id || 0)), MAX_MESSAGES]);
+ const allowed = await consent.filterRows(messages);
+ const prior = await db.get('SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = ? AND period_start = ?', [c.user_id, c.channel_user_id, 'day', c.period_start]);
+ if (!allowed.length) {
+ // A withdrawn or unverifiable consent erases every cached level and the old profile.
+ await forget(c.user_id, false);
+ return;
+ }
+ // Mentions can name a person who opted out; strip them from the model input.
+ const source = allowed.map(m => ({ message: text(m.message, 200).replace(/@[A-Za-z0-9_]+/g, '@someone'), timestamp: m.timestamp, stream_id: m.stream_id }));
+ const replyIds = [...new Set(allowed.map(m => Number(m.reply_to_id || 0)).filter(Boolean))];
+ if (replyIds.length) {
+ const peers = await db.all(`SELECT target.id, u.username, u.subject_id, COALESCE(s.opted_out, 0) AS opted_out
+ FROM chat_messages target JOIN ctx_users u ON u.id = target.user_id
+ LEFT JOIN person_chat_memory_settings s ON s.user_id = u.id
+ WHERE target.id IN (${replyIds.map(() => '?').join(',')})`, replyIds);
+ const consented = await consent.allowedSubjects(peers.map(p => p.subject_id));
+ const names = peers.filter(p => !Number(p.opted_out) && consented.has(p.subject_id) && p.username)
+ .map(p => String(p.username)).slice(0, 8);
+ if (names.length) source[0].people = names;
+ }
+ const model = await summarize(source, prior, 'day', c.period_start);
+ await save(c.user_id, c.channel_user_id, 'day', c.period_start,
+ build(source, model, prior, Number(prior?.message_count || 0) + allowed.length, messages[messages.length - 1].id));
+ // A closed day is due for rollup only after its final raw batch. This avoids five
+ // extra model calls for each 180-message batch of a historical day.
+ const settled = c.period_start < new Date().toISOString().slice(0, 10) && messages.length < MAX_MESSAGES;
+ if (settled) {
+ await rollup(c.user_id, c.channel_user_id, 'week', c.period_start, 'day');
+ await rollup(c.user_id, c.channel_user_id, 'month', c.period_start, 'week');
+ }
+ if (c.channel_user_id !== -1) {
+ const daily = await db.all("SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id >= 0 AND level = 'day' AND period_start = ? AND message_count > 0", [c.user_id, c.period_start]);
+ const source = daily.map(r => ({ summary: r.summary, period_start: r.period_start, people: json(r.people) }));
+ const priorAll = await db.get("SELECT * FROM person_chat_memory WHERE user_id = ? AND channel_user_id = -1 AND level = 'day' AND period_start = ?", [c.user_id, c.period_start]);
+ const allModel = settled ? await summarize(source, priorAll, 'day', c.period_start)
+ : { summary: source.map(r => r.summary).filter(Boolean).join(' · ').slice(0, 500), topics: [], highlights: [], tone: '', catchphrases: [] };
+ await save(c.user_id, -1, 'day', c.period_start, build(source, allModel, priorAll,
+ daily.reduce((n, r) => n + Number(r.message_count), 0), Math.max(...daily.map(r => Number(r.last_message_id)))));
+ if (settled) {
+ await rollup(c.user_id, -1, 'week', c.period_start, 'day');
+ await rollup(c.user_id, -1, 'month', c.period_start, 'week');
+ }
+ }
+}
+
+async function tick() {
+ if (!ENABLED) return;
+ const candidates = await db.all(`SELECT m.user_id, COALESCE(m.channel_user_id, 0) AS channel_user_id,
+ left(m.timestamp, 10) AS period_start, COALESCE(p.last_message_id, 0) AS last_message_id,
+ COALESCE(s.resume_after_id, 0) AS resume_after_id
+ FROM chat_messages m LEFT JOIN person_chat_memory p ON p.user_id = m.user_id
+ AND p.channel_user_id = COALESCE(m.channel_user_id, 0) AND p.level = 'day' AND p.period_start = left(m.timestamp, 10)
+ LEFT JOIN person_chat_memory_settings s ON s.user_id = m.user_id
+ WHERE m.user_id IS NOT NULL AND m.id > COALESCE(p.last_message_id, 0)
+ AND m.id > COALESCE(s.resume_after_id, 0)
+ AND m.is_deleted = 0 AND m.is_filtered = 0 AND m.message_type = 'chat'
+ AND COALESCE(s.opted_out, 0) = 0
+ GROUP BY m.user_id, COALESCE(m.channel_user_id, 0), left(m.timestamp, 10), p.last_message_id, s.resume_after_id
+ HAVING COUNT(*) >= ? OR left(m.timestamp, 10) < ?
+ ORDER BY period_start DESC LIMIT 2`, [MIN_NEW_MESSAGES, new Date().toISOString().slice(0, 10)]);
+ for (const c of candidates) await processDay(c);
+}
+
+async function read(userId, { channelId = null, level = 'day', privateView = false, limit = 60 } = {}) {
+ if (await optedOut(userId)) return [];
+ const params = [userId, level];
+ let where = '';
+ if (channelId != null) { where = ' AND channel_user_id = ?'; params.push(channelId); }
+ else { where = ' AND channel_user_id = -1'; }
+ params.push(limit);
+ const rows = await db.all(`SELECT * FROM person_chat_memory WHERE user_id = ? AND level = ?${where} AND message_count > 0 ORDER BY period_start DESC, channel_user_id LIMIT ?`, params);
+ let allowedPeople = new Set();
+ if (privateView) {
+ const names = [...new Set(rows.flatMap(r => json(r.people)))];
+ if (names.length) {
+ const peers = await db.all(`SELECT u.username, u.subject_id, COALESCE(s.opted_out, 0) AS opted_out
+ FROM ctx_users u LEFT JOIN person_chat_memory_settings s ON s.user_id = u.id
+ WHERE u.username IN (${names.map(() => '?').join(',')})`, names);
+ const consented = await consent.allowedSubjects(peers.map(p => p.subject_id));
+ allowedPeople = new Set(peers.filter(p => !Number(p.opted_out) && consented.has(p.subject_id)).map(p => p.username));
+ }
+ }
+ return rows.map(r => ({ level: r.level, period_start: r.period_start, channel_user_id: Number(r.channel_user_id),
+ summary: r.summary, topics: json(r.topics), highlights: json(r.highlights), message_count: Number(r.message_count),
+ ...(privateView ? { tone: r.tone, catchphrases: json(r.catchphrases), people: json(r.people).filter(name => allowedPeople.has(name)) } : {}) }));
+}
+
+let timer = null;
+let busy = false;
+function start() {
+ if (!ENABLED || timer) return;
+ const due = async () => {
+ if (busy) return;
+ busy = true;
+ try { await tick(); } catch (err) { console.warn('[PersonMemory]', err.message); }
+ finally { busy = false; }
+ };
+ timer = setInterval(due, 60_000);
+ timer.unref?.();
+ due();
+}
+function stop() { if (timer) clearInterval(timer); timer = null; }
+
+module.exports = { ENABLED, CAP_USD, tick, start, stop, read, forget, optedOut, spentToday, week, month };
@@ -181,6 +181,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -181,6 +181,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
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.
// 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.use('/api/v1/rooms', require('./chat/memory-routes'));
+ app.use('/api/v1/people', require('./chat/person-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
@@ -45,6 +45,8 @@ async function liveIds(d, subjects) {
@@ -45,6 +45,8 @@ async function liveIds(d, subjects) {
const LIMIT = ROW_LIMIT + 1;
const LIMIT = ROW_LIMIT + 1;
const EXPORTS = [
const EXPORTS = [
['chat_messages.json', `SELECT * FROM chat_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['chat_messages.json', `SELECT * FROM chat_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['person_chat_memory.json', `SELECT * FROM person_chat_memory WHERE user_id = ANY(@ids) ORDER BY period_start DESC LIMIT ${LIMIT}`],
+ ['person_chat_memory_settings.json', `SELECT * FROM person_chat_memory_settings WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['vibe_canvases.json', `SELECT c.* FROM vibe_canvases c JOIN rooms r ON r.id = c.room_id WHERE r.owner_subject = ANY(@s) OR r.owner_id = ANY(@ids) ORDER BY c.updated_at DESC LIMIT ${LIMIT}`],
['vibe_canvases.json', `SELECT c.* FROM vibe_canvases c JOIN rooms r ON r.id = c.room_id WHERE r.owner_subject = ANY(@s) OR r.owner_id = ANY(@ids) ORDER BY c.updated_at DESC LIMIT ${LIMIT}`],
['vibe_canvas_versions.json', `SELECT v.* FROM vibe_canvas_versions v JOIN vibe_canvases c ON c.id = v.canvas_id JOIN rooms r ON r.id = c.room_id WHERE r.owner_subject = ANY(@s) OR r.owner_id = ANY(@ids) ORDER BY v.created_at DESC LIMIT ${LIMIT}`],
['vibe_canvas_versions.json', `SELECT v.* FROM vibe_canvas_versions v JOIN vibe_canvases c ON c.id = v.canvas_id JOIN rooms r ON r.id = c.room_id WHERE r.owner_subject = ANY(@s) OR r.owner_id = ANY(@ids) ORDER BY v.created_at DESC LIMIT ${LIMIT}`],
@@ -139,6 +141,8 @@ const ERASE_AFTER_ROOMS = [
@@ -139,6 +141,8 @@ const ERASE_AFTER_ROOMS = [
// A person's own summaries and timeline are keyed by their Live id (scope 'user').
// A person's own summaries and timeline are keyed by their Live id (scope 'user').
['ai_summaries', "DELETE FROM chat_ai_summaries WHERE scope = 'user' AND subject_id = ANY(@ids)"],
['ai_summaries', "DELETE FROM chat_ai_summaries WHERE scope = 'user' AND subject_id = ANY(@ids)"],
['timeline', "DELETE FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ANY(@ids)"],
['timeline', "DELETE FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ANY(@ids)"],
+ ['person_chat_memory', 'DELETE FROM person_chat_memory WHERE user_id = ANY(@ids)'],
+ ['person_chat_memory_settings', 'DELETE FROM person_chat_memory_settings WHERE user_id = ANY(@ids)'],
['tags', 'DELETE FROM user_tags WHERE user_id = ANY(@ids)'],
['tags', 'DELETE FROM user_tags WHERE user_id = ANY(@ids)'],
['moderator_roles', 'DELETE FROM channel_moderators WHERE user_id = ANY(@ids)'],
['moderator_roles', 'DELETE FROM channel_moderators WHERE user_id = ANY(@ids)'],
['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],
['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+const express = require('express');
+const router = express.Router();
+const ctx = require('../live-context');
+const consent = require('../net/consent');
+const memory = require('../ai/person-memory');
+const { optionalAuth, requireAuth } = require('../auth/auth');
+
+async function person(req, res) {
+ const username = String(req.params.username || '').trim();
+ if (!/^[a-zA-Z0-9_]{1,60}$/.test(username)) { res.status(404).json({ error: 'Person not found' }); return null; }
+ const user = await ctx.getUserByUsername(username);
+ if (!user) { res.status(404).json({ error: 'Person not found' }); return null; }
+ return user;
+}
+
+router.get('/:username/memory', optionalAuth, async (req, res) => {
+ try {
+ const user = await person(req, res); if (!user) return;
+ const level = String(req.query.level || 'day');
+ if (!['day', 'week', 'month'].includes(level)) return res.status(400).json({ error: 'Invalid level' });
+ let channelId = null;
+ if (req.query.channel) {
+ const channel = await ctx.getUserByUsername(String(req.query.channel));
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ channelId = Number(channel.id);
+ }
+ const self = Number(req.user?.id) === Number(user.id);
+ if (channelId == null && !self) return res.status(400).json({ error: 'Select a channel' });
+ const full = self || (channelId != null && Number(req.user?.id) === channelId);
+ res.set('Cache-Control', 'private, no-store');
+ // A withdrawn or unverifiable consent removes existing memory as well as blocking new work.
+ if (!user.subject_id || !(await consent.allowedSubjects([user.subject_id])).has(user.subject_id)) {
+ await memory.forget(user.id, false);
+ return res.json({ username: user.username, level, channel: req.query.channel || null, entries: [], opted_out: false, enabled: memory.ENABLED });
+ }
+ return res.json({ username: user.username, level, channel: req.query.channel || null,
+ entries: await memory.read(user.id, { channelId, level, privateView: full }),
+ opted_out: await memory.optedOut(user.id), enabled: memory.ENABLED });
+ } catch (err) { console.warn('[PersonMemory] read:', err.message); return res.status(500).json({ error: 'Memory unavailable' }); }
+});
+
+router.delete('/:username/memory', requireAuth, async (req, res) => {
+ try {
+ const user = await person(req, res); if (!user) return;
+ if (Number(req.user.id) !== Number(user.id)) return res.status(403).json({ error: 'Only this person can delete their memory' });
+ await memory.forget(user.id, true);
+ res.set('Cache-Control', 'no-store');
+ return res.json({ deleted: true, opted_out: true });
+ } catch (err) { return res.status(500).json({ error: 'Could not delete memory' }); }
+});
+
+router.put('/:username/memory/preferences', requireAuth, async (req, res) => {
+ try {
+ const user = await person(req, res); if (!user) return;
+ if (Number(req.user.id) !== Number(user.id)) return res.status(403).json({ error: 'Only this person can change memory preferences' });
+ if (typeof req.body?.opted_out !== 'boolean') return res.status(400).json({ error: 'opted_out must be a boolean' });
+ await memory.forget(user.id, req.body.opted_out);
+ res.set('Cache-Control', 'no-store');
+ return res.json({ opted_out: req.body.opted_out });
+ } catch (err) { return res.status(500).json({ error: 'Could not change memory preferences' }); }
+});
+
+module.exports = router;
@@ -88,6 +88,7 @@ async function start() {
@@ -88,6 +88,7 @@ async function start() {
// The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1; it
// The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1; it
// writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).
// writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).
chatAi.start();
chatAi.start();
+ require('./ai/person-memory').start();
// Workspace rooms' queues (server/rooms/workspace.js): the OpenVibe.Actor tasks they started, followed every 10 s.
// Workspace rooms' queues (server/rooms/workspace.js): the OpenVibe.Actor tasks they started, followed every 10 s.
require('./rooms/workspace').start();
require('./rooms/workspace').start();
// OpenVibe's own computers for parties (server/rooms/party-pool.js): release holds that went idle and seat the
// OpenVibe's own computers for parties (server/rooms/party-pool.js): release holds that went idle and seat the
@@ -36,6 +36,7 @@ t('export, then erase and confirm', async () => {
@@ -36,6 +36,7 @@ t('export, then erase and confirm', async () => {
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
'chat_stage_speakers',
'chat_stage_speakers',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'dm_link_preview_settings', 'emotes', 'inbox_actions',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'dm_link_preview_settings', 'emotes', 'inbox_actions',
+ 'person_chat_memory', 'person_chat_memory_settings',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions', 'chat_activity',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions', 'chat_activity',
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_canvases', 'vibe_canvas_versions', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_canvases', 'vibe_canvas_versions', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
exempt: {
exempt: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+
+const t = suite('person-memory');
+let h, db, memory, person, channel, outsider;
+const yesterday = () => new Date(Date.now() - 86400000).toISOString().replace('T', ' ').replace('Z', '');
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_PERSON_MEMORY_ENABLED: '1' } });
+ db = require('../server/db/database');
+ memory = require('../server/ai/person-memory');
+ person = h.addUser('memoryperson', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B1' });
+ channel = h.addUser('memorychannel', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2' });
+ outsider = h.addUser('memoryoutsider', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B3' });
+ h.consent.allowed.add(person.subject_id);
+ await h.ctx.sync();
+});
+
+t('new messages form one day, then week and month from the day', async () => {
+ for (const message of ['hello there', 'we are playing a game']) {
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, message, yesterday()]);
+ }
+ await memory.tick();
+ const day = await memory.read(person.id, { channelId: channel.id });
+ assert.strictEqual(day.length, 1);
+ assert.strictEqual(day[0].message_count, 2);
+ assert.strictEqual(day[0].highlights.length, 0);
+ assert.strictEqual((await memory.read(person.id, { channelId: channel.id, level: 'week' }))[0].message_count, 2);
+ assert.strictEqual((await memory.read(person.id, { channelId: channel.id, level: 'month' }))[0].message_count, 2);
+ assert.strictEqual((await memory.read(person.id))[0].message_count, 2);
+ await db.run("UPDATE person_chat_memory SET summary = 'Alice discussed a game.' WHERE user_id = ? AND channel_user_id = ? AND level = 'day'", [person.id, channel.id]);
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'one more', yesterday()]);
+ await memory.tick();
+ const incremented = (await memory.read(person.id, { channelId: channel.id }))[0];
+ assert.strictEqual(incremented.message_count, 3);
+ assert.strictEqual(incremented.summary, 'Alice discussed a game.', 'a capped model call does not discard earlier memory');
+});
+
+t('read exposes private fields only to the person or channel owner', async () => {
+ const publicRead = await h.http('GET', `/api/v1/people/${person.username}/memory?channel=${channel.username}`);
+ assert.strictEqual(publicRead.status, 200, publicRead.text);
+ assert.strictEqual(publicRead.body.entries[0].tone, undefined);
+ const ownRead = await h.http('GET', `/api/v1/people/${person.username}/memory?channel=${channel.username}`, { token: person.token });
+ assert.strictEqual(ownRead.status, 200, ownRead.text);
+ assert.ok(Object.hasOwn(ownRead.body.entries[0], 'tone'));
+ const noChannel = await h.http('GET', `/api/v1/people/${person.username}/memory`, { token: outsider.token });
+ assert.strictEqual(noChannel.status, 400);
+});
+
+t('reply partners require their own consent and disappear after opt out', async () => {
+ h.consent.allowed.add(channel.subject_id);
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`, [channel.id, channel.id, channel.username, channel.subject_id, 'welcome', yesterday()]);
+ const target = await db.get('SELECT MAX(id) AS id FROM chat_messages WHERE user_id = ?', [channel.id]);
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp, reply_to_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'thanks', yesterday(), target.id]);
+ await memory.tick();
+ const visible = await memory.read(person.id, { channelId: channel.id, privateView: true });
+ assert.ok(visible[0].people.includes(channel.username));
+ await memory.forget(channel.id, true);
+ const hidden = await memory.read(person.id, { channelId: channel.id, privateView: true });
+ assert.ok(!hidden[0].people.includes(channel.username));
+ const stored = await db.get("SELECT people FROM person_chat_memory WHERE user_id = ? AND channel_user_id = ? AND level = 'day'", [person.id, channel.id]);
+ assert.ok(!JSON.stringify(stored.people).includes(channel.username), 'opt out also erases stored references');
+});
+
+t('the Chat workflow records measured Exchange-backed run cost', async () => {
+ const config = require('../server/config');
+ const client = require('../server/ai/client');
+ config.ai.enabled = true;
+ client._setClient({ runs: { create: async (workflow, input) => {
+ assert.strictEqual(workflow, 'chat.person-memory');
+ assert.ok(['day', 'week', 'month'].includes(input.level));
+ return { run: { status: 'succeeded', usage: { cost_usd: 0.002 }, output: {
+ summary: 'A game and a win.', topics: ['game'], highlights: [], tone: 'brief', catchphrases: [],
+ } } };
+ } } });
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'we won', yesterday()]);
+ await memory.tick();
+ assert.strictEqual((await memory.read(person.id, { channelId: channel.id }))[0].summary, 'A game and a win.');
+ const beforeFailure = await memory.spentToday();
+ assert.ok(beforeFailure.usd >= 0.002);
+ client._setClient({ runs: { create: async () => ({ run: { status: 'failed', usage: { cost_usd: 0.003 } } }) } });
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'another game', yesterday()]);
+ await memory.tick();
+ assert.strictEqual((await memory.read(person.id, { channelId: channel.id }))[0].summary, 'A game and a win.');
+ assert.ok((await memory.spentToday()).usd >= beforeFailure.usd + 0.003, 'a billed failed run counts toward the cap');
+ client._reset();
+ config.ai.enabled = false;
+});
+
+t('the person can erase and opt out; the worker does not rebuild it', async () => {
+ const erased = await h.http('DELETE', `/api/v1/people/${person.username}/memory`, { token: person.token });
+ assert.strictEqual(erased.status, 200, erased.text);
+ assert.strictEqual(erased.body.opted_out, true);
+ await memory.tick();
+ assert.deepStrictEqual(await memory.read(person.id, { channelId: channel.id }), []);
+ const outsiderDelete = await h.http('DELETE', `/api/v1/people/${person.username}/memory`, { token: outsider.token });
+ assert.strictEqual(outsiderDelete.status, 403);
+ const resume = await h.http('PUT', `/api/v1/people/${person.username}/memory/preferences`, { token: person.token, body: { opted_out: false } });
+ assert.strictEqual(resume.status, 200, resume.text);
+ await memory.tick();
+ assert.deepStrictEqual(await memory.read(person.id, { channelId: channel.id }), [], 'old messages remain forgotten');
+});
+
+t('withdrawn consent erases a newly built memory', async () => {
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'new day', yesterday()]);
+ await memory.tick();
+ assert.strictEqual((await memory.read(person.id, { channelId: channel.id }))[0].message_count, 1);
+ h.consent.allowed.delete(person.subject_id);
+ require('../server/net/consent')._reset();
+ await db.run(`INSERT INTO chat_messages (user_id, channel_user_id, username, subject_id, message, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`, [person.id, channel.id, person.username, person.subject_id, 'withdrawn', yesterday()]);
+ await memory.tick();
+ assert.deepStrictEqual(await memory.read(person.id, { channelId: channel.id }), []);
+});
+
+t('close', async () => { memory.stop(); await h.close(); });
@@ -23,7 +23,8 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||
@@ -23,7 +23,8 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||
// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.
// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.
const TABLES = [
const TABLES = [
'vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import
'vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import
- 'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
+ 'chat_messages', 'person_chat_memory', 'person_chat_memory_settings', 'person_chat_memory_spend',
+ 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',