openvibers/openvibe.live Commits

Chat analysis, person overviews, session titles and VOD ranking send OpenVibe.AI data, not prompts (roadmap WS-O task 2; AI 6b14064). The global chat picture and every chatter's profile (signed-in, bridged, anonymous) send the prior notes, moments already recorded and the messages as data: minutes ago, where, author, kind, text (_msgData). AI formats them as _fmtMessages did, so that and _parseJson are gone. The combined streamer-and-chatter overview sends the two summaries; session titles send the summaries; the moments job's VOD ranking sends titles, overviews and stats. Live keeps what it does with the answers: timeline merging, title storage, rank caching and the objective fallback. test/ai-templates-jobs.test.js checks the message data. npm test 148/148.

OP OpenVibers committed 28 Sept 2026 · fb2d76835c · 4 files, +65 −142


Changes

ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 64b1ead7..b80fc100 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -66,22 +66,14 @@ function _titleFromDesc(desc) {
// ── Stage 1: rank VODs by their AI overview ─────────────────────────────────────────────
// Scores one chunk of VODs via a single LLM call; returns [{vod, score, why}] best-first.
async function _scoreVodChunk(chunk, want) {
-- const list = chunk.map((v, i) => {
-- const ov = _cleanText(v.ai_overview || v.ai_overview_short, 260) || '(no summary)';
-- return `${i}. [${v.view_count || 0} views · ${v.clip_count || 0} clips · peak ${v.peak_viewers || 0}] "${_cleanText(v.title, 70)}" — ${ov}`;
-- }).join('\n');
-- const prompt = `These are livestream VODs with their AI summaries and popularity stats. Rank the MOST interesting/entertaining/memorable ones for a highlights showcase — favor funny, dramatic, surprising, high-energy, or unusual content over routine "just chatting / sitting at a desk" streams. Clips taken and peak viewers are strong signals that something notable happened.
--
--${list}
--
--Return STRICT JSON only, nothing else: [{"index": <n>, "score": <1-100>, "why": "<3-8 words>"}] for the top ${Math.min(chunk.length, Math.max(want, 6))}, best first.`;
++ // The prompt is OpenVibe.AI's versioned template live.moments.rank (WS-O task 2); Live sends the VODs as data.
++ const n = (v) => Math.max(0, Math.round(Number(v) || 0));
++ const vods = chunk.map(v => ({ title: _cleanText(v.title, 70) || 'Untitled', overview: _cleanText(v.ai_overview || v.ai_overview_short, 260), views: n(v.view_count), clips: n(v.clip_count), peak_viewers: n(v.peak_viewers) }));
try {
-- const text = await ai.summarizeText(prompt, 700, 'moment_vod_rank');
-- const m = text && text.match(/\[[\s\S]*\]/);
-- if (!m) return [];
-- const arr = JSON.parse(m[0]);
++ const out = await aiService.structured('live.moments.rank', { vods, want: Math.max(1, Math.min(25, want || 6)) }, { meter: { kind: 'moment_vod_rank', role: 'legacy' } });
++ if (!out || !Array.isArray(out.ranked)) return [];
const seen = new Set();
-- return arr.filter(x => chunk[x.index] != null && !seen.has(x.index) && seen.add(x.index))
++ return out.ranked.filter(x => chunk[x.index] != null && !seen.has(x.index) && seen.add(x.index))
.map(x => ({ vod: chunk[x.index], score: Number(x.score) || 0, why: _cleanText(x.why, 60) }))
.sort((a, b) => b.score - a.score);
} catch { return []; }
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex db6f5eba..c36821a6 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -131,9 +131,9 @@ function _combinedOverview(userId, streamerOv, chatIns) {
const ai = require('./ai-analysis');
if (!_combinedBusy.has(userId) && ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
_combinedBusy.add(userId);
-- const prompt = `You are describing a person on a streaming site by fusing two AI summaries about them into ONE cohesive 2-4 sentence overview of who they are overall — both as a STREAMER and as a CHATTER. Be natural, specific, and not repetitive. Output ONLY the overview prose with no label or prefix.\n\nAS A STREAMER:\n${sOv}\n\nAS A CHATTER:\n${cOv}`;
-- Promise.resolve(ai.summarizeText(prompt, 320, 'combined_overview'))
-- .then(text => { if (text) { const clean = String(text).replace(/^\s*(combined\s+overview|overview)\s*[:\-–]\s*/i, '').trim(); try { db.setState(key, JSON.stringify({ text: clean, generated_at: Date.now(), src: srcLen })); } catch { /* */ } } })
++ // The prompt is OpenVibe.AI's versioned template live.person.overview (WS-O task 2).
++ Promise.resolve(require('./ai-service').structured('live.person.overview', { as_streamer: String(sOv).slice(0, 4000), as_chatter: String(cOv).slice(0, 4000) }, { meter: { kind: 'combined_overview', role: 'legacy', ownerUserId: userId } }))
++ .then(out => { const clean = out && out.overview ? String(out.overview).trim() : ''; if (clean) { try { db.setState(key, JSON.stringify({ text: clean, generated_at: Date.now(), src: srcLen })); } catch { /* */ } } })
.catch(() => { })
.finally(() => _combinedBusy.delete(userId));
}
@@@ -151,14 +151,13 @@ function _ensureSessionTitles(userId) {
const pending = db.getUntitledAiSessions(userId, 20);
if (!pending.length) return;
_titlingBusy.add(userId);
-- const list = pending.map((p, i) => `${i}. ${String(p.ai_overview_short || p.ai_overview || '').replace(/\s+/g, ' ').slice(0, 200)}`).join('\n');
-- const prompt = `Write a SHORT, catchy stream title for each livestream summary below — the way a real streamer titles a VOD: 3 to 6 words, punchy, no surrounding quotes, no trailing period. Match each stream's vibe.\n\n${list}\n\nReturn STRICT JSON only: [{"index": <number>, "title": "<3-6 word title>"}] for every item.`;
-- Promise.resolve(ai.summarizeText(prompt, 700, 'session_titles'))
-- .then(text => {
-- const m = text && text.match(/\[[\s\S]*\]/);
-- if (!m) return;
++ // The prompt is OpenVibe.AI's versioned template live.stream.titles (WS-O task 2); Live sends the summaries.
++ const summaries = pending.map(p => String(p.ai_overview_short || p.ai_overview || '').replace(/\s+/g, ' ').slice(0, 200) || '(no summary)');
++ Promise.resolve(require('./ai-service').structured('live.stream.titles', { summaries }, { meter: { kind: 'session_titles', role: 'legacy', ownerUserId: userId } }))
++ .then(out => {
++ if (!out || !Array.isArray(out.titles)) return;
let touched = 0;
-- for (const x of JSON.parse(m[0])) {
++ for (const x of out.titles) {
const p = pending[x.index];
if (p && x.title) { db.setStreamAiTitle(p.id, String(x.title).replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80)); touched++; }
}
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex 7b02e0df..8a02b643 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -45,7 +45,6 @@ const MEMORY_MAX_CHARS = 1600;
const USER_MEMORY_MAX_CHARS = 1200;
const TIMELINE_MAX = 40;
const MSG_MAX_CHARS = 220;
--const PROMPT_MSGS_MAX_CHARS = 9000;
let _running = false;
let _timer = null;
@@@ -54,42 +53,28 @@ let _lastUserPass = 0;
// ── Small utils ──────────────────────────────────────────────────────────────
function _ai() { return require('./ai-analysis'); }
++function _aiService() { return require('./ai-service'); }
// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (CURRENT_TIMESTAMP). Match that format.
function _sqlTime(d) { return new Date(d).toISOString().slice(0, 19).replace('T', ' '); }
function _parseSqlTime(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
--function _parseJson(text) {
-- if (!text) return null;
-- // Prefer a fenced/object slice; tolerate leading prose from chatty models.
-- const m = text.match(/\{[\s\S]*\}/);
-- if (!m) return null;
-- try { return JSON.parse(m[0]); } catch { /* */ }
-- // Retry after stripping trailing commas.
-- try { return JSON.parse(m[0].replace(/,\s*([}\]])/g, '$1')); } catch { return null; }
--}
function _clip(str, n) { str = (str == null ? '' : String(str)).trim(); return str.length > n ? str.slice(0, n) : str; }
--function _fmtMessages(rows, { includeChannel = false, now = 0 } = {}) {
-- const lines = [];
-- for (const r of rows) {
-- const who = r.username || (r.user_id ? `user#${r.user_id}` : 'anon');
-- let ctx = '';
-- if (includeChannel) {
-- if (r.is_global) ctx = '[global] ';
-- else if (r.channel_username) ctx = `[#${r.channel_username}] `;
-- else if (r.stream_id) ctx = `[stream] `;
-- }
-- const kind = (r.message_type && r.message_type !== 'chat') ? `(${r.message_type}) ` : '';
-- // "[Xm ago]" marker so the model can time-stamp notable moments accurately.
-- let when = '';
-- if (now) { const t = _parseSqlTime(r.timestamp || r.created_at); if (t) when = `[${Math.max(0, Math.round((now - t) / 60000))}m ago] `; }
-- lines.push(`${when}${ctx}${who}: ${kind}${_clip(r.message, MSG_MAX_CHARS)}`);
-- }
-- let out = lines.join('\n');
-- if (out.length > PROMPT_MSGS_MAX_CHARS) out = out.slice(out.length - PROMPT_MSGS_MAX_CHARS); // keep the freshest
-- return out;
++
++/** Messages as data for OpenVibe.AI's chat templates (WS-O task 2), which format them "[Xm ago] [#channel] name: text". */
++function _msgData(rows, { includeChannel = false, now = 0 } = {}) {
++ return rows.slice(-MAX_BATCH_MESSAGES).map((r) => {
++ let where = null;
++ if (includeChannel) where = r.is_global ? 'global' : r.channel_username ? `#${r.channel_username}` : r.stream_id ? 'stream' : null;
++ const t = now ? _parseSqlTime(r.timestamp || r.created_at) : 0;
++ return {
++ mins_ago: t ? Math.max(0, Math.round((now - t) / 60000)) : null, where: where ? String(where).slice(0, 80) : null,
++ author: String(r.username || (r.user_id ? `user#${r.user_id}` : 'anon')).slice(0, 80),
++ kind: r.message_type && r.message_type !== 'chat' ? String(r.message_type).slice(0, 40) : null, text: _clip(r.message, MSG_MAX_CHARS),
++ };
++ });
}
function _windowLabel(ms) {
@@@ -161,31 +146,13 @@ async function _refreshGlobal() {
const priorMemory = prior ? (prior.memory_json || '') : '';
let priorTl = [];
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
-- const recentLabels = priorTl.slice(-8).map(t => `- ${t.label}`).join('\n') || '(none yet)';
--
-- const prompt =
--`You are the community analyst for a live-streaming site's chat. Analyze the RECENT chat below (covers roughly the ${windowLabel}, across the global room and individual streamer channels) and update a rolling picture of the community.
--
--Return ONLY a JSON object, no prose, with exactly these keys:
--{
-- "recent_overview": "2-4 sentences on what the chat is about right now: main topics, mood/energy, who's active, any notable events. Concrete, no fluff.",
-- "memory": "Condensed running notes about this community carried forward: recurring topics, regulars, running jokes, ongoing events/dramas. Update the PRIOR notes with anything new; keep it under ~1400 chars.",
-- "timeline": [ {"label":"short title","detail":"one sentence","mins_ago": <integer: minutes before now this happened, read from the [Xm ago] markers on the messages>} ] // 0-3 genuinely NOTABLE moments from THIS window (raids, milestones, big drama, memorable bits). Use [] if nothing stands out — do not invent.
--}
--
--PRIOR running notes (may be empty):
--"""${_clip(priorMemory, MEMORY_MAX_CHARS)}"""
--
--Recent timeline entries already recorded (avoid duplicating these):
--${recentLabels}
--
--RECENT CHAT (${rows.length} messages, oldest first):
--${_fmtMessages(rows, { includeChannel: true, now })}`;
--
-- const text = await ai.summarizeText(prompt, 700, 'chat_global');
-- if (!text) return false; // disabled / over budget / call failed
-- const parsed = _parseJson(text);
-- if (!parsed) { console.warn('[ChatAI] global: unparseable model output'); return false; }
++ // The prompt is OpenVibe.AI's versioned template live.chat.global (WS-O task 2); Live sends the chat as data.
++ const parsed = await _aiService().structured('live.chat.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, { includeChannel: true, now }),
++ }, { meter: { kind: 'chat_global', role: 'legacy' } });
++ if (!parsed) return false; // disabled / over budget / call failed
const nowIso = _sqlTime(now);
// Persist the new moments to the growing timeline log (for the browsable/searchable view).
@@@ -223,27 +190,11 @@ async function _refreshUser(uid, maxId) {
const priorMemory = prior ? (prior.memory_json || '') : '';
const totalSeen = (prior ? (prior.message_count || 0) : 0);
-- const prompt =
--`You profile an individual chatter ("${uname}") on a live-streaming site, contrasting who they are TODAY vs OVERALL. Base everything only on the evidence below — do not invent.
--
--Return ONLY a JSON object, no prose, with exactly these keys:
--{
-- "overview_24h": "${has24h ? '2-3 sentences on what this user has been chatting about and their mood/energy in the LAST 24 HOURS.' : 'They have not chatted in the last 24h; write 1 sentence noting they have been quiet recently.'}",
-- "overview_alltime": "2-3 sentences on who this user is as a chatter OVERALL: their style, recurring interests, tone, how they interact.",
-- "memory": "Condensed running notes about this user carried forward (interests, catchphrases, who they talk to, patterns). Update the PRIOR notes; keep under ~1000 chars.",
-- "timeline": [ {"label":"short title","detail":"one sentence","mins_ago": <integer: minutes before now this happened, read from the [Xm ago] markers on the messages>} ] // 0-2 NEW notable moments for this user, or [].
--}
--
--PRIOR running notes (all-time gist so far${totalSeen ? `; ~${totalSeen} messages seen previously` : ''}):
--"""${_clip(priorMemory, USER_MEMORY_MAX_CHARS)}"""
--
--${has24h ? 'MESSAGES FROM THIS USER IN THE LAST 24H' : 'THIS USER\'S MOST RECENT MESSAGES'} (oldest first):
--${_fmtMessages(dayRows, { now })}`;
--
-- const text = await ai.summarizeText(prompt, 600, 'chat_user');
-- if (!text) return false;
-- const parsed = _parseJson(text);
-- if (!parsed) { console.warn(`[ChatAI] user ${uid}: unparseable model output`); return false; }
++ const parsed = await _aiService().structured('live.chat.profile', {
++ subject_kind: 'user', name: _clip(uname, 120), recent_24h: has24h, seen: totalSeen, prior_memory: _clip(priorMemory, USER_MEMORY_MAX_CHARS),
++ messages: _msgData(dayRows, { now }),
++ }, { meter: { kind: 'chat_user', role: 'legacy' } });
++ if (!parsed) return false;
const newCount = db.countChatMessagesSince(prior ? (prior.last_message_id || 0) : 0, uid);
const nowIso = _sqlTime(now);
@@@ -305,27 +256,11 @@ async function _refreshRelayUser(ru) {
const uname = ru.display_name || ru.username;
const priorMemory = prior ? (prior.memory_json || '') : '';
-- const prompt =
--`You profile an external chatter ("${uname}", bridged in from ${ru.platform}) on a live-streaming site, contrasting who they are TODAY vs OVERALL. Base everything only on the evidence below — do not invent.
--
--Return ONLY a JSON object, no prose, with exactly these keys:
--{
-- "overview_24h": "${has24h ? '2-3 sentences on what this user chatted about and their mood/energy in the LAST 24 HOURS.' : 'They have not chatted in the last 24h; 1 sentence noting they have been quiet recently.'}",
-- "overview_alltime": "2-3 sentences on who this user is as a chatter OVERALL: style, recurring interests, tone.",
-- "memory": "Condensed running notes carried forward (interests, catchphrases, patterns). Update the PRIOR notes; keep under ~1000 chars.",
-- "timeline": [ {"label":"short title","detail":"one sentence","mins_ago": <integer: minutes before now this happened, read from the [Xm ago] markers on the messages>} ] // 0-2 NEW notable moments, or [].
--}
--
--PRIOR running notes:
--"""${_clip(priorMemory, USER_MEMORY_MAX_CHARS)}"""
--
--${has24h ? 'MESSAGES FROM THIS USER IN THE LAST 24H' : "THIS USER'S MOST RECENT MESSAGES"} (oldest first):
--${_fmtMessages(dayRows, { now })}`;
--
-- const text = await ai.summarizeText(prompt, 600, 'chat_relay');
-- if (!text) return false;
-- const parsed = _parseJson(text);
-- if (!parsed) { console.warn(`[ChatAI] relay ${ru.id}: unparseable model output`); return false; }
++ const parsed = await _aiService().structured('live.chat.profile', {
++ subject_kind: 'relay', name: _clip(uname, 120), platform: _clip(ru.platform, 40), recent_24h: has24h, prior_memory: _clip(priorMemory, USER_MEMORY_MAX_CHARS),
++ messages: _msgData(dayRows, { now }),
++ }, { meter: { kind: 'chat_relay', role: 'legacy' } });
++ if (!parsed) return false;
const nowIso = _sqlTime(now);
await chatTables.write('upsertChatAiSummary', {
@@@ -376,27 +311,11 @@ async function _refreshAnon(anonId) {
if (!dayRows.length) return false;
const priorMemory = prior ? (prior.memory_json || '') : '';
-- const prompt =
--`You profile an ANONYMOUS chatter ("${anonId}", not logged in) on a live-streaming site, contrasting who they are TODAY vs OVERALL. Base everything only on the evidence below — do not invent.
--
--Return ONLY a JSON object, no prose, with exactly these keys:
--{
-- "overview_24h": "${has24h ? '2-3 sentences on what this anon chatted about and their mood/energy in the LAST 24 HOURS.' : 'They have not chatted in the last 24h; 1 sentence noting they have been quiet recently.'}",
-- "overview_alltime": "2-3 sentences on who this anon is as a chatter OVERALL: style, recurring interests, tone.",
-- "memory": "Condensed running notes carried forward (interests, catchphrases, patterns). Update the PRIOR notes; keep under ~1000 chars.",
-- "timeline": [ {"label":"short title","detail":"one sentence","mins_ago": <integer: minutes before now this happened, read from the [Xm ago] markers on the messages>} ] // 0-2 NEW notable moments, or [].
--}
--
--PRIOR running notes:
--"""${_clip(priorMemory, USER_MEMORY_MAX_CHARS)}"""
--
--${has24h ? 'MESSAGES FROM THIS ANON IN THE LAST 24H' : "THIS ANON'S MOST RECENT MESSAGES"} (oldest first):
--${_fmtMessages(dayRows, { now })}`;
--
-- const text = await ai.summarizeText(prompt, 600, 'chat_anon');
-- if (!text) return false;
-- const parsed = _parseJson(text);
-- if (!parsed) { console.warn(`[ChatAI] anon ${anonId}: unparseable model output`); return false; }
++ const parsed = await _aiService().structured('live.chat.profile', {
++ subject_kind: 'anon', name: _clip(anonId, 120), recent_24h: has24h, prior_memory: _clip(priorMemory, USER_MEMORY_MAX_CHARS),
++ messages: _msgData(dayRows, { now }),
++ }, { meter: { kind: 'chat_anon', role: 'legacy' } });
++ if (!parsed) return false;
const nowIso = _sqlTime(now);
await chatTables.write('upsertChatAiSummary', {
@@@ -554,4 +473,4 @@ function getAnonInsight(anonId) {
};
}
--module.exports = { start, stop, getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight, _tick };
++module.exports = { start, stop, getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight, _tick, _msgData };
ddiff --git a/test/ai-templates-jobs.test.js b/test/ai-templates-jobs.test.js
iindex c0e1dae2..644609c6 100644
---- a/test/ai-templates-jobs.test.js
++++ b/test/ai-templates-jobs.test.js
@@@ -86,6 +86,19 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
const pool = JSON.parse(db.getState('home_hero_slogans'));
assert.ok(pool.audiences.includes('crouton fans 0') && !pool.audiences.some((a) => /free/.test(a)), 'the no-free rule still applies');
assert.ok(pool.audiences.includes('coders'), '"live streaming for" is stripped');
++ // Chat messages go to live.chat.global / live.chat.profile as data, in the shape AI formats.
++ const now = Date.parse('2026-09-28T01:00:00Z');
++ const data = require('../server/ai/chat-ai')._msgData([
++ { username: 'goosely', is_global: 1, message: ' croutons ', timestamp: '2026-09-28 00:48:00' },
++ { user_id: 5, channel_username: 'ann', message_type: 'emote', message: 'waves', timestamp: '2026-09-28 00:57:00' },
++ { message: 'hi', stream_id: 3 },
++ ], { includeChannel: true, now });
++ assert.deepStrictEqual(data, [
++ { mins_ago: 12, where: 'global', author: 'goosely', kind: null, text: 'croutons' },
++ { mins_ago: 3, where: '#ann', author: 'user#5', kind: 'emote', text: 'waves' },
++ { mins_ago: null, where: 'stream', author: 'anon', kind: null, text: 'hi' },
++ ]);
++ assert.strictEqual(require('../server/ai/chat-ai')._msgData([{ username: 'x', is_global: 1, message: 'y' }]).pop().where, null, 'no channel tag in a profile');
} finally {
network.close(); ai.close();
fs.rmSync(tmp, { recursive: true, force: true });