openvibers/openvibe.chat Commits

Compact personal chat memory by day week and month (#514)

OP OpenVibers committed 2 days ago · 8e4479c206 · 12 files, +465 −1

* Compact personal chat memory by day week and month

* Include person chat memory in account export and erasure

* Cover personal chat memory in account erasure test

* Erase person memory when chat AI consent is withdrawn

* Retain compacted memory when the cost gate skips a model call

* Run person memory under Chat workflow with measured cost

* Compact closed memory windows after final batch

* Meter billed memory runs even when generation fails

* Track consented reply partners in private memory

Changes

ddiff --git a/README.md b/README.md
iindex 6ff2d19..a88b0bb 100644
---- a/README.md
++++ b/README.md
@@@ -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/`):
`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 network
ddiff --git a/migrations/0074_person_chat_memory.sql b/migrations/0074_person_chat_memory.sql
nnew file mode 100644
iindex 0000000..e0838cf
---- /dev/null
++++ b/migrations/0074_person_chat_memory.sql
@@@ -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
++);
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex 4339763..70a037a 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -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;
}
ddiff --git a/server/ai/client.js b/server/ai/client.js
iindex 033d45e..0a826bc 100644
---- a/server/ai/client.js
++++ b/server/ai/client.js
@@@ -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) {
ddiff --git a/server/ai/person-memory.js b/server/ai/person-memory.js
nnew file mode 100644
iindex 0000000..9759da8
---- /dev/null
++++ b/server/ai/person-memory.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 7931147..dd4cab2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -181,6 +181,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
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.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
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 880b177..894a65f 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -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)'],
ddiff --git a/server/chat/person-memory-routes.js b/server/chat/person-memory-routes.js
nnew file mode 100644
iindex 0000000..925dc07
---- /dev/null
++++ b/server/chat/person-memory-routes.js
@@@ -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;
ddiff --git a/server/index.js b/server/index.js
iindex cb4ac25..82649a1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex dc4b638..efab102 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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: {
ddiff --git a/test/person-memory.test.js b/test/person-memory.test.js
nnew file mode 100644
iindex 0000000..9883b4b
---- /dev/null
++++ b/test/person-memory.test.js
@@@ -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(); });
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex da0e649..1fade4f 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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',