openvibers/openvibe.chat Commits

Chat: a room's insight, for rooms, server channels and Live channels (#484)

OP OpenVibers committed 3 days ago · 1138bd3fa6 · 12 files, +507 −28

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

* Merge origin/main

* Chat: a room's insight, for rooms, server channels and Live channels

* Merge origin/main

* Merge origin/main

* Chat: renumber the room insight settings migration to 0065

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

* Chat: renumber the room insight settings migration to 0066

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

* Renumber room insight migration to 0068

* Merge origin/main into feat/room-insight-rooms-1110

Changes

ddiff --git a/migrations/0068_room_insight_rooms.sql b/migrations/0068_room_insight_rooms.sql
nnew file mode 100644
iindex 0000000..8c4384a
---- /dev/null
++++ b/migrations/0068_room_insight_rooms.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- A room's AI insight setting (plan 2026-11-10, part 2; GET /api/v1/rooms/:id/insight). NULL keeps the
++-- default: a public room is summarised, a private room stays off until its owner turns it on. A DM and a
++-- chat with Vibe are never summarised. Only the room's owner (or chat staff) changes it, through the room
++-- settings page or PATCH /api/chat/rooms/:slug.
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS ai_insight integer;
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex 400d764..d7cdb96 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -38,6 +38,16 @@ const USER_PASS_EVERY_MS = 3 * 60 * 1000; // throttle the (heavier) user d
const USER_STALE_MS = 24 * 60 * 60 * 1000; // refresh a lagging user at least daily
const USER_DISCOVERY_LOOKBACK_DAYS = 14; // bound the discovery GROUP BY
++// Rooms and Live channels (plan 2026-11-10, part 2): the same one-room summary, one or two rooms per
++// pass, and only when there were new messages — the summary is a room's read, not a site-wide one.
++const ROOM_MSG_THRESHOLD = 20; // a room refreshes once this many new messages pile up
++const ROOM_MAX_PER_TICK = 1; // cap LLM calls per pass (rooms can be many)
++const ROOM_PASS_EVERY_MS = 3 * 60 * 1000; // same cadence as the per-chatter scan
++const ROOM_STALE_MS = 24 * 60 * 60 * 1000; // ...and at least daily for a quiet room
++const ROOM_BATCH = 120; // newest messages a room summary reads
++const CHANNEL_MSG_THRESHOLD = 30; // Live channel chats: same rules, a slightly higher bar
++const CHANNEL_MAX_PER_TICK = 1;
++
const WINDOW_TARGET_MESSAGES = 300; // adaptive-window sizing target
const MAX_BATCH_MESSAGES = 300; // token cap per call
const WINDOW_MIN_MS = 30 * 60 * 1000; // overview window is never shorter than 30 min
@@@ -57,6 +67,7 @@ let _running = false;
let _timer = null;
let _tickInFlight = false;
let _lastUserPass = 0;
++let _lastRoomPass = 0;
// ── Small utils ──────────────────────────────────────────────────────────────
// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (ov_now()). Match that format.
@@@ -221,6 +232,128 @@ async function _refreshGlobal() {
return true;
}
++// ── ROOMS AND LIVE CHANNELS (plan 2026-11-10, part 2) ───────────────────────
++// One room's rolling picture, from the room's own newest messages. The same engine, prompt and budget
++// rules as Global, capped to a room or two a pass. Only people who allowed AI use of their messages are
++// read (network.consent@1), exactly as Global.
++function _newSince(rows, prior) {
++ const hw = prior ? (Number(prior.last_message_id) || 0) : 0;
++ return (rows || []).reduce((n, r) => n + (Number(r.id) > hw ? 1 : 0), 0);
++}
++function _windowOf(rows, now) {
++ const first = rows[0] && (rows[0].created_at || rows[0].timestamp);
++ const ms = Math.max(60_000, now - _parseSqlTime(first || now));
++ return { label: _windowLabel(ms), start: _sqlTime(now - ms) };
++}
++
++async function _refreshRoom(room) {
++ const prior = await db.getChatAiSummary('room', room.id, 'rolling');
++ const raw = await db.getRoomMessagesForAi({ roomId: room.id, limit: ROOM_BATCH });
++ if (!raw.length) return false;
++ const rows = await consent.filterRows(raw);
++ if (!rows.length) return false;
++ const now = Date.now();
++ const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
++ const priorMemory = prior ? (prior.memory_json || '') : '';
++ let priorTl = [];
++ try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
++
++ let parsed = await aiClient.structured(WORKFLOW_GLOBAL, {
++ window_label: windowLabel, prior_memory: _clip(priorMemory, MEMORY_MAX_CHARS),
++ recent_labels: priorTl.slice(-8).map(t => _clip(t.label, 200)).filter(Boolean),
++ messages: _msgData(rows, { now }),
++ });
++ if (!parsed) parsed = extractive.globalFrom(rows, { windowLabel, priorMemory, now });
++ if (!parsed) return false;
++
++ const nowIso = _sqlTime(now);
++ const newCount = _newSince(rows, prior);
++ await db.addChatTimelineEvents('room', room.id, _stampAdditions(parsed.timeline, nowIso, now));
++ await db.upsertChatAiSummary({
++ scope: 'room', subject_id: room.id, window: 'rolling',
++ overview: _clip(parsed.recent_overview || '', 2000),
++ memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),
++ timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
++ message_count: (prior ? (prior.message_count || 0) : 0) + newCount,
++ window_message_count: rows.length,
++ last_message_id: Number(room.max_id) || (rows[rows.length - 1] || {}).id || 0,
++ window_label: windowLabel,
++ window_start: windowStart,
++ window_end: nowIso,
++ people: _people(rows),
++ topics_json: JSON.stringify(_topics(rows)),
++ });
++ console.log(`[ChatAI] room ${room.slug} refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);
++ return true;
++}
++
++async function _roomPass() {
++ const staleCutoff = _sqlTime(Date.now() - ROOM_STALE_MS);
++ const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
++ let candidates = [];
++ try { candidates = await db.getRoomsNeedingInsight({ threshold: ROOM_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: ROOM_MAX_PER_TICK }); }
++ catch (e) { console.warn('[ChatAI] room discovery failed:', e.message); return; }
++ for (const room of candidates) {
++ try { await _refreshRoom(room); }
++ catch (e) { console.warn(`[ChatAI] room ${room.slug} refresh failed:`, e.message); }
++ }
++}
++
++async function _refreshChannel(channel) {
++ const subjectId = Number(channel.channel_id);
++ if (!subjectId) return false;
++ const prior = await db.getChatAiSummary('channel', subjectId, 'rolling');
++ const raw = await db.getChannelMessagesForAi({ channelUserId: subjectId, limit: ROOM_BATCH });
++ if (!raw.length) return false;
++ const rows = await consent.filterRows(raw);
++ if (!rows.length) return false;
++ const now = Date.now();
++ const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
++ const priorMemory = prior ? (prior.memory_json || '') : '';
++ let priorTl = [];
++ try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
++
++ let parsed = await aiClient.structured(WORKFLOW_GLOBAL, {
++ window_label: windowLabel, prior_memory: _clip(priorMemory, MEMORY_MAX_CHARS),
++ recent_labels: priorTl.slice(-8).map(t => _clip(t.label, 200)).filter(Boolean),
++ messages: _msgData(rows, { now }),
++ });
++ if (!parsed) parsed = extractive.globalFrom(rows, { windowLabel, priorMemory, now });
++ if (!parsed) return false;
++
++ const nowIso = _sqlTime(now);
++ const newCount = _newSince(rows, prior);
++ await db.addChatTimelineEvents('channel', subjectId, _stampAdditions(parsed.timeline, nowIso, now));
++ await db.upsertChatAiSummary({
++ scope: 'channel', subject_id: subjectId, window: 'rolling',
++ overview: _clip(parsed.recent_overview || '', 2000),
++ memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),
++ timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
++ message_count: (prior ? (prior.message_count || 0) : 0) + newCount,
++ window_message_count: rows.length,
++ last_message_id: Number(channel.max_id) || (rows[rows.length - 1] || {}).id || 0,
++ window_label: windowLabel,
++ window_start: windowStart,
++ window_end: nowIso,
++ people: _people(rows),
++ topics_json: JSON.stringify(_topics(rows)),
++ });
++ console.log(`[ChatAI] channel ${subjectId} refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);
++ return true;
++}
++
++async function _channelPass() {
++ const staleCutoff = _sqlTime(Date.now() - ROOM_STALE_MS);
++ const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
++ let candidates = [];
++ try { candidates = await db.getChannelsNeedingInsight({ threshold: CHANNEL_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: CHANNEL_MAX_PER_TICK }); }
++ catch (e) { console.warn('[ChatAI] channel discovery failed:', e.message); return; }
++ for (const channel of candidates) {
++ try { await _refreshChannel(channel); }
++ catch (e) { console.warn(`[ChatAI] channel ${channel.channel_id} refresh failed:`, e.message); }
++ }
++}
++
// ── PER-USER ─────────────────────────────────────────────────────────────────
async function _refreshUser(uid, maxId) {
const prior = await db.getChatAiSummary('user', uid, 'rolling');
@@@ -400,6 +533,13 @@ async function _tick() {
try { await _refreshGlobal(); }
catch (e) { console.warn('[ChatAI] global refresh failed:', e.message); }
++ // Rooms and Live channels (part 2): their own cadence, one or two a pass, only when new messages.
++ if (Date.now() - _lastRoomPass >= ROOM_PASS_EVERY_MS) {
++ _lastRoomPass = Date.now();
++ await _roomPass();
++ await _channelPass();
++ }
++
if (Date.now() - _lastUserPass >= USER_PASS_EVERY_MS) {
_lastUserPass = Date.now();
await _userPass();
@@@ -489,7 +629,7 @@ async function getAnonInsight(anonId) {
}
module.exports = {
-- start, stop, _tick, _msgData, _topics, _people,
++ start, stop, _tick, _msgData, _topics, _people, _refreshRoom, _refreshChannel, WORKFLOW_GLOBAL,
getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight,
-- WORKFLOW_GLOBAL, WORKFLOW_PROFILE,
++ WORKFLOW_PROFILE,
};
ddiff --git a/server/chat/room-insight-routes.js b/server/chat/room-insight-routes.js
iindex e4ac200..51bfbe9 100644
---- a/server/chat/room-insight-routes.js
++++ b/server/chat/room-insight-routes.js
@@@ -1,27 +1,51 @@
'use strict';
/**
-- * A room's public AI insight (plan 2026-11-10, part 1): GET /api/v1/rooms/:id/insight.
++ * A room's AI insight (plan 2026-11-10, parts 1 and 2): 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".
++ * One shape for every room, so a reader never needs to know which kind it opened:
++ * global the site's Global room (public)
++ * <slug> a room or a server's text channel (public when the room is; members only when private)
++ * channel:<user id> a Live channel's chat, keyed by the broadcaster's Chat user id (public, as the channel is)
++ *
++ * A private room whose owner has not turned insight on answers `state: "off"` to a member, and 404 to
++ * everyone else (the room itself is invisible). A DM is not a room and never answers; a chat with Vibe is
++ * a person's own conversation and answers 404.
*/
const express = require('express');
const router = express.Router();
++const rooms = require('../rooms/rooms');
const roomInsight = require('./room-insight');
++const { optionalAuth } = require('../auth/auth');
--router.get('/:id/insight', async (req, res) => {
++router.get('/:id/insight', optionalAuth, 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);
++ if (id === 'global') {
++ res.set('Cache-Control', 'public, max-age=60');
++ return res.json(await roomInsight.global());
++ }
++ // A Live channel's chat: the job keys it by the broadcaster's Chat user id.
++ if (id.startsWith('channel:')) {
++ const channelUserId = Number(id.slice('channel:'.length));
++ if (!channelUserId) return res.status(404).json({ error: 'Room insight not found' });
++ res.set('Cache-Control', 'public, max-age=60');
++ return res.json(await roomInsight.channel(channelUserId));
++ }
++ const room = await rooms.bySlug(id);
++ if (!room || Number(room.assistant) === 1) return res.status(404).json({ error: 'Room insight not found' });
++ const access = await rooms.access(room, req.user || null);
++ if (!access.read) return res.status(404).json({ error: 'Room insight not found' });
++ res.set('Cache-Control', `${req.user ? 'private' : 'public'}, max-age=60`);
++ return res.json(await roomInsight.room(room, { enabled: rooms.insightOn(room) }));
} catch (err) {
-- console.warn('[RoomInsight] global read failed:', err && err.message);
++ console.warn('[RoomInsight] read failed:', err && err.message);
return res.status(500).json({ error: 'Failed to load room insight' });
}
});
++// A channel's display name is kept nowhere in chat_ai_summaries; the read shape does not need it. Exported
++// for tests that want the key format.
++function channelKey(channelUserId) { return `channel:${Number(channelUserId)}`; }
++
module.exports = router;
++module.exports.channelKey = channelKey;
ddiff --git a/server/chat/room-insight.js b/server/chat/room-insight.js
iindex a3ca33a..bc6536e 100644
---- a/server/chat/room-insight.js
++++ b/server/chat/room-insight.js
@@@ -1,22 +1,23 @@
'use strict';
/**
-- * One room's AI insight, as GET /api/v1/rooms/:id/insight answers it (plan 2026-11-10, part 1: Global).
++ * One room's AI insight, as GET /api/v1/rooms/:id/insight answers it (plan 2026-11-10, parts 1 and 2).
*
* 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.
++ * them. This module is the reader: it shapes a stored summary and the append-only timeline into one answer
++ * for every room, so a room, a server's channel and a Live channel 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.
++ * only the stored timeline remains. Off is a room whose owner has not turned the insight on: no text, no
++ * timeline, and never a DM or a chat with Vibe. The timeline is newest first.
*/
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 QUIET_AFTER_MS = 6 * 60 * 60 * 1000; // the job refreshes a busy room at least every 30 min
const TIMELINE_LIMIT = 40;
function _time(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
@@@ -41,7 +42,18 @@ function _timeline(events) {
})).filter((e) => e.at && e.title);
}
--function _shape(row, events) {
++/**
++ * One answer from a summary row and its timeline. `enabled` false (a private room whose owner has not
++ * turned insight on, or a chat with Vibe) is `off`: empty, not a history leak.
++ */
++function _shape(row, events, enabled = true) {
++ if (!enabled) {
++ return {
++ overview: { text: '', topics: [], people: 0, messages: 0, from: null, to: null, updated_at: null },
++ timeline: [],
++ state: 'off',
++ };
++ }
// A summary written from nobody's messages is not a read of the room: zero consenting people means
// quiet, however recent the row is (rows written before the consent filter carry no people count).
const fresh = !!(row && row.overview && Number(row.people) > 0 && row.window_end && Date.now() - _time(row.window_end) < QUIET_AFTER_MS);
@@@ -61,7 +73,7 @@ function _shape(row, events) {
};
}
--/** The Global room's insight. Other rooms answer through room-insight in part 2. */
++/** The Global room's insight (scope `global`, written by the site-wide job). */
async function global() {
const [row, events] = await Promise.all([
db.getChatAiSummary('global', 0, 'global'),
@@@ -70,4 +82,24 @@ async function global() {
return _shape(row, events);
}
--module.exports = { global, _shape, _topics, QUIET_AFTER_MS };
++/** A room's (or a server channel's) insight: scope `room`, subject is the rooms row's id. */
++async function room(roomRow, { enabled = true } = {}) {
++ const [row, events] = await Promise.all([
++ db.getChatAiSummary('room', roomRow.id, 'rolling'),
++ db.getChatTimelineEvents({ scope: 'room', subjectId: roomRow.id, limit: TIMELINE_LIMIT }),
++ ]);
++ return _shape(row, events, enabled);
++}
++
++/** A Live channel chat's insight: scope `channel`, subject is the broadcaster's user id. */
++async function channel(channelUserId, { enabled = true } = {}) {
++ const id = Number(channelUserId);
++ if (!id) return _shape(null, [], false);
++ const [row, events] = await Promise.all([
++ db.getChatAiSummary('channel', id, 'rolling'),
++ db.getChatTimelineEvents({ scope: 'channel', subjectId: id, limit: TIMELINE_LIMIT }),
++ ]);
++ return _shape(row, events, enabled);
++}
++
++module.exports = { global, room, channel, _shape, _topics, QUIET_AFTER_MS };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 092d9bf..f2828ab 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1304,7 +1304,7 @@ async function countChatMessagesSince(afterId, userId = null) {
return (await get(sql, params))?.c || 0;
}
// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
--async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {
++async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, channelUserId = null, limit = 400, order = 'asc' } = {}) {
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
@@@ -1315,6 +1315,7 @@ async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = n
if (afterId != null) { sql += ' AND cm.id > ?'; params.push(afterId); }
if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
++ if (channelUserId != null) { sql += ' AND cm.channel_user_id = ? AND cm.is_global = 0'; params.push(Number(channelUserId)); }
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
params.push(Math.max(1, Math.min(2000, limit)));
const rows = await all(sql, params);
@@@ -1330,6 +1331,62 @@ async function getNthRecentChatTs(n, userId = null) {
return (await get(sql, params))?.ts || null;
}
++// ── Room insight (plan 2026-11-10, part 2): rooms, server channels and Live channels ─────────────
++// The newest messages of one room (oldest first), with the author's subject so the job can ask Network
++// whether AI may read them. AI members' own answers are not people's messages: left out.
++async function getRoomMessagesForAi({ roomId, limit = 120 } = {}) {
++ const rows = await all(`SELECT m.id, m.room_id, m.user_id, m.subject_id, m.message, m.created_at,
++ u.username, u.display_name
++ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id
++ WHERE m.room_id = ? AND m.is_deleted = 0 AND m.ai_member_id IS NULL AND m.agent_stream = false
++ ORDER BY m.id DESC LIMIT ?`,
++ [Number(roomId), Math.max(1, Math.min(400, Number(limit) || 120))]);
++ return (rows || []).reverse();
++}
++
++// Rooms with enough new chat (or a stale summary) to refresh. A server's channels are rooms too.
++async function getRoomsNeedingInsight({ threshold = 20, staleCutoffIso, sinceTs, limit = 2 } = {}) {
++ return await all(`
++ SELECT r.id, r.slug, r.name, r.visibility, MAX(m.id) AS max_id,
++ CAST(SUM(CASE WHEN m.id > COALESCE(s.last_message_id, 0) THEN 1 ELSE 0 END) AS BIGINT) AS new_msgs
++ FROM rooms r
++ JOIN room_messages m ON m.room_id = r.id AND m.is_deleted = 0 AND m.ai_member_id IS NULL AND m.agent_stream = false
++ LEFT JOIN chat_ai_summaries s
++ ON s.scope = 'room' AND s.subject_id = r.id AND s."window" = 'rolling'
++ WHERE r.archived_at IS NULL AND r.assistant = 0
++ AND COALESCE(r.ai_insight, CASE WHEN r.visibility = 'public' THEN 1 ELSE 0 END) = 1
++ AND m.created_at >= ?
++ GROUP BY r.id, r.slug, r.name, r.visibility, s.last_message_id, s.updated_at
++ HAVING SUM(CASE WHEN m.id > COALESCE(s.last_message_id, 0) THEN 1 ELSE 0 END) > 0
++ AND ( SUM(CASE WHEN m.id > COALESCE(s.last_message_id, 0) THEN 1 ELSE 0 END) >= ?
++ OR s.last_message_id IS NULL OR s.updated_at IS NULL OR s.updated_at < ? )
++ ORDER BY new_msgs DESC, r.id
++ LIMIT ?`, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
++}
++
++// The newest messages of one Live channel chat, oldest first.
++async function getChannelMessagesForAi({ channelUserId, limit = 120 } = {}) {
++ return await getChatMessagesForAi({ channelUserId, limit, order: 'desc' });
++}
++
++// Live channels with enough new chat (or a stale summary) to refresh.
++async function getChannelsNeedingInsight({ threshold = 30, staleCutoffIso, sinceTs, limit = 1 } = {}) {
++ return await all(`
++ SELECT cm.channel_user_id AS channel_id, MAX(cm.id) AS max_id,
++ CAST(SUM(CASE WHEN cm.id > COALESCE(s.last_message_id, 0) THEN 1 ELSE 0 END) AS BIGINT) AS new_msgs
++ FROM chat_messages cm
++ LEFT JOIN chat_ai_summaries s
++ ON s.scope = 'channel' AND s.subject_id = cm.channel_user_id AND s."window" = 'rolling'
++ WHERE cm.channel_user_id IS NOT NULL AND cm.is_global = 0 AND ${_CHAT_AI_WHERE}
++ AND cm.timestamp >= ?
++ GROUP BY cm.channel_user_id, s.last_message_id, s.updated_at
++ HAVING SUM(CASE WHEN cm.id > COALESCE(s.last_message_id, 0) THEN 1 ELSE 0 END) > 0
++ AND ( SUM(CASE WHEN cm.id > COALESCE(s.last_message_id, 0) THEN 1 ELSE 0 END) >= ?
++ OR s.last_message_id IS NULL OR s.updated_at IS NULL OR s.updated_at < ? )
++ ORDER BY new_msgs DESC, channel_id
++ LIMIT ?`, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
++}
++
// Users with enough new chat activity (or a stale summary) to warrant an AI refresh.
async function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {
const sql = `
@@@ -1517,6 +1574,10 @@ module.exports = {
countChatMessagesSince,
getChatMessagesForAi,
getNthRecentChatTs,
++ getRoomMessagesForAi,
++ getRoomsNeedingInsight,
++ getChannelMessagesForAi,
++ getChannelsNeedingInsight,
getUsersNeedingChatAi,
getRelayChatMessagesForAi,
getRelayUsersNeedingChatAi,
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex a8dd8fa..0b6e001 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -113,11 +113,23 @@ async function publicRoom(r, extra = {}) {
created_at: r.created_at, members: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
...(Number(r.assistant) === 1 ? { assistant: true } : {}),
...(isWorkspace(r) ? { workspace: true } : {}),
++ insight: insightOn(r),
...(r.server_id ? { server: { slug: (await db.get('SELECT slug FROM chat_servers WHERE id = ?', [r.server_id]) || {}).slug || null, channel: r.channel_slug } } : {}),
...extra,
};
}
++/**
++ * Whether a room is summarised (plan 2026-11-10, part 2): NULL keeps the default — a public room on, a
++ * private one off until its owner turns it on. A chat with Vibe is never: it is a person's own conversation.
++ */
++function insightOn(room) {
++ if (!room) return false;
++ if (Number(room.assistant) === 1) return false;
++ if (room.ai_insight == null) return room.visibility === 'public';
++ return Number(room.ai_insight) === 1;
++}
++
async function bySlug(slug) {
await ensureSchema();
return await db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
@@@ -475,7 +487,7 @@ function screenUrlOf(v) {
return s;
}
--async function update(room, actor, { name, topic, visibility, slow_seconds, join_role, screen_url, workspace, link_previews } = {}, { fromServer = false, minName = 3 } = {}) {
++async function update(room, actor, { name, topic, visibility, slow_seconds, join_role, screen_url, workspace, ai_insight, link_previews } = {}, { fromServer = false, minName = 3 } = {}) {
if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
// A server's channel follows the server's visibility, and is changed through the server (servers.js).
if (room.server_id && !fromServer && visibility != null && visibility !== room.visibility) fail(409, 'rooms.server_channel', 'A channel is as public as its server: change the server instead');
@@@ -503,8 +515,14 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
}
if (screen_url !== undefined && isWorkspace(next)) next.screen_url = screenUrlOf(screen_url);
else if (screen_url !== undefined && screen_url !== '' && screen_url !== null) fail(409, 'rooms.not_workspace', 'Only a workspace room shows a shared screen');
++ // The room's AI insight (plan 2026-11-10, part 2): the owner turns it on or off; without a choice the
++ // default (public on, private off) applies. A chat with Vibe never records one.
++ if (ai_insight !== undefined && ai_insight !== null) {
++ if (Number(room.assistant) === 1 && (ai_insight === true || ai_insight === 1 || ai_insight === '1' || ai_insight === 'on')) fail(409, 'rooms.insight_vibe', 'A chat with Vibe is never summarised');
++ next.ai_insight = (ai_insight === true || ai_insight === 1 || ai_insight === '1' || ai_insight === 'on') ? 1 : 0;
++ }
await db.tx(async () => {
-- await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ?, screen_url = ?, workspace = ?, link_previews = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, next.screen_url == null ? null : next.screen_url, Number(next.workspace) === 1 ? 1 : 0, next.link_previews !== false, room.id]);
++ await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ?, screen_url = ?, workspace = ?, ai_insight = ?, link_previews = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, next.screen_url == null ? null : next.screen_url, Number(next.workspace) === 1 ? 1 : 0, next.ai_insight == null ? null : Number(next.ai_insight), next.link_previews !== false, room.id]);
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
await announceDeleted(room, (await db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id])).map((r) => r.id));
@@@ -583,6 +601,6 @@ module.exports = {
isWorkspace, isStaffRoom,
postAsAi,
ensureSchema, create, list, bySlug, access, join, leave, post, history, historyPage, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
-- attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, POSTERS, TALKERS,
++ attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, insightOn, POSTERS, TALKERS,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
ddiff --git a/server/web/insight-block.js b/server/web/insight-block.js
nnew file mode 100644
iindex 0000000..309d781
---- /dev/null
++++ b/server/web/insight-block.js
@@@ -0,0 +1,28 @@
++'use strict';
++/**
++ * A room's AI insight, plain markup behind a button in the room's head (plan 2026-11-10).
++ * Server-rendered, so it reads and opens without JavaScript (<details>); the look is the designer's.
++ * Kit classes only: the popover, muted text, the icon button. Used by the Global page (server/web/pages.js)
++ * and by a server's channel page (server/web/server-pages.js).
++ */
++const { esc } = require('./layout');
++const ui = require('./ui');
++const { ICONS: IC } = require('./icons');
++
++function insightBlock(insight) {
++ const o = (insight && insight.overview) || {};
++ const topics = (o.topics || []).slice(0, 8);
++ const events = ((insight && insight.timeline) || []).slice(0, 6);
++ const ready = insight && insight.state === 'ready' && !!o.text;
++ const off = insight && insight.state === 'off';
++ 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">${off ? 'AI insight is off for this room.' : '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>';
++}
++
++module.exports = { insightBlock };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 8b4486e..e1a675a 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -39,6 +39,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const cache = require('openvibe-shared/cache-policy');
const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = require('./layout');
++const { insightBlock } = require('./insight-block');
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const home = require('./home');
@@@ -1084,6 +1085,8 @@ ${people}
const work = rooms.isWorkspace(room) ? await require('../rooms/workspace').list(room, me, { limit: 40 }) : null;
const aiHere = ais.length ? `<p class="oc-ai-here">${ICONS.spark}<span>${ais.map((a) => `<strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(', ')} ${ais.length > 1 ? 'are' : 'is'} here: write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
const memberCount = (await rooms.publicRoom(room)).members;
++ const insight = await require('../chat/room-insight').room(room, { enabled: rooms.insightOn(room) }).catch(() => null);
++ const insightHtml = insight ? insightBlock(insight) : '';
// A public room's own line when it has one long enough to read as a description; otherwise its name, size
// and what the page is for. Escaped by the layout's head builder like every other description.
const topic = typeof room.topic === 'string' ? room.topic.trim() : '';
@@@ -1110,7 +1113,7 @@ ${people}
<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
<div class="oc-room-id"><h1>${esc(room.name)}${room.visibility === 'private' ? `<span class="oc-room-lock" title="Private: only people invited read and post">${ICONS.lock}<span class="oc-sr">private</span></span>` : ''}${kindBadge(room)}</h1>
<p class="oc-muted oc-room-sub">${room.topic && !isAgentRoom ? `${esc(room.topic)} · ` : ''}${memberCount} ${memberCount === 1 ? 'member' : 'members'}${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole} <span class="oc-live" id="oc-live" hidden>● live</span></p>${roomRules(room)}</div>
--<div class="oc-room-tools">${manage}${leave}</div>
++<div class="oc-room-tools">${manage}${leave}${insightHtml}</div>
</header>
${isAgentRoom ? `<aside class="oc-agent-banner" role="note"><strong>Co-pilot room</strong> ${esc(require('../chat/agent-room').BANNER)}</aside>` : ''}
${agentPollView ? agentPollCard(room, agentPollView, can) : ''}
@@@ -1267,6 +1270,9 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
return await page(req, res, 200, {
actor, title: `#${room.name}: AI members`, path: `/r/${room.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', shell: true, footer: 'none',
body: roomShell(`${ui.head({ title: `AI members in #${esc(room.name)}`, back: { href: await channelUrl(room, '/settings'), label: 'Server settings' } })}${saved}${error}
++${can.manage ? `<section class="oc-set"><h2>AI insight</h2><form class="oc-form oc-set-form" method="post" action="/r/${esc(room.slug)}/settings">
++<input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Summarise this channel<small>An overview and timeline of the channel, written for someone arriving now. Only messages from people who allowed AI use of them are read.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>
++<p><button type="submit">Save</button></p></form></section>` : ''}
${aiSection}`),
});
}
@@@ -1289,6 +1295,7 @@ ${can.manage ? `<section class="oc-set"><h2>Room</h2><form class="oc-form oc-gri
<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
${room.kind === 'call' ? `<label>People who join <select name="join_role">${rooms.JOIN_ROLES_CALL.map((r) => `<option value="${r}"${r === addRole ? ' selected' : ''}>${{ speaker: 'can talk (speakers)', participant: 'listen and write (participants)', viewer: 'listen and read (viewers)' }[r]}</option>`).join('')}</select></label>` : ''}
<label>Slow mode: seconds between messages (0 is off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
++<input type="hidden" name="has_ai_insight" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">AI insight<small>An overview and timeline of this room, written for someone arriving now. Only messages from people who allowed AI use of them are read; a private room is off until you turn this on.</small></span><input type="checkbox" class="oc-switch" name="ai_insight" value="1"${rooms.insightOn(room) ? ' checked' : ''}></label>
${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(room.assistant) !== 1 ? `<input type="hidden" name="has_workspace" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Work together here<small>A work queue beside the conversation, alerts from other systems and a shared screen</small></span><input type="checkbox" class="oc-switch" name="workspace" value="1"${rooms.isWorkspace(room) ? ' checked' : ''}></label>` : ''}
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
<p><button type="submit">Save</button></p></form></section>` : ''}
@@@ -1324,6 +1331,7 @@ ${attachList}`),
const workspace = b.has_workspace ? [].concat(b.workspace || []).includes('1') : undefined;
const updated = await rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined,
...(workspace !== undefined ? { workspace } : {}),
++ ...(b.has_ai_insight ? { ai_insight: b.ai_insight === '1' } : {}),
...(rooms.isWorkspace(room) && b.screen_url !== undefined ? { screen_url: b.screen_url } : {}) });
// A room that just became a workspace gets Vibe, who runs and announces its work, when it has room for one more.
if (workspace && !rooms.isWorkspace(room) && updated.workspace && aiMembers.enabled()) {
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex fa0b8d2..48be08c 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -18,6 +18,7 @@ const ui = require('./ui');
const { ICONS: IC } = require('./icons');
const rooms = require('../rooms/rooms');
const shell = require('./shell');
++const { insightBlock } = require('./insight-block');
const servers = require('../rooms/servers');
const ctx = require('../live-context');
@@@ -290,6 +291,8 @@ ${sheet}`,
// request, and the channel's own URL when the request named it. `back` stays the channel URL the composer
// and the sign-in link post to (POST /s/:slug/:channel).
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
++ const insight = room ? await require('../chat/room-insight').room(room, { enabled: rooms.insightOn(room) }).catch(() => null) : null;
++ const insightHtml = insight ? insightBlock(insight) : '';
await page(req, res, 200, {
actor, shell: true, footer: 'none', title: `${isVoice ? (isStage ? 'Stage · ' : '') : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
@@@ -317,7 +320,7 @@ ${sheet}`,
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
}),
sideLabel: server.name,
-- main: `${shell.channelHeader({ name: room.name, kind: isStage ? 'stage' : isVoice ? 'voice' : 'text', topic: room.topic, members: true })}
++ main: `${shell.channelHeader({ name: room.name, kind: isStage ? 'stage' : isVoice ? 'voice' : 'text', topic: room.topic, members: true, extra: insightHtml })}
${invitePanel}
<span class="oc-live" id="oc-live" hidden>● live</span>
${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}
ddiff --git a/server/web/shell.js b/server/web/shell.js
iindex 5e0344f..c75ae22 100644
---- a/server/web/shell.js
++++ b/server/web/shell.js
@@@ -269,7 +269,7 @@ ${o.topic ? `<span class="oc-muted oc-chan-topic">${esc(o.topic)}</span>` : ''}
<span class="oc-chan-tools">
<button type="button" class="oc-icon-btn oc-members-toggle" data-oc-panel="members" aria-label="Show the people" title="People" aria-pressed="false">${IC.members}</button>
${o.back ? iconBtn(o.back.href, o.back.label, IC.back) : ''}
--</span></header>`;
++</span>${o.extra || ''}</header>`;
}
/**
ddiff --git a/test/room-insight-rooms.test.js b/test/room-insight-rooms.test.js
nnew file mode 100644
iindex 0000000..fef030c
---- /dev/null
++++ b/test/room-insight-rooms.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * Part 2 of the room insight (plan 2026-11-10): a server's channels and the rooms people make read the
++ * same endpoint as Global. A public room is summarised; a private one is off until its owner turns it on;
++ * a chat with Vibe is never; a Live channel chat is keyed `channel:<broadcaster user id>`.
++ */
++const assert = require('assert');
++const { boot, suite, sqliteNow } = require('./helpers');
++
++const t = suite('room-insight-rooms');
++let h, db, rooms, chatAi, aiClient, owner, alice, pub, priv, streamer, channelId, streamId;
++
++async function roomMsg(roomId, user, text) {
++ return (await db.run(
++ `INSERT INTO room_messages (room_id, user_id, subject_id, message, created_at) VALUES (?, ?, ?, ?, ?)`,
++ [roomId, user.id, user.subject_id || null, text, sqliteNow()]
++ )).lastInsertRowid;
++}
++async function chatMsg({ user, message, channelUserId }) {
++ return (await db.run(
++ `INSERT INTO chat_messages (stream_id, channel_user_id, user_id, subject_id, username, message, message_type, is_global, timestamp)
++ VALUES (?, ?, ?, ?, ?, ?, 'chat', 0, ?)`,
++ [streamId, channelUserId, user.id, user.subject_id || null, user.username, message, sqliteNow()]
++ )).lastInsertRowid;
++}
++function aiStub(text) {
++ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'succeeded', output: {
++ recent_overview: text, memory: 'memory line',
++ timeline: [{ label: 'a moment', detail: 'detail', mins_ago: 2 }],
++ } } }) } });
++}
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_AI_ENABLED: '1' } });
++ db = require('../server/db/database');
++ rooms = require('../server/rooms/rooms');
++ chatAi = require('../server/ai/chat-ai');
++ aiClient = require('../server/ai/client');
++ owner = h.addUser('roomowner', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B1' });
++ alice = h.addUser('roomalice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2' });
++ streamer = h.addUser('roomstreamer', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B3' });
++ h.consent.allowed.add(owner.subject_id);
++ h.consent.allowed.add(alice.subject_id);
++ h.consent.allowed.add(streamer.subject_id);
++ channelId = h.addChannel(streamer.id);
++ streamId = h.addStream(streamer.id, channelId);
++ await h.ctx.sync();
++ pub = await rooms.create(owner, { name: 'Builders Room', slug: 'builders-room', visibility: 'public' });
++ priv = await rooms.create(owner, { name: 'Secret Room', slug: 'secret-room', visibility: 'private' });
++ assert.strictEqual(pub.insight, true, 'a public room is on by default');
++ assert.strictEqual(priv.insight, false, 'a private room is off by default');
++ await rooms.join(pub, alice);
++ await db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'member')", [priv.id, alice.id]);
++});
++
++t('a public room is summarised and answers the same shape publicly', async () => {
++ aiStub('the builders are planning the Friday stream');
++ const id = await roomMsg(pub.id, alice, 'the friday stream needs a capture card capture card');
++ await chatAi._refreshRoom({ id: pub.id, slug: pub.slug, max_id: id });
++
++ const row = await db.getChatAiSummary('room', pub.id, 'rolling');
++ assert.ok(row, 'a room summary row was written');
++ assert.strictEqual(row.overview, 'the builders are planning the Friday stream');
++ assert.ok(Number(row.people) >= 1);
++ assert.ok(JSON.parse(row.topics_json).some((x) => x.label === 'capture'));
++
++ const r = await h.http('GET', '/api/v1/rooms/builders-room/insight'); // no token
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'ready');
++ assert.strictEqual(r.body.overview.text, 'the builders are planning the Friday stream');
++ assert.ok(r.body.timeline.some((e) => e.title === 'a moment'));
++});
++
++t('a private room is off for its members and 404 for everyone else; its owner turns it on', async () => {
++ let r = await h.http('GET', '/api/v1/rooms/secret-room/insight');
++ assert.strictEqual(r.status, 404, 'not readable without a session');
++
++ r = await h.http('GET', '/api/v1/rooms/secret-room/insight', { token: owner.token });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'off');
++ assert.strictEqual(r.body.overview.text, '');
++ assert.deepStrictEqual(r.body.timeline, []);
++
++ // The owner's setting through the API the settings page uses.
++ r = await h.http('PATCH', '/api/chat/rooms/secret-room', { token: owner.token, body: { ai_insight: true } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.room.insight, true);
++
++ aiStub('the secret room decided on a date');
++ const id = await roomMsg(priv.id, alice, 'secret date decided');
++ await chatAi._refreshRoom({ id: priv.id, slug: priv.slug, max_id: id });
++
++ r = await h.http('GET', '/api/v1/rooms/secret-room/insight', { token: owner.token });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'ready');
++ assert.strictEqual(r.body.overview.text, 'the secret room decided on a date');
++ r = await h.http('GET', '/api/v1/rooms/secret-room/insight');
++ assert.strictEqual(r.status, 404, 'still not readable signed out');
++});
++
++t('a chat with Vibe never answers, and the settings page offers the switch', async () => {
++ const vibe = await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, assistant) VALUES ('vibechat', 'Vibe', 'community', 'private', ?, ?, 1)", [owner.id, owner.subject_id]);
++ const r = await h.http('GET', '/api/v1/rooms/vibechat/insight', { token: owner.token });
++ assert.strictEqual(r.status, 404);
++ assert.ok(vibe.lastInsertRowid);
++
++ const page = await fetch(`${h.base}/r/builders-room/settings`, { headers: { cookie: `ov_token=${owner.token}` } }).then((x) => x.text());
++ assert.ok(page.includes('AI insight'), 'the room settings offer the switch');
++ assert.ok(page.includes('name="ai_insight"'));
++});
++
++t('a Live channel chat answers under channel:<broadcaster id>', async () => {
++ aiStub('the channel is talking about the new patch');
++ const id = await chatMsg({ user: alice, message: 'the patch notes are wild', channelUserId: streamer.id });
++ await chatAi._refreshChannel({ channel_id: streamer.id, max_id: id });
++
++ const row = await db.getChatAiSummary('channel', streamer.id, 'rolling');
++ assert.ok(row, 'a channel summary row was written');
++ const r = await h.http('GET', `/api/v1/rooms/channel:${streamer.id}/insight`);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'ready');
++ assert.strictEqual(r.body.overview.text, 'the channel is talking about the new patch');
++});
++
++t('a server channel is a room and answers the same way', async () => {
++ const serverId = (await db.run("INSERT INTO chat_servers (slug, name, visibility, owner_id, owner_subject) VALUES ('buildsrv', 'Build Server', 'public', ?, ?)", [owner.id, owner.subject_id])).lastInsertRowid;
++ await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'owner')", [serverId, owner.id]);
++ const roomId = (await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, server_id, channel_slug) VALUES ('general', 'general', 'community', 'public', ?, ?, ?, 'general')", [owner.id, owner.subject_id, serverId])).lastInsertRowid;
++ aiStub('the channel is picking a launch date');
++ const id = await roomMsg(roomId, alice, 'launch date talk');
++ await chatAi._refreshRoom({ id: roomId, slug: 'general', max_id: id });
++ const r = await h.http('GET', '/api/v1/rooms/general/insight');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'ready');
++ assert.strictEqual(r.body.overview.text, 'the channel is picking a launch date');
++});
++
++t('the room job discovers rooms with new messages, not the ones switched off', async () => {
++ await roomMsg(pub.id, alice, 'one more line so the room has news');
++ const candidates = await db.getRoomsNeedingInsight({
++ threshold: 1, staleCutoffIso: sqliteNow(24 * 3600e3), sinceTs: sqliteNow(-14 * 86400e3), limit: 10,
++ });
++ const slugs = candidates.map((c) => c.slug);
++ assert.ok(slugs.includes('builders-room'), JSON.stringify(slugs));
++ assert.ok(!slugs.includes('vibechat'), 'a chat with Vibe is never a candidate');
++});
++
++t.run(async () => { try { chatAi.stop(); } catch { /* */ } });
ddiff --git a/test/room-insight.test.js b/test/room-insight.test.js
iindex 17221c9..858774f 100644
---- a/test/room-insight.test.js
++++ b/test/room-insight.test.js
@@@ -53,6 +53,17 @@ t('an old summary is quiet: no text, no topics, the timeline stays', async () =>
assert.strictEqual(shaped.timeline[0].kind, 'moment');
});
++t('a room with insight off says off, with no text and no timeline', async () => {
++ const now = sqliteNow();
++ const shaped = roomInsight._shape({
++ overview: 'was once here', window_end: now, updated_at: now, people: 2, window_message_count: 3,
++ }, [{ ts: now, label: 'a moment', detail: 'old' }], false);
++ assert.strictEqual(shaped.state, 'off');
++ assert.strictEqual(shaped.overview.text, '');
++ assert.deepStrictEqual(shaped.overview.topics, []);
++ assert.deepStrictEqual(shaped.timeline, []);
++});
++
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');