openvibers/openvibe.chat Commits
Chat: a room's insight, for rooms, server channels and Live channels (#484)
Changes
nnew file mode 100644@@@ -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;@@@ -38,6 +38,16 @@ const USER_PASS_EVERY_MS = 3 * 60 * 1000; // throttle the (heavier) user dconst USER_STALE_MS = 24 * 60 * 60 * 1000; // refresh a lagging user at least dailyconst 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 targetconst MAX_BATCH_MESSAGES = 300; // token cap per callconst 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,};@@@ -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;@@@ -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 minconst 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 };@@@ -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 = nif (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,@@@ -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,};nnew file mode 100644@@@ -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 };@@@ -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()) {@@@ -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>` : ''}@@@ -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>`;}/**nnew file mode 100644@@@ -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 { /* */ } });@@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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;
@@ -38,6 +38,16 @@ const USER_PASS_EVERY_MS = 3 * 60 * 1000; // throttle the (heavier) user d
@@ -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_STALE_MS = 24 * 60 * 60 * 1000; // refresh a lagging user at least daily
const USER_DISCOVERY_LOOKBACK_DAYS = 14; // bound the discovery GROUP BY
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 WINDOW_TARGET_MESSAGES = 300; // adaptive-window sizing target
const MAX_BATCH_MESSAGES = 300; // token cap per call
const MAX_BATCH_MESSAGES = 300; // token cap per call
const WINDOW_MIN_MS = 30 * 60 * 1000; // overview window is never shorter than 30 min
const WINDOW_MIN_MS = 30 * 60 * 1000; // overview window is never shorter than 30 min
@@ -57,6 +67,7 @@ let _running = false;
@@ -57,6 +67,7 @@ let _running = false;
let _timer = null;
let _timer = null;
let _tickInFlight = false;
let _tickInFlight = false;
let _lastUserPass = 0;
let _lastUserPass = 0;
+let _lastRoomPass = 0;
// ── Small utils ──────────────────────────────────────────────────────────────
// ── Small utils ──────────────────────────────────────────────────────────────
// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (ov_now()). Match that format.
// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (ov_now()). Match that format.
@@ -221,6 +232,128 @@ async function _refreshGlobal() {
@@ -221,6 +232,128 @@ async function _refreshGlobal() {
return true;
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 ─────────────────────────────────────────────────────────────────
// ── PER-USER ─────────────────────────────────────────────────────────────────
async function _refreshUser(uid, maxId) {
async function _refreshUser(uid, maxId) {
const prior = await db.getChatAiSummary('user', uid, 'rolling');
const prior = await db.getChatAiSummary('user', uid, 'rolling');
@@ -400,6 +533,13 @@ async function _tick() {
@@ -400,6 +533,13 @@ async function _tick() {
try { await _refreshGlobal(); }
try { await _refreshGlobal(); }
catch (e) { console.warn('[ChatAI] global refresh failed:', e.message); }
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) {
if (Date.now() - _lastUserPass >= USER_PASS_EVERY_MS) {
_lastUserPass = Date.now();
_lastUserPass = Date.now();
await _userPass();
await _userPass();
@@ -489,7 +629,7 @@ async function getAnonInsight(anonId) {
@@ -489,7 +629,7 @@ async function getAnonInsight(anonId) {
}
}
module.exports = {
module.exports = {
- start, stop, _tick, _msgData, _topics, _people,
+ start, stop, _tick, _msgData, _topics, _people, _refreshRoom, _refreshChannel, WORKFLOW_GLOBAL,
getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight,
getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight,
- WORKFLOW_GLOBAL, WORKFLOW_PROFILE,
+ WORKFLOW_PROFILE,
};
};
@@ -1,27 +1,51 @@
@@ -1,27 +1,51 @@
'use strict';
'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 express = require('express');
const router = express.Router();
const router = express.Router();
+const rooms = require('../rooms/rooms');
const roomInsight = require('./room-insight');
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 || '');
const id = String(req.params.id || '');
- if (id !== 'global') return res.status(404).json({ error: 'Room insight not found' });
try {
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) {
} 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' });
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 = router;
+module.exports.channelKey = channelKey;
@@ -1,22 +1,23 @@
@@ -1,22 +1,23 @@
'use strict';
'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
* 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
* 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 },
* { overview: { text, topics: [{ label, count }], people, messages, from, to, updated_at },
* timeline: [{ at, kind, title, text, message_id }],
* timeline: [{ at, kind, title, text, message_id }],
* state: 'ready' | 'quiet' | 'off' }
* 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
* 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 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;
const TIMELINE_LIMIT = 40;
function _time(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
function _time(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
@@ -41,7 +42,18 @@ function _timeline(events) {
@@ -41,7 +42,18 @@ function _timeline(events) {
})).filter((e) => e.at && e.title);
})).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
// 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).
// 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);
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) {
@@ -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() {
async function global() {
const [row, events] = await Promise.all([
const [row, events] = await Promise.all([
db.getChatAiSummary('global', 0, 'global'),
db.getChatAiSummary('global', 0, 'global'),
@@ -70,4 +82,24 @@ async function global() {
@@ -70,4 +82,24 @@ async function global() {
return _shape(row, events);
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 };
@@ -1304,7 +1304,7 @@ async function countChatMessagesSince(afterId, userId = null) {
@@ -1304,7 +1304,7 @@ async function countChatMessagesSince(afterId, userId = null) {
return (await get(sql, params))?.c || 0;
return (await get(sql, params))?.c || 0;
}
}
// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
// 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,
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,
cm.stream_id, cm.channel_user_id, cm.is_global,
ch.username AS channel_username, ch.display_name AS channel_display
ch.username AS channel_username, ch.display_name AS channel_display
@@ -1315,6 +1315,7 @@ async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = n
@@ -1315,6 +1315,7 @@ async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = n
if (afterId != null) { sql += ' AND cm.id > ?'; params.push(afterId); }
if (afterId != null) { sql += ' AND cm.id > ?'; params.push(afterId); }
if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
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 ?`;
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
params.push(Math.max(1, Math.min(2000, limit)));
params.push(Math.max(1, Math.min(2000, limit)));
const rows = await all(sql, params);
const rows = await all(sql, params);
@@ -1330,6 +1331,62 @@ async function getNthRecentChatTs(n, userId = null) {
@@ -1330,6 +1331,62 @@ async function getNthRecentChatTs(n, userId = null) {
return (await get(sql, params))?.ts || 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.
// Users with enough new chat activity (or a stale summary) to warrant an AI refresh.
async function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {
async function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {
const sql = `
const sql = `
@@ -1517,6 +1574,10 @@ module.exports = {
@@ -1517,6 +1574,10 @@ module.exports = {
countChatMessagesSince,
countChatMessagesSince,
getChatMessagesForAi,
getChatMessagesForAi,
getNthRecentChatTs,
getNthRecentChatTs,
+ getRoomMessagesForAi,
+ getRoomsNeedingInsight,
+ getChannelMessagesForAi,
+ getChannelsNeedingInsight,
getUsersNeedingChatAi,
getUsersNeedingChatAi,
getRelayChatMessagesForAi,
getRelayChatMessagesForAi,
getRelayUsersNeedingChatAi,
getRelayUsersNeedingChatAi,
@@ -113,11 +113,23 @@ async function publicRoom(r, extra = {}) {
@@ -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,
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 } : {}),
...(Number(r.assistant) === 1 ? { assistant: true } : {}),
...(isWorkspace(r) ? { workspace: 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 } } : {}),
...(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,
...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) {
async function bySlug(slug) {
await ensureSchema();
await ensureSchema();
return await db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
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) {
@@ -475,7 +487,7 @@ function screenUrlOf(v) {
return s;
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');
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).
// 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');
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
@@ -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);
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');
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.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.
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
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));
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 = {
@@ -583,6 +601,6 @@ module.exports = {
isWorkspace, isStaffRoom,
isWorkspace, isStaffRoom,
postAsAi,
postAsAi,
ensureSchema, create, list, bySlug, access, join, leave, post, history, historyPage, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
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,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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 };
@@ -39,6 +39,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -39,6 +39,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = require('./layout');
const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = require('./layout');
+const { insightBlock } = require('./insight-block');
const { createSessionRoutes } = require('./session');
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const home = require('./home');
const home = require('./home');
@@ -1084,6 +1085,8 @@ ${people}
@@ -1084,6 +1085,8 @@ ${people}
const work = rooms.isWorkspace(room) ? await require('../rooms/workspace').list(room, me, { limit: 40 }) : null;
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 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 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
// 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.
// 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() : '';
const topic = typeof room.topic === 'string' ? room.topic.trim() : '';
@@ -1110,7 +1113,7 @@ ${people}
@@ -1110,7 +1113,7 @@ ${people}
<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
<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>
<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>
<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>
</header>
${isAgentRoom ? `<aside class="oc-agent-banner" role="note"><strong>Co-pilot room</strong> ${esc(require('../chat/agent-room').BANNER)}</aside>` : ''}
${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) : ''}
${agentPollView ? agentPollCard(room, agentPollView, can) : ''}
@@ -1267,6 +1270,9 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
@@ -1267,6 +1270,9 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
return await page(req, res, 200, {
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',
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}
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}`),
${aiSection}`),
});
});
}
}
@@ -1289,6 +1295,7 @@ ${can.manage ? `<section class="oc-set"><h2>Room</h2><form class="oc-form oc-gri
@@ -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>
<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>` : ''}
${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>
<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>` : ''}
${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>` : ''}
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
<p><button type="submit">Save</button></p></form></section>` : ''}
<p><button type="submit">Save</button></p></form></section>` : ''}
@@ -1324,6 +1331,7 @@ ${attachList}`),
@@ -1324,6 +1331,7 @@ ${attachList}`),
const workspace = b.has_workspace ? [].concat(b.workspace || []).includes('1') : undefined;
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,
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 } : {}),
...(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 } : {}) });
...(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.
// 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()) {
if (workspace && !rooms.isWorkspace(room) && updated.workspace && aiMembers.enabled()) {
@@ -18,6 +18,7 @@ const ui = require('./ui');
@@ -18,6 +18,7 @@ const ui = require('./ui');
const { ICONS: IC } = require('./icons');
const { ICONS: IC } = require('./icons');
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
const shell = require('./shell');
const shell = require('./shell');
+const { insightBlock } = require('./insight-block');
const servers = require('../rooms/servers');
const servers = require('../rooms/servers');
const ctx = require('../live-context');
const ctx = require('../live-context');
@@ -290,6 +291,8 @@ ${sheet}`,
@@ -290,6 +291,8 @@ ${sheet}`,
// request, and the channel's own URL when the request named it. `back` stays the channel URL the composer
// 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).
// and the sign-in link post to (POST /s/:slug/:channel).
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
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, {
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'] : [],
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,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
@@ -317,7 +320,7 @@ ${sheet}`,
@@ -317,7 +320,7 @@ ${sheet}`,
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
}),
}),
sideLabel: server.name,
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}
${invitePanel}
<span class="oc-live" id="oc-live" hidden>● live</span>
<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>` : ''}
${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>` : ''}
@@ -269,7 +269,7 @@ ${o.topic ? `<span class="oc-muted oc-chan-topic">${esc(o.topic)}</span>` : ''}
@@ -269,7 +269,7 @@ ${o.topic ? `<span class="oc-muted oc-chan-topic">${esc(o.topic)}</span>` : ''}
<span class="oc-chan-tools">
<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>
<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) : ''}
${o.back ? iconBtn(o.back.href, o.back.label, IC.back) : ''}
-</span></header>`;
+</span>${o.extra || ''}</header>`;
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -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 { /* */ } });
@@ -53,6 +53,17 @@ t('an old summary is quiet: no text, no topics, the timeline stays', async () =>
@@ -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');
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 () => {
t('no row at all is quiet, and the route module is a router', async () => {
const shaped = roomInsight._shape(null, []);
const shaped = roomInsight._shape(null, []);
assert.strictEqual(shaped.state, 'quiet');
assert.strictEqual(shaped.state, 'quiet');