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.
Changes
@@@ -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 []; }@@@ -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++; }}@@@ -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 failedconst 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 };@@@ -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 });
@@ -66,22 +66,14 @@ function _titleFromDesc(desc) {
@@ -66,22 +66,14 @@ function _titleFromDesc(desc) {
// ── Stage 1: rank VODs by their AI overview ─────────────────────────────────────────────
// ── Stage 1: rank VODs by their AI overview ─────────────────────────────────────────────
// Scores one chunk of VODs via a single LLM call; returns [{vod, score, why}] best-first.
// Scores one chunk of VODs via a single LLM call; returns [{vod, score, why}] best-first.
async function _scoreVodChunk(chunk, want) {
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 {
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();
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) }))
.map(x => ({ vod: chunk[x.index], score: Number(x.score) || 0, why: _cleanText(x.why, 60) }))
.sort((a, b) => b.score - a.score);
.sort((a, b) => b.score - a.score);
} catch { return []; }
} catch { return []; }
@@ -131,9 +131,9 @@ function _combinedOverview(userId, streamerOv, chatIns) {
@@ -131,9 +131,9 @@ function _combinedOverview(userId, streamerOv, chatIns) {
const ai = require('./ai-analysis');
const ai = require('./ai-analysis');
if (!_combinedBusy.has(userId) && ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
if (!_combinedBusy.has(userId) && ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
_combinedBusy.add(userId);
_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(() => { })
.catch(() => { })
.finally(() => _combinedBusy.delete(userId));
.finally(() => _combinedBusy.delete(userId));
}
}
@@ -151,14 +151,13 @@ function _ensureSessionTitles(userId) {
@@ -151,14 +151,13 @@ function _ensureSessionTitles(userId) {
const pending = db.getUntitledAiSessions(userId, 20);
const pending = db.getUntitledAiSessions(userId, 20);
if (!pending.length) return;
if (!pending.length) return;
_titlingBusy.add(userId);
_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;
let touched = 0;
- for (const x of JSON.parse(m[0])) {
+ for (const x of out.titles) {
const p = pending[x.index];
const p = pending[x.index];
if (p && x.title) { db.setStreamAiTitle(p.id, String(x.title).replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80)); touched++; }
if (p && x.title) { db.setStreamAiTitle(p.id, String(x.title).replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80)); touched++; }
}
}
@@ -45,7 +45,6 @@ const MEMORY_MAX_CHARS = 1600;
@@ -45,7 +45,6 @@ const MEMORY_MAX_CHARS = 1600;
const USER_MEMORY_MAX_CHARS = 1200;
const USER_MEMORY_MAX_CHARS = 1200;
const TIMELINE_MAX = 40;
const TIMELINE_MAX = 40;
const MSG_MAX_CHARS = 220;
const MSG_MAX_CHARS = 220;
-const PROMPT_MSGS_MAX_CHARS = 9000;
let _running = false;
let _running = false;
let _timer = null;
let _timer = null;
@@ -54,42 +53,28 @@ let _lastUserPass = 0;
@@ -54,42 +53,28 @@ let _lastUserPass = 0;
// ── Small utils ──────────────────────────────────────────────────────────────
// ── Small utils ──────────────────────────────────────────────────────────────
function _ai() { return require('./ai-analysis'); }
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.
// 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 _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 _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 _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) {
function _windowLabel(ms) {
@@ -161,31 +146,13 @@ async function _refreshGlobal() {
@@ -161,31 +146,13 @@ async function _refreshGlobal() {
const priorMemory = prior ? (prior.memory_json || '') : '';
const priorMemory = prior ? (prior.memory_json || '') : '';
let priorTl = [];
let priorTl = [];
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { 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);
const nowIso = _sqlTime(now);
// Persist the new moments to the growing timeline log (for the browsable/searchable view).
// Persist the new moments to the growing timeline log (for the browsable/searchable view).
@@ -223,27 +190,11 @@ async function _refreshUser(uid, maxId) {
@@ -223,27 +190,11 @@ async function _refreshUser(uid, maxId) {
const priorMemory = prior ? (prior.memory_json || '') : '';
const priorMemory = prior ? (prior.memory_json || '') : '';
const totalSeen = (prior ? (prior.message_count || 0) : 0);
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 newCount = db.countChatMessagesSince(prior ? (prior.last_message_id || 0) : 0, uid);
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
@@ -305,27 +256,11 @@ async function _refreshRelayUser(ru) {
@@ -305,27 +256,11 @@ async function _refreshRelayUser(ru) {
const uname = ru.display_name || ru.username;
const uname = ru.display_name || ru.username;
const priorMemory = prior ? (prior.memory_json || '') : '';
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);
const nowIso = _sqlTime(now);
await chatTables.write('upsertChatAiSummary', {
await chatTables.write('upsertChatAiSummary', {
@@ -376,27 +311,11 @@ async function _refreshAnon(anonId) {
@@ -376,27 +311,11 @@ async function _refreshAnon(anonId) {
if (!dayRows.length) return false;
if (!dayRows.length) return false;
const priorMemory = prior ? (prior.memory_json || '') : '';
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);
const nowIso = _sqlTime(now);
await chatTables.write('upsertChatAiSummary', {
await chatTables.write('upsertChatAiSummary', {
@@ -554,4 +473,4 @@ function getAnonInsight(anonId) {
@@ -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 };
@@ -86,6 +86,19 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
@@ -86,6 +86,19 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
const pool = JSON.parse(db.getState('home_hero_slogans'));
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('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');
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 {
} finally {
network.close(); ai.close();
network.close(); ai.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });