openvibers/openvibe.chat Commits

Chat: the Global room's AI insight, read and shown (#480)

OP OpenVibers committed 4 days ago · 562ac7e237 · 14 files, +475 −20

* Chat: the Global room's AI insight, read and shown

* Merge origin/main

* Chat: renumber the room insight migration to 0063

* Merge remote-tracking branch 'origin/main' into feat/room-insight-1110

Changes

ddiff --git a/migrations/0063_room_insight.sql b/migrations/0063_room_insight.sql
nnew file mode 100644
iindex 0000000..83033e2
---- /dev/null
++++ b/migrations/0063_room_insight.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- A room's AI insight (GET /api/v1/rooms/:id/insight): the same summary row Global already keeps, now
++-- with the window's people count and its topics (label + how many messages mentioned it) so one public
++-- read answers the shape every room will use. The timeline gets a kind and an optional source message,
++-- so a reader can tell a notable moment from a running topic. Both columns are nullable/defaulted:
++-- older rows keep working, the job fills them from its next window.
++ALTER TABLE chat_ai_summaries ADD COLUMN IF NOT EXISTS people bigint DEFAULT 0;
++ALTER TABLE chat_ai_summaries ADD COLUMN IF NOT EXISTS topics_json text COLLATE "C" DEFAULT '[]';
++ALTER TABLE chat_timeline_events ADD COLUMN IF NOT EXISTS kind text COLLATE "C" DEFAULT 'moment';
++ALTER TABLE chat_timeline_events ADD COLUMN IF NOT EXISTS message_id bigint;
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex b9c93ae..1713986 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -646,6 +646,16 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border-radius: 8px; background: transparent; color: var(--oc-text); font-weight: 600; text-align: left; }
.oc-pop-item:hover { background: var(--oc-panel-2); }
.oc-pop-item.oc-danger { color: var(--oc-red); }
++/* A room's AI insight (server/web/pages.js insightBlock): plain text, topics and a short timeline. Only
++ the parts a popover needs to stay readable on a phone; its look is the designer's. */
++.oc-insight-pop { width: min(380px, calc(100vw - 32px)); max-height: min(70vh, 560px); overflow-y: auto; }
++.oc-insight-pop > p { margin: 6px 8px; }
++.oc-insight-topics, .oc-insight-timeline { list-style: none; margin: 8px; padding: 0; font-size: .88rem; }
++.oc-insight-topics { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(92px, 100%), 1fr)); gap: 4px; }
++.oc-insight-topics li { padding: 3px 8px; border: 1px solid var(--oc-line); border-radius: 999px; }
++.oc-insight-timeline { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
++.oc-insight-timeline time { margin-right: 6px; color: var(--oc-muted); font-variant-numeric: tabular-nums; }
++.oc-insight-meta { font-size: .78rem; }
.oc-row-lock { display: inline-flex; color: var(--oc-muted); flex: none; }
.oc-row-lock .oc-i { width: .9em; height: .9em; }
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex cec6413..400d764 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -23,6 +23,7 @@ const config = require('../config');
const db = require('../db/database');
const aiClient = require('./client');
const extractive = require('./extractive');
++const consent = require('../net/consent');
// ── Tunables (Live's) ────────────────────────────────────────────────────────
const TICK_MS = config.ai.tickMs;
@@@ -96,7 +97,11 @@ function _stampAdditions(additions, fallbackTs, nowMs) {
let ts = fallbackTs;
const mins = Number(a && a.mins_ago);
if (Number.isFinite(mins) && mins >= 0 && mins <= 43200) ts = _sqlTime(now - mins * 60000);
-- out.push({ ts, label, detail: _clip(a.detail || a.description || '', 240) });
++ const messageId = a && a.message_id != null && Number.isFinite(Number(a.message_id)) ? Number(a.message_id) : null;
++ out.push({
++ ts, label, detail: _clip(a.detail || a.description || '', 240),
++ kind: _clip(a && a.kind ? a.kind : 'moment', 20), message_id: messageId,
++ });
}
return out;
}
@@@ -116,6 +121,42 @@ function _cleanTimeline(arr) {
.sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts));
}
++// The words people keep coming back to in the window: a topic with how many messages mentioned it.
++// Extractive so the room read has topics even when the model wrote only prose.
++const TOPIC_STOP = new Set(('the and for that this with you your are was were have has had not but all can '
++ + 'just from they them their there here what when who how why will would could should about into over than then '
++ + 'its it\'s i\'m dont don\'t im ive you\'re your\'re hes shes theyre we\'re thats whats lets get got like one '
++ + 'now out up off too very really some any more most much many also only even still back well good bad lol lmao '
++ + 'yeah yes nope ok okay hi hello hey thanks thank please bro dude guys guy chat room message messages people').split(' '));
++function _topics(rows, limit = 8) {
++ const counts = new Map();
++ for (const r of rows || []) {
++ const text = String((r && r.message) || '').toLowerCase()
++ .replace(/https?:\/\/\S+/g, ' ')
++ .replace(/:[a-z0-9_+-]+:/g, ' ')
++ .replace(/@[a-z0-9_.-]+/g, ' ');
++ for (const m of text.matchAll(/[a-z][a-z'’-]{2,}/g)) {
++ const word = m[0].replace(/['’-]+$/g, '');
++ if (word.length < 3 || TOPIC_STOP.has(word)) continue;
++ counts.set(word, (counts.get(word) || 0) + 1);
++ }
++ }
++ return [...counts.entries()]
++ .filter(([, count]) => count >= 2)
++ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
++ .slice(0, limit)
++ .map(([label, count]) => ({ label: _clip(label, 40), count }));
++}
++/** How many different people wrote in the window (one per signed-in author, else per name). */
++function _people(rows) {
++ const seen = new Set();
++ for (const r of rows || []) {
++ const key = r && (r.user_id ? `u:${r.user_id}` : r.username ? `n:${String(r.username).toLowerCase()}` : null);
++ if (key) seen.add(key);
++ }
++ return seen.size;
++}
++
// Chat is the only writer of the two summaries tables since the C-04 cutover: no authority gate.
// ── GLOBAL ───────────────────────────────────────────────────────────────────
@@@ -138,8 +179,16 @@ async function _refreshGlobal() {
const windowStart = _sqlTime(startMs);
const windowLabel = _windowLabel(now - startMs);
-- const rows = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
-- if (!rows.length) return false;
++ // The room's read includes only people who allowed AI use of their messages (network.consent@1,
++ // ai.train on kind chat). Anon and relay authors have no Network subject and are left out; when
++ // Network cannot answer, the check fails closed and this window is skipped.
++ const batch = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
++ if (!batch.length) return false;
++ const rows = await consent.filterRows(batch);
++ if (!rows.length) {
++ console.log('[ChatAI] global window has no consenting messages; nothing summarised');
++ return false;
++ }
const priorMemory = prior ? (prior.memory_json || '') : '';
let priorTl = [];
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
@@@ -165,8 +214,10 @@ async function _refreshGlobal() {
window_label: windowLabel,
window_start: windowStart,
window_end: nowIso,
++ people: _people(rows),
++ topics_json: JSON.stringify(_topics(rows)),
});
-- console.log(`[ChatAI] global refreshed (${newCount} new, window=${windowLabel}, ${rows.length} msgs)`);
++ console.log(`[ChatAI] global refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);
return true;
}
@@@ -438,7 +489,7 @@ async function getAnonInsight(anonId) {
}
module.exports = {
-- start, stop, _tick, _msgData,
++ start, stop, _tick, _msgData, _topics, _people,
getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight,
WORKFLOW_GLOBAL, WORKFLOW_PROFILE,
};
ddiff --git a/server/ai/extractive.js b/server/ai/extractive.js
iindex 396c38f..447d8fa 100644
---- a/server/ai/extractive.js
++++ b/server/ai/extractive.js
@@@ -33,7 +33,10 @@ function _timeline(rows, now) {
.map((r) => {
const label = _oneLine(r.message, 80);
if (!label || _PLACEHOLDER.test(label)) return null;
-- return { label, detail: _oneLine(r.username || r.channel_username || '', 120), mins_ago: _minsAgo(r.timestamp || r.created_at, now) };
++ return {
++ label, detail: _oneLine(r.username || r.channel_username || '', 120), mins_ago: _minsAgo(r.timestamp || r.created_at, now),
++ kind: r.message_type && r.message_type !== 'chat' ? 'event' : 'message', message_id: r.id == null ? null : Number(r.id),
++ };
})
.filter(Boolean);
}
ddiff --git a/server/app.js b/server/app.js
iindex a5d3bca..8639d8b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -177,6 +177,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// The chat plays queue of an agent stream's room (Agent Streams S6): what the desk may read of it and
// how it records that a request ran (the capabilities chat.play_queue.read and chat.play_queue.write).
app.use('/api/v1/rooms', require('./chat/play-queue-routes').service);
++ // A room's AI insight (plan 2026-11-10): public, one shape for Global and, in part 2, every room.
++ app.use('/api/v1/rooms', require('./chat/room-insight-routes'));
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/room-insight-routes.js b/server/chat/room-insight-routes.js
nnew file mode 100644
iindex 0000000..e4ac200
---- /dev/null
++++ b/server/chat/room-insight-routes.js
@@@ -0,0 +1,27 @@
++'use strict';
++/**
++ * A room's public AI insight (plan 2026-11-10, part 1): GET /api/v1/rooms/:id/insight.
++ *
++ * One shape for every room — Global first, then a server's channels and rooms — so a reader never needs
++ * to know which kind of room it opened. Global's stable id is `global`; a room not covered by the
++ * rollout answers 404 rather than an empty shape, so a client can tell "no such room" from "quiet".
++ */
++const express = require('express');
++const router = express.Router();
++const roomInsight = require('./room-insight');
++
++router.get('/:id/insight', async (req, res) => {
++ const id = String(req.params.id || '');
++ if (id !== 'global') return res.status(404).json({ error: 'Room insight not found' });
++ try {
++ const insight = await roomInsight.global();
++ // A minute of cache: the job rewrites at most every 30 minutes and the answer is the same for everyone.
++ res.set('Cache-Control', 'public, max-age=60');
++ return res.json(insight);
++ } catch (err) {
++ console.warn('[RoomInsight] global read failed:', err && err.message);
++ return res.status(500).json({ error: 'Failed to load room insight' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/chat/room-insight.js b/server/chat/room-insight.js
nnew file mode 100644
iindex 0000000..cb4d082
---- /dev/null
++++ b/server/chat/room-insight.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * One room's AI insight, as GET /api/v1/rooms/:id/insight answers it (plan 2026-11-10, part 1: Global).
++ *
++ * The writer is the existing chat-AI job (server/ai/chat-ai.js): the same schedule, the same window and
++ * budget rules, only when there were new messages, and only the messages of people who allowed AI use of
++ * them. This module is the reader: it shapes the stored summary and the append-only timeline into one
++ * answer for every room, so Live and Chat can read the same thing.
++ *
++ * { overview: { text, topics: [{ label, count }], people, messages, from, to, updated_at },
++ * timeline: [{ at, kind, title, text, message_id }],
++ * state: 'ready' | 'quiet' | 'off' }
++ *
++ * Quiet is said as quiet: when the last summary is old (or there is none), the overview text is empty and
++ * only the stored timeline remains. The timeline is newest first, which is what someone arriving wants.
++ */
++const db = require('../db/database');
++
++const QUIET_AFTER_MS = 6 * 60 * 60 * 1000; // the job refreshes Global at least every 30 min when there is news
++const TIMELINE_LIMIT = 40;
++
++function _time(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
++
++function _topics(raw) {
++ let list = [];
++ try { list = JSON.parse(raw || '[]'); } catch { list = []; }
++ if (!Array.isArray(list)) return [];
++ return list
++ .map((t) => ({ label: String((t && t.label) || '').trim().slice(0, 60), count: Math.max(0, Number(t && t.count) || 0) }))
++ .filter((t) => t.label)
++ .slice(0, 20);
++}
++
++function _timeline(events) {
++ return (events || []).map((e) => ({
++ at: e.ts || null,
++ kind: e.kind || 'moment',
++ title: String(e.label || '').slice(0, 140),
++ text: String(e.detail || ''),
++ message_id: e.message_id == null ? null : Number(e.message_id),
++ })).filter((e) => e.at && e.title);
++}
++
++function _shape(row, events) {
++ const fresh = !!(row && row.overview && row.window_end && Date.now() - _time(row.window_end) < QUIET_AFTER_MS);
++ const state = fresh ? 'ready' : 'quiet';
++ return {
++ overview: {
++ text: fresh ? String(row.overview) : '',
++ topics: fresh ? _topics(row.topics_json) : [],
++ people: fresh ? (Number(row.people) || 0) : 0,
++ messages: fresh ? (Number(row.window_message_count) || 0) : 0,
++ from: row ? (row.window_start || null) : null,
++ to: row ? (row.window_end || null) : null,
++ updated_at: row ? (row.updated_at || null) : null,
++ },
++ timeline: _timeline(events),
++ state,
++ };
++}
++
++/** The Global room's insight. Other rooms answer through room-insight in part 2. */
++async function global() {
++ const [row, events] = await Promise.all([
++ db.getChatAiSummary('global', 0, 'global'),
++ db.getChatTimelineEvents({ scope: 'global', subjectId: 0, limit: TIMELINE_LIMIT }),
++ ]);
++ return _shape(row, events);
++}
++
++module.exports = { global, _shape, _topics, QUIET_AFTER_MS };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 3268ce5..092d9bf 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1217,13 +1217,14 @@ async function upsertChatAiSummary(sfx) {
scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
message_count = 0, window_message_count = 0, last_message_id = 0,
window_label = '', window_start = null, window_end = null,
++ people = 0, topics_json = '[]',
} = sfx || {};
return await transaction(async () => {
const res = await run(
`INSERT INTO chat_ai_summaries
(scope, subject_id, "window", overview, memory_json, timeline_json, message_count,
-- window_message_count, last_message_id, window_label, window_start, window_end, updated_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())
++ window_message_count, last_message_id, window_label, window_start, window_end, people, topics_json, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())
ON CONFLICT(scope, subject_id, "window") DO UPDATE SET
overview = excluded.overview,
memory_json = excluded.memory_json,
@@@ -1234,9 +1235,11 @@ async function upsertChatAiSummary(sfx) {
window_label = excluded.window_label,
window_start = excluded.window_start,
window_end = excluded.window_end,
++ people = excluded.people,
++ topics_json = excluded.topics_json,
updated_at = ov_now()`,
[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
-- window_message_count, last_message_id, window_label, window_start, window_end]
++ window_message_count, last_message_id, window_label, window_start, window_end, people, topics_json]
);
return _writeResult(res, 'chat_ai_summaries', [await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?', [scope, subject_id || 0, window])]);
});
@@@ -1250,8 +1253,9 @@ async function addChatTimelineEvents(scope, subjectId, events) {
for (const e of events) {
if (!e || !e.label || !e.ts) continue;
try {
-- const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',
-- [scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400)]);
++ const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail, kind, message_id) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',
++ [scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400),
++ String(e.kind || 'moment').slice(0, 20), e.message_id == null ? null : Number(e.message_id)]);
if (res.changes) ids.push(Number(res.lastInsertRowid));
n++; // Live counts every event it tried, the duplicates included
} catch { /* */ }
@@@ -1285,7 +1289,7 @@ async function getChatTimelineEvents({ scope = 'global', subjectId = 0, before =
if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label ILIKE ? OR detail ILIKE ?)'); params.push(like, like); }
params.push(Math.min(60, Math.max(1, limit)));
try {
-- return await all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];
++ return await all(`SELECT id, ts, label, detail, kind, message_id FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];
} catch { return []; }
}
@@@ -1301,7 +1305,7 @@ async function countChatMessagesSince(afterId, userId = null) {
}
// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {
-- let sql = `SELECT cm.id, cm.user_id, cm.username, cm.message, cm.message_type, cm.timestamp,
++ let sql = `SELECT cm.id, cm.user_id, cm.subject_id, cm.username, cm.message, cm.message_type, cm.timestamp,
cm.stream_id, cm.channel_user_id, cm.is_global,
ch.username AS channel_username, ch.display_name AS channel_display
FROM chat_messages cm
ddiff --git a/server/net/consent.js b/server/net/consent.js
nnew file mode 100644
iindex 0000000..40b8cc1
---- /dev/null
++++ b/server/net/consent.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * OpenVibe.Network's consent check, as Chat sees it for the room insight (plan L24: data and AI use by
++ * consent, openvibe-contracts network.consent@1).
++ *
++ * allowedSubjects(subjects) POST /internal/consent/check { purpose: 'ai.train', kind: 'chat' }
++ * with Chat's own service token (client `chat`, capability
++ * network.consent.read). A person who has not allowed AI use of their
++ * chat messages is left out of what the summariser reads.
++ *
++ * Fail-closed, exactly as the consent contract says: no token, an answer that is not ok, a timeout or a
++ * Network that is down all mean "nobody allowed". An answer is cached 60 s per person; a failed call is
++ * not retried for 30 s, so an outage costs one call, not one per tick. Nothing here logs a subject.
++ */
++const config = require('../config');
++const auth = require('./service-auth');
++
++const CACHE_TTL_MS = 60_000;
++const FAIL_TTL_MS = 30_000;
++const MAX_BATCH = 500;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++const cache = new Map(); // subject → { at, allowed }
++let blockedUntil = 0; // a failed call: nobody is allowed until this passes
++let lastLog = '';
++
++function _log(message) {
++ if (message === lastLog) return;
++ lastLog = message;
++ console.warn(`[Consent] ${message}`);
++}
++
++async function _ask(batch) {
++ const headers = await auth.headers('openvibe.network', 'network.consent.read');
++ const res = await fetch(`${config.networkInternalUrl}/internal/consent/check`, {
++ method: 'POST',
++ headers: { ...headers, Accept: 'application/json', 'Content-Type': 'application/json' },
++ body: JSON.stringify({ purpose: 'ai.train', kind: 'chat', subjects: batch }),
++ signal: AbortSignal.timeout(5000),
++ });
++ if (!res.ok) throw new Error(`answered ${res.status}`);
++ const data = await res.json().catch(() => null);
++ return (data && data.allowed) || {};
++}
++
++/**
++ * Which of these subjects allowed AI use of their chat messages. Always a Set of subject ids; a subject
++ * that was not asked (no Network account, a relay or anon author) is simply not in it.
++ */
++async function allowedSubjects(subjects) {
++ const wanted = [...new Set((subjects || []).map(String).filter((s) => SUBJECT_RE.test(s)))];
++ const now = Date.now();
++ const allowed = new Set();
++ const missing = [];
++ for (const s of wanted) {
++ const hit = cache.get(s);
++ if (hit && now - hit.at < CACHE_TTL_MS) { if (hit.allowed) allowed.add(s); } else missing.push(s);
++ }
++ if (!missing.length || blockedUntil > now) return allowed;
++ for (let i = 0; i < missing.length; i += MAX_BATCH) {
++ const batch = missing.slice(i, i + MAX_BATCH);
++ try {
++ const answers = await _ask(batch);
++ const at = Date.now();
++ for (const s of batch) {
++ const ok = answers[s] === true;
++ cache.set(s, { at, allowed: ok });
++ if (ok) allowed.add(s);
++ else allowed.delete(s);
++ }
++ lastLog = '';
++ } catch (err) {
++ blockedUntil = Date.now() + FAIL_TTL_MS;
++ _log(`check failed, leaving everyone out: ${(err && err.message) || err}`);
++ return allowed; // fail closed; the people already cached as allowed stay
++ }
++ }
++ return allowed;
++}
++
++/** The rows whose author allowed AI use. Rows without a Network subject (anon, relay, deleted) are left out. */
++async function filterRows(rows) {
++ const list = Array.isArray(rows) ? rows : [];
++ const allowed = await allowedSubjects(list.map((r) => r && r.subject_id).filter(Boolean));
++ return list.filter((r) => r && r.subject_id && allowed.has(String(r.subject_id)));
++}
++
++module.exports = { allowedSubjects, filterRows, _reset() { cache.clear(); blockedUntil = 0; lastLog = ''; } };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 085b770..bd5c3c1 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -276,6 +276,10 @@ function createWebRoutes({ config }) {
: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const signedIn = actor.kind === 'user';
++ // The room's AI insight (server/chat/room-insight.js): a local read of the stored summary, so the page
++ // never waits on OpenVibe.AI. Public like the room: signed out too.
++ const insight = await require('../chat/room-insight').global().catch(() => null);
++ const insightHtml = insight ? insightBlock(insight) : '';
// Around the room: the real activity (server/web/home.js, read once a minute). Someone signed in gets the app: the
// room first at the screen's height, Ask Vibe in one line, compact lists, and no introduction or questions (those,
// the hero and the full footer, are for people who are new here, and for search).
@@@ -318,7 +322,7 @@ function createWebRoutes({ config }) {
rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: shell.railServers(mineServers) }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
sideLabel: 'Home',
-- main: `<header class="oc-chan-head">${shell.toList}<h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span></header>
++ main: `<header class="oc-chan-head">${shell.toList}<h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span>${insightHtml}</header>
${error}
${feedList}
${composer}`,
@@@ -341,6 +345,7 @@ ${parts.vibe}
<section class="oc-room" aria-labelledby="oc-h">
<h2 id="oc-h">Global chat</h2>
<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>
++${insightHtml}
${error}
${feedList}
${composer}
@@@ -367,6 +372,25 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
// ── Direct messages ──
const dmApi = require('../chat/dm-routes');
const { ICONS: IC } = require('./icons');
++ /**
++ * The Global room's AI insight, plain markup behind the head's AI button (plan 2026-11-10, part 1).
++ * Server-rendered, so it reads without JavaScript; no JavaScript is needed to open it either (<details>).
++ * The look is Fable's: this is only text, topics and the recent timeline in kit classes.
++ */
++ const insightBlock = (insight) => {
++ const o = insight.overview || {};
++ const topics = (o.topics || []).slice(0, 8);
++ const events = (insight.timeline || []).slice(0, 6);
++ const ready = insight.state === 'ready' && !!o.text;
++ const when = o.updated_at ? ui.when(o.updated_at) : null;
++ const meta = [o.people ? `${o.people} ${o.people === 1 ? 'person' : 'people'}` : '', o.messages ? `${o.messages} messages` : '', when && when.text ? `updated ${when.text}` : ''].filter(Boolean);
++ return `<details class="oc-more oc-insight"><summary class="oc-icon-btn" aria-label="AI overview of this room" title="AI overview">${IC.spark}</summary><div class="oc-pop oc-insight-pop">`
++ + (ready ? `<p>${esc(o.text)}</p>` : '<p class="oc-muted">Quiet right now: there is nothing new to summarise.</p>')
++ + (topics.length ? `<ul class="oc-insight-topics">${topics.map((t) => `<li>${esc(t.label)} <span class="oc-muted">${Number(t.count) || 0}</span></li>`).join('')}</ul>` : '')
++ + (events.length ? `<ol class="oc-insight-timeline">${events.map((e) => `<li><time>${esc(ui.when(e.at).text || '')}</time> <strong>${esc(e.title)}</strong>${e.text ? ` <span class="oc-muted">${esc(e.text)}</span>` : ''}</li>`).join('')}</ol>` : '')
++ + (meta.length ? `<p class="oc-muted oc-insight-meta">${esc(meta.join(' · '))}</p>` : '')
++ + '</div></details>';
++ };
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null, avatarUrl = '' }) => {
const t = ui.when(at);
ddiff --git a/test/chat-ai.test.js b/test/chat-ai.test.js
iindex eaea949..5a8a5bb 100644
---- a/test/chat-ai.test.js
++++ b/test/chat-ai.test.js
@@@ -13,12 +13,14 @@ const t = suite('chat-ai');
let h, db, ctx, chatAi, aiClient, streamer, alice, bob, channelId, streamId;
async function msg(o = {}) {
++ const subject = o.subject_id !== undefined ? o.subject_id
++ : (o.user_id ? ((h.live.users.get(o.user_id) || {}).subject_id || null) : null);
return (await db.run(
-- `INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
++ `INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp, subject_id)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[o.stream_id === undefined ? streamId : o.stream_id, o.user_id === undefined ? null : o.user_id,
o.username || null, o.message || 'a message', o.message_type || 'chat', o.is_global ? 1 : 0,
-- o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]
++ o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow(), subject]
)).lastInsertRowid;
}
@@@ -28,9 +30,12 @@ t('boot', async () => {
ctx = require('../server/live-context');
chatAi = require('../server/ai/chat-ai');
aiClient = require('../server/ai/client');
-- streamer = h.addUser('streamer', { role: 'streamer' });
-- alice = h.addUser('alice');
-- bob = h.addUser('bob');
++ streamer = h.addUser('streamer', { role: 'streamer', subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' });
++ alice = h.addUser('alice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A2' });
++ bob = h.addUser('bob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' });
++ h.consent.allowed.add(streamer.subject_id);
++ h.consent.allowed.add(alice.subject_id);
++ h.consent.allowed.add(bob.subject_id);
channelId = h.addChannel(streamer.id);
streamId = h.addStream(streamer.id, channelId);
await h.ctx.sync();
@@@ -170,4 +175,92 @@ t('the six routes answer with Live\'s shapes, publicly', async () => {
assert.strictEqual(r.status, 404);
});
++t('the summariser reads only people who allowed AI use, and stores people and topics', async () => {
++ const calls = [];
++ aiClient._setClient({
++ runs: {
++ create: async (workflow, input) => {
++ calls.push({ workflow, input });
++ return { run: { status: 'succeeded', output: {
++ recent_overview: 'consenting talk', memory: 'memory line',
++ timeline: [{ label: 'a moment', detail: 'detail', mins_ago: 1 }],
++ } } };
++ },
++ },
++ });
++ const carol = h.addUser('carol', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4' }); // no consent
++ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);
++ await msg({ user_id: carol.id, username: 'carol', message: 'carol secret line' });
++ await msg({ user_id: alice.id, username: 'alice', message: 'zelda talk is good' });
++ await msg({ user_id: bob.id, username: 'bob', message: 'zelda again' });
++
++ await chatAi._tick();
++
++ const global = calls.find((c) => c.workflow === chatAi.WORKFLOW_GLOBAL);
++ assert.ok(global, 'the global workflow was called');
++ const authors = global.input.messages.map((m) => m.author);
++ assert.ok(!authors.includes('carol'), 'carol is left out');
++ assert.ok(authors.includes('alice') && authors.includes('bob'), JSON.stringify(authors));
++ const row = await db.getChatAiSummary('global', 0, 'global');
++ assert.strictEqual(row.overview, 'consenting talk');
++ assert.strictEqual(Number(row.people), 2);
++ const topics = JSON.parse(row.topics_json);
++ assert.ok(topics.some((x) => x.label === 'zelda' && x.count >= 2), JSON.stringify(topics));
++});
++
++t('a consent check that fails leaves everyone out (fail closed)', async () => {
++ const consent = require('../server/net/consent');
++ consent._reset();
++ h.consent.down = true;
++ const before = (await db.getChatAiSummary('global', 0, 'global')).message_count;
++ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);
++ await msg({ user_id: alice.id, username: 'alice', message: 'not read while Network is down' });
++ await chatAi._tick();
++ assert.strictEqual((await db.getChatAiSummary('global', 0, 'global')).message_count, before);
++ h.consent.down = false;
++ consent._reset();
++});
++
++t('GET /api/v1/rooms/global/insight answers the room shape, publicly', async () => {
++ await db.upsertChatAiSummary({
++ scope: 'global', subject_id: 0, window: 'global', overview: 'arrivals see this', memory_json: 'm',
++ timeline_json: JSON.stringify([{ ts: sqliteNow(), label: 'a moment', detail: 'detail' }]),
++ message_count: 12, window_message_count: 4, window_label: 'past hour',
++ window_start: sqliteNow(-3600e3), window_end: sqliteNow(), people: 3,
++ topics_json: JSON.stringify([{ label: 'zelda', count: 2 }]),
++ });
++ await db.addChatTimelineEvents('global', 0, [{ ts: sqliteNow(), label: 'the moment', detail: 'what happened', kind: 'moment' }]);
++
++ let r = await h.http('GET', '/api/v1/rooms/global/insight'); // no token: public
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'ready');
++ assert.strictEqual(r.body.overview.text, 'arrivals see this');
++ assert.deepStrictEqual(r.body.overview.topics, [{ label: 'zelda', count: 2 }]);
++ assert.strictEqual(r.body.overview.people, 3);
++ assert.strictEqual(r.body.overview.messages, 4);
++ assert.ok(r.body.overview.from && r.body.overview.to && r.body.overview.updated_at);
++ assert.ok(Array.isArray(r.body.timeline) && r.body.timeline.length >= 1);
++ const ev = r.body.timeline.find((e) => e.title === 'the moment');
++ assert.ok(ev, JSON.stringify(r.body.timeline.slice(0, 3)));
++ assert.deepStrictEqual(Object.keys(ev).sort(), ['at', 'kind', 'message_id', 'text', 'title']);
++ assert.strictEqual(ev.text, 'what happened');
++
++ r = await h.http('GET', '/api/v1/rooms/nope/insight');
++ assert.strictEqual(r.status, 404);
++
++ r = await h.http('GET', '/');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('arrivals see this'), 'the Global page shows the overview');
++ assert.ok(r.text.includes('AI overview of this room'), 'behind the head button');
++});
++
++t('an old summary is said as quiet, never as today\'s news', async () => {
++ await db.run("UPDATE chat_ai_summaries SET window_end = ?, updated_at = ? WHERE scope = 'global'", [sqliteNow(-48 * 3600e3), sqliteNow(-48 * 3600e3)]);
++ const r = await h.http('GET', '/api/v1/rooms/global/insight');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'quiet');
++ assert.strictEqual(r.body.overview.text, '');
++ assert.deepStrictEqual(r.body.overview.topics, []);
++});
++
t.run(async () => { try { chatAi.stop(); } catch { /* */ } });
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 07c2ba6..aa011b1 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -99,6 +99,10 @@ async function boot({ env = {} } = {}) {
// Presence for the Network endpoint Chat reads (network.presence.read). Tests set rows here; the
// visibility rule itself is Network's and is tested there, so the stub answers the subjects asked for.
const netPresence = { rows: new Map(), calls: [] };
++ // The consent check (network.consent@1, capability network.consent.read): Chat's room insight asks
++ // which authors allowed ai.train on kind chat. Tests put subjects in h.consent.allowed; h.consent.down
++ // makes the call fail, which must leave everyone out (fail closed).
++ const netConsent = { allowed: new Set(), calls: [], down: false };
const network = http.createServer(async (req, res) => {
const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });
res.setHeader('Content-Type', 'application/json');
@@@ -113,6 +117,18 @@ async function boot({ env = {} } = {}) {
netPresence.calls.push({ for: forSubject, subjects });
return res.end(JSON.stringify({ presence: subjects.map((s) => netPresence.rows.get(s)).filter(Boolean) }));
}
++ if (req.url === '/internal/consent/check') {
++ const auth = String(req.headers.authorization || '');
++ const v = auth.startsWith('Bearer ') ? serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.network' }) : { ok: false, code: 'token.missing' };
++ if (!v.ok) { res.statusCode = 401; return res.end(JSON.stringify({ error: v.code })); }
++ if (!(v.claims.cap || []).includes('network.consent.read')) { res.statusCode = 403; return res.end(JSON.stringify({ error: 'capability.denied' })); }
++ if (netConsent.down) { req.socket.destroy(); return; }
++ let body = {}; try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
++ netConsent.calls.push(body);
++ const allowed = {};
++ for (const s of Array.isArray(body.subjects) ? body.subjects : []) allowed[s] = netConsent.allowed.has(s);
++ return res.end(JSON.stringify({ purpose: body.purpose, kind: body.kind, allowed, checked_at: new Date().toISOString() }));
++ }
const mm = /^\/internal\/modules\/([^/?]+)\/([^/?]+)$/.exec(req.url);
if (mm) return modulesRoute(req, res, raw, decodeURIComponent(mm[1]), decodeURIComponent(mm[2]));
if (req.url === '/oauth/token') {
@@@ -313,6 +329,7 @@ async function boot({ env = {} } = {}) {
const h = {
tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS, netModules, netPresence,
++ consent: netConsent,
userSeq: 0,
/** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */
addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {
ddiff --git a/test/room-insight.test.js b/test/room-insight.test.js
nnew file mode 100644
iindex 0000000..9243bb9
---- /dev/null
++++ b/test/room-insight.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * A room's AI insight reader (plan 2026-11-10, part 1): the one shape GET /api/v1/rooms/:id/insight
++ * answers, quiet said as quiet, and the route module mounted for Global. The job's consent filtering
++ * and the public route are exercised end to end in chat-ai.test.js.
++ */
++const assert = require('assert');
++const { suite, sqliteNow } = require('./helpers');
++const roomInsight = require('../server/chat/room-insight');
++const routes = require('../server/chat/room-insight-routes');
++
++const t = suite('room-insight');
++
++t('a fresh summary is ready and keeps its topics, people and timeline', async () => {
++ const now = sqliteNow();
++ const from = sqliteNow(-3600e3);
++ const shaped = roomInsight._shape({
++ overview: 'arrivals see this', window_start: from, window_end: now, updated_at: now,
++ people: 3, window_message_count: 4, topics_json: JSON.stringify([{ label: 'zelda', count: 2 }, { label: '', count: 9 }]),
++ }, [{ ts: now, label: 'a moment', detail: 'what happened', kind: 'event', message_id: 9 }]);
++ assert.strictEqual(shaped.state, 'ready');
++ assert.strictEqual(shaped.overview.text, 'arrivals see this');
++ assert.deepStrictEqual(shaped.overview.topics, [{ label: 'zelda', count: 2 }]);
++ assert.strictEqual(shaped.overview.people, 3);
++ assert.strictEqual(shaped.overview.messages, 4);
++ assert.deepStrictEqual(shaped.timeline, [{ at: now, kind: 'event', title: 'a moment', text: 'what happened', message_id: 9 }]);
++ assert.strictEqual(shaped.overview.from, from);
++});
++
++t('an old summary is quiet: no text, no topics, the timeline stays', async () => {
++ const old = sqliteNow(-48 * 3600e3);
++ const shaped = roomInsight._shape({
++ overview: 'two days ago everything happened', window_start: old, window_end: old, updated_at: old,
++ people: 7, window_message_count: 30, topics_json: JSON.stringify([{ label: 'zelda', count: 4 }]),
++ }, [{ ts: old, label: 'a moment', detail: 'old news' }]);
++ assert.strictEqual(shaped.state, 'quiet');
++ assert.strictEqual(shaped.overview.text, '');
++ assert.deepStrictEqual(shaped.overview.topics, []);
++ assert.strictEqual(shaped.overview.people, 0);
++ assert.strictEqual(shaped.overview.messages, 0);
++ assert.strictEqual(shaped.timeline.length, 1);
++ assert.strictEqual(shaped.timeline[0].kind, 'moment');
++});
++
++t('no row at all is quiet, and the route module is a router', async () => {
++ const shaped = roomInsight._shape(null, []);
++ assert.strictEqual(shaped.state, 'quiet');
++ assert.strictEqual(shaped.overview.text, '');
++ assert.strictEqual(shaped.overview.updated_at, null);
++ assert.deepStrictEqual(shaped.timeline, []);
++ assert.strictEqual(typeof routes, 'function');
++});
++
++t.run();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4486002..73f1539 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -121,6 +121,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/presence.js': 'Network internal presence (configured internal URL), fixed /internal/presence path, the subject list in the query, a service token, 1.5 s, held 30 s',
++ 'server/net/consent.js': 'Network internal consent check (configured internal URL), fixed /internal/consent/check path, up to 500 subject ids in the body (never a URL), a service token, 5 s, fail-closed',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',