openvibers/openvibe.ai Commits
Chat analysis, person overviews, session titles and VOD ranking are versioned templates (roadmap WS-O task 2). Live's prompts from chat-ai.js, chat-ai-routes.js and ai-moments-job.js become five structured workflows. live.chat.global takes the window, prior notes, moments already recorded and the recent messages. live.chat.profile takes a signed-in, bridged or anonymous chatter, one template with the subject's wording chosen by subject_kind. live.person.overview fuses a streamer and a chatter summary. live.stream.titles gives a title per session summary. live.moments.rank (version 2) takes VODs with overviews and stats. Messages arrive as data (minutes ago, where, author, kind, text) and are formatted as Live formatted them, keeping the freshest 9000 characters. Every answer is JSON with every key its schema asks for; titles and ranks keep only indexes that exist. live.chat.insight stays a passthrough until Live's previous release is gone. test/live-templates.test.js checks the rendering; workflows.test runs all five end to end. npm test passes.
Changes
@@@ -75,7 +75,48 @@ const MOMENT_FLAVOR = {};const oneLine = (t, max) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, max);++/** Chat messages as Live formatted them: "[Xm ago] [#channel] author: (kind) text", the freshest 9000 characters. */++function chatLines(messages, withWhere) {++ const out = (messages || []).map((m) => {++ const when = Number.isInteger(m.mins_ago) ? `[${m.mins_ago}m ago] ` : '';++ const where = withWhere && m.where ? `[${m.where}] ` : '';++ const kind = m.kind && m.kind !== 'chat' ? `(${m.kind}) ` : '';++ return `${when}${where}${m.author}: ${kind}${String(m.text || '').trim().slice(0, 220)}`;++ }).join('\n');++ return out.length > 9000 ? out.slice(out.length - 9000) : out;++}++const PREPARE = {++ // Chat analysis (chat-ai.js): messages formatted as Live formatted them, freshest 9000 characters kept.++ 'live.chat.global'(input) {++ return { vars: {++ window_label: input.window_label || 'past hour', prior_memory: String(input.prior_memory || '').slice(0, 1600),++ recent_labels: (input.recent_labels || []).slice(-8).map((l) => `- ${l}`).join('\n') || '(none yet)',++ count: (input.messages || []).length, messages: chatLines(input.messages, true),++ } };++ },++ 'live.chat.profile'(input) {++ const name = String(input.name || '').slice(0, 120);++ const subject = input.subject_kind === 'relay' ? `an external chatter ("${name}", bridged in from ${input.platform || 'another platform'})`++ : input.subject_kind === 'anon' ? `an ANONYMOUS chatter ("${name}", not logged in)` : `an individual chatter ("${name}")`;++ const noun = input.subject_kind === 'anon' ? 'this anon' : 'this user';++ const header = input.recent_24h ? `MESSAGES FROM ${noun.toUpperCase()} IN THE LAST 24H` : `${noun.toUpperCase()}'S MOST RECENT MESSAGES`;++ return { vars: {++ subject, noun, recent: input.recent_24h === true, seen: input.subject_kind === 'user' && input.seen ? input.seen : 0,++ prior_memory: String(input.prior_memory || '').slice(0, 1200), messages_header: header, messages: chatLines(input.messages, false),++ } };++ },++ 'live.person.overview'(input) {++ return { vars: { as_streamer: String(input.as_streamer), as_chatter: String(input.as_chatter) } };++ },++ 'live.stream.titles'(input) {++ return { vars: { list: input.summaries.map((t, i) => `${i}. ${String(t).replace(/\s+/g, ' ').slice(0, 200)}`).join('\n') } };++ },++ 'live.moments.rank'(input) {++ const vods = input.vods || [];++ const list = vods.map((v, i) => `${i}. [${v.views || 0} views · ${v.clips || 0} clips · peak ${v.peak_viewers || 0}] "${String(v.title || '').replace(/\s+/g, ' ').trim().slice(0, 70)}" — ${String(v.overview || '').replace(/\s+/g, ' ').trim().slice(0, 260) || '(no summary)'}`).join('\n');++ return { vars: { list, top: Math.min(vods.length, Math.max(input.want || 6, 6)) } };++ },'live.hero.slogans'(input) {const block = (list, head, total) => (list || []).map((x) => `- ${String(x.name || '').slice(0, head)}: ${String(x.text || '').replace(/\s+/g, ' ').slice(0, 180)}`).join('\n').slice(0, total);return { vars: {@@@ -215,6 +256,39 @@ const PREPARE = {};const POSTPROCESS = {++ // Global picture or one chatter's profile (the profile's input names its subject_kind); every key the schema asks for.++ 'live.chat.insight'(r, input) {++ const j = r.json || parseJsonLoose(r.text);++ if (!j || typeof j !== 'object' || Array.isArray(j)) return null;++ const keys = input.subject_kind ? ['overview_24h', 'overview_alltime', 'memory'] : ['recent_overview', 'memory'];++ if (!keys.some((k) => typeof j[k] === 'string' && j[k].trim())) return null;++ const out = {};++ for (const k of keys) out[k] = typeof j[k] === 'string' ? j[k].slice(0, 4000) : '';++ out.timeline = (Array.isArray(j.timeline) ? j.timeline : []).filter((t) => t && t.label).slice(0, 5)++ .map((t) => ({ label: String(t.label).slice(0, 120), detail: String(t.detail || '').slice(0, 400), mins_ago: Math.max(0, Math.round(Number(t.mins_ago) || 0)) }));++ return out;++ },++ 'live.person.overview'(r) {++ const t = (r.json && typeof r.json.overview === 'string' ? r.json.overview : r.text || '').replace(/^\s*(combined\s+overview|overview)\s*[:\-–]\s*/i, '').trim();++ return t ? { overview: t.slice(0, 2000) } : null;++ },++ 'live.stream.titles'(r, input) {++ const j = r.json || parseJsonLoose(r.text);++ const arr = Array.isArray(j) ? j : j && Array.isArray(j.titles) ? j.titles : null;++ if (!arr) return null;++ const titles = arr.filter((x) => x && Number.isInteger(Number(x.index)) && Number(x.index) >= 0 && Number(x.index) < input.summaries.length && x.title)++ .map((x) => ({ index: Number(x.index), title: String(x.title).replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80) }));++ return { titles };++ },++ 'live.moments.rank'(r, input) {++ const j = r.json || parseJsonLoose(r.text);++ const arr = Array.isArray(j) ? j : j && Array.isArray(j.ranked) ? j.ranked : null;++ if (!arr) return null;++ const seen = new Set();++ const ranked = arr.filter((x) => x && Number.isInteger(Number(x.index)) && input.vods[Number(x.index)] != null && !seen.has(Number(x.index)) && seen.add(Number(x.index)))++ .map((x) => ({ index: Number(x.index), score: Math.max(0, Math.min(100, Math.round(Number(x.score) || 0))), why: String(x.why || '').replace(/\s+/g, ' ').trim().slice(0, 60) }));++ return { ranked };++ },'live.hero.slogans'(r) {const j = r.json || parseJsonLoose(r.text);const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').map((x) => x.slice(0, 200)).slice(0, 60) : []);@@@ -79,6 +79,51 @@ const templates = [user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n{{parts}}',default_route: 'live.legacy', owner: 'live',},++ {++ key: 'live.chat.global', name: "Live: the global chat picture",++ description: "From chat-ai.js (chat_global): what the chat is about now, the running community notes, and 0-3 notable moments.",++ input_schema: { type: 'object' },++ output_schema: {"type": "object", "additionalProperties": false, "required": ["recent_overview", "memory", "timeline"], "properties": {"recent_overview": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},++ system_prompt: "",++ user_prompt: "You are the community analyst for a live-streaming site's chat. Analyze the RECENT chat below (covers roughly the {{window_label}}, across the global room and individual streamer channels) and update a rolling picture of the community.\n\nReturn ONLY a JSON object, no prose, with exactly these keys:\n{\n \"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.\",\n \"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.\",\n \"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.\n}\n\nPRIOR running notes (may be empty):\n\"\"\"{{prior_memory}}\"\"\"\n\nRecent timeline entries already recorded (avoid duplicating these):\n{{recent_labels}}\n\nRECENT CHAT ({{count}} messages, oldest first):\n{{messages}}",++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'chat_global' },++ },++ {++ key: 'live.chat.profile', name: "Live: one chatter today vs overall",++ description: "From chat-ai.js (chat_user, chat_relay, chat_anon): a signed-in, bridged or anonymous chatter.",++ input_schema: { type: 'object' },++ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview_24h", "overview_alltime", "memory", "timeline"], "properties": {"overview_24h": {"type": "string"}, "overview_alltime": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},++ system_prompt: "",++ user_prompt: "You profile {{subject}} on a live-streaming site, contrasting who they are TODAY vs OVERALL. Base everything only on the evidence below — do not invent.\n\nReturn ONLY a JSON object, no prose, with exactly these keys:\n{\n \"overview_24h\": \"{{#recent}}2-3 sentences on what {{noun}} has been chatting about and their mood/energy in the LAST 24 HOURS.{{/recent}}{{^recent}}They have not chatted in the last 24h; write 1 sentence noting they have been quiet recently.{{/recent}}\",\n \"overview_alltime\": \"2-3 sentences on who {{noun}} is as a chatter OVERALL: their style, recurring interests, tone, how they interact.\",\n \"memory\": \"Condensed running notes about {{noun}} carried forward (interests, catchphrases, who they talk to, patterns). Update the PRIOR notes; keep under ~1000 chars.\",\n \"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 {{noun}}, or [].\n}\n\nPRIOR running notes (all-time gist so far{{#seen}}; ~{{seen}} messages seen previously{{/seen}}):\n\"\"\"{{prior_memory}}\"\"\"\n\n{{messages_header}} (oldest first):\n{{messages}}",++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'chat_profile' },++ },++ {++ key: 'live.person.overview', name: "Live: a person as streamer and chatter",++ description: "From chat-ai-routes.js (combined_overview): two summaries fused into one.",++ input_schema: { type: 'object' },++ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},++ system_prompt: "",++ user_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{{as_streamer}}\n\nAS A CHATTER:\n{{as_chatter}}",++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'person_overview' },++ },++ {++ key: 'live.stream.titles', name: "Live: short titles for past sessions",++ description: "From chat-ai-routes.js (session_titles): a 3-6 word title per session summary.",++ input_schema: { type: 'object' },++ output_schema: {"type": "object", "additionalProperties": false, "required": ["titles"], "properties": {"titles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer"}, "title": {"type": "string"}}}}}},++ system_prompt: "",++ user_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: {\"titles\": [{\"index\": <number>, \"title\": \"<3-6 word title>\"}]} with every item.",++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'session_titles' },++ },++ {++ key: 'live.moments.rank', name: "Live: rank VODs for the highlights showcase",++ description: "From ai-moments-job.js (moment_vod_rank).",++ input_schema: { type: 'object' },++ output_schema: {"type": "object", "additionalProperties": false, "required": ["ranked"], "properties": {"ranked": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "score", "why"], "properties": {"index": {"type": "integer"}, "score": {"type": "integer"}, "why": {"type": "string"}}}}}},++ system_prompt: "",++ user_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.\n\n{{list}}\n\nReturn STRICT JSON only, nothing else: {\"ranked\": [{\"index\": <n>, \"score\": <1-100>, \"why\": \"<3-8 words>\"}]} for the top {{top}}, best first.",++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_vod_rank' },++ },{key: 'live.hero.slogans', name: 'Live: home hero audiences and quips',description: 'From slogan-job.js (hero_slogans): the rotating "Live streaming for …" audiences and one-liners, from the community\'s real chat, streamers and VODs.',@@@ -236,6 +281,46 @@ const structured = [steps: [{ kind: 'llm', template: 'live.media.overview', operation: 'summarize', output: 'text', prepare: 'live.media.overview', postprocess: 'live.media.overview', params: { max_tokens: 400 } }],cache_mode: 'private',},++ {++ key: 'live.chat.global', name: "The global chat picture", namespace: 'live',++ description: "The prompt Live used to render (chat-ai.js), now a versioned template: the window, the prior notes, moments already recorded and the recent messages in.",++ input_schema: TEXT_OUT({ window_label: STR(40), prior_memory: STR(4000), recent_labels: { type: 'array', maxItems: 20, items: STR(200) }, messages: { type: 'array', maxItems: 300, items: { type: 'object', required: ['author', 'text'], additionalProperties: false, properties: { mins_ago: { type: ['integer', 'null'], minimum: 0 }, where: { type: ['string', 'null'], maxLength: 80 }, author: STR(80), kind: { type: ['string', 'null'], maxLength: 40 }, text: STR(1000) } } } }, ['messages']),++ output_schema: {"type": "object", "additionalProperties": false, "required": ["recent_overview", "memory", "timeline"], "properties": {"recent_overview": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},++ steps: [{ kind: 'llm', template: 'live.chat.global', operation: 'generate', output: 'json', prepare: 'live.chat.global', postprocess: 'live.chat.insight', params: { max_tokens: 700 } }],++ cache_mode: 'none',++ },++ {++ key: 'live.chat.profile', name: "One chatter today vs overall", namespace: 'live',++ description: "The prompt Live used to render for signed-in, bridged and anonymous chatters (chat-ai.js), now one versioned template.",++ input_schema: TEXT_OUT({ subject_kind: { enum: ['user', 'relay', 'anon'] }, name: STR(120), platform: STR(40), recent_24h: { type: 'boolean' }, seen: { type: 'integer', minimum: 0 }, prior_memory: STR(4000), messages: { type: 'array', maxItems: 300, items: { type: 'object', required: ['author', 'text'], additionalProperties: false, properties: { mins_ago: { type: ['integer', 'null'], minimum: 0 }, where: { type: ['string', 'null'], maxLength: 80 }, author: STR(80), kind: { type: ['string', 'null'], maxLength: 40 }, text: STR(1000) } } } }, ['subject_kind', 'name', 'messages']),++ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview_24h", "overview_alltime", "memory", "timeline"], "properties": {"overview_24h": {"type": "string"}, "overview_alltime": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},++ steps: [{ kind: 'llm', template: 'live.chat.profile', operation: 'generate', output: 'json', prepare: 'live.chat.profile', postprocess: 'live.chat.insight', params: { max_tokens: 600 } }],++ cache_mode: 'none',++ },++ {++ key: 'live.person.overview', name: "A person as streamer and chatter", namespace: 'live',++ description: "The prompt Live used to render (chat-ai-routes.js combined_overview).",++ input_schema: TEXT_OUT({ as_streamer: { type: 'string', minLength: 1, maxLength: 4000 }, as_chatter: { type: 'string', minLength: 1, maxLength: 4000 } }, ['as_streamer', 'as_chatter']),++ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},++ steps: [{ kind: 'llm', template: 'live.person.overview', operation: 'generate', output: 'json', prepare: 'live.person.overview', postprocess: 'live.person.overview', params: { max_tokens: 320 } }],++ cache_mode: 'none',++ },++ {++ key: 'live.stream.titles', name: "Short titles for past sessions", namespace: 'live',++ description: "The prompt Live used to render (chat-ai-routes.js session_titles): session summaries in, a title per index out.",++ input_schema: TEXT_OUT({ summaries: { type: 'array', minItems: 1, maxItems: 20, items: STR(1000) } }, ['summaries']),++ output_schema: {"type": "object", "additionalProperties": false, "required": ["titles"], "properties": {"titles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer"}, "title": {"type": "string"}}}}}},++ steps: [{ kind: 'llm', template: 'live.stream.titles', operation: 'generate', output: 'json', prepare: 'live.stream.titles', postprocess: 'live.stream.titles', params: { max_tokens: 700 } }],++ cache_mode: 'none',++ },++ {++ key: 'live.moments.rank', name: "Rank VODs for the highlights showcase", namespace: 'live',++ description: "The prompt Live used to render (ai-moments-job.js): VODs with their summaries and stats in, the top ones scored out.",++ input_schema: TEXT_OUT({ vods: { type: 'array', minItems: 1, maxItems: 25, items: { type: 'object', required: ['title'], additionalProperties: false, properties: { title: STR(200), overview: STR(1000), views: { type: 'integer', minimum: 0 }, clips: { type: 'integer', minimum: 0 }, peak_viewers: { type: 'integer', minimum: 0 } } } }, want: { type: 'integer', minimum: 1, maximum: 25 } }, ['vods']),++ output_schema: {"type": "object", "additionalProperties": false, "required": ["ranked"], "properties": {"ranked": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "score", "why"], "properties": {"index": {"type": "integer"}, "score": {"type": "integer"}, "why": {"type": "string"}}}}}},++ steps: [{ kind: 'llm', template: 'live.moments.rank', operation: 'generate', output: 'json', prepare: 'live.moments.rank', postprocess: 'live.moments.rank', params: { max_tokens: 700 } }],++ cache_mode: 'none',++ },{key: 'live.hero.slogans', name: 'Home hero audiences and quips', namespace: 'live',description: 'The prompt Live used to render (slogan-job.js), now a versioned template: the chat vibe, per-user notes, streamers, recent VODs and active usernames in; audiences and quips out. Live keeps its cleaning rules (no "free", no "streaming for").',@@@ -361,7 +446,6 @@ const PASSTHROUGH = [['live.viewers.line', 'AI viewers: single line', 'viewers/budget.js generate() — ai_viewers (chat reply).'],['live.viewers.fold', 'AI viewers: memory fold', 'viewers/fold.js — ai_viewers_fold.'],['live.chat.insight', 'Chat insights and session overviews', 'chat-ai.js and chat-ai-routes.js — chat_global, chat_user, chat_relay, chat_anon, combined_overview, session_titles.'],-- ['live.moments.rank', 'Moments: rank VOD moments', 'ai-moments-job.js — moment_vod_rank.'],['live.clips.confirm', 'Auto-clip confirmation', 'auto-clip-job.js — auto_clip_confirm.'],['live.arena.persona', 'Arena: persona', 'arena-service.js — arena_persona.'],['live.arena.quotes', 'Arena: quotes', 'arena-service.js — arena_quotes.'],@@@ -71,4 +71,35 @@ t.test('slogans, the daily secret and the star render as Live rendered them', ()assert.strictEqual(POSTPROCESS['live.home.star']({ json: { headline: 'x' } }), null);});++t.test('chat analysis, person overviews, session titles and VOD ranking render as Live rendered them', () => {++ const tp = (k) => live.templates.find((x) => x.key === k).user_prompt;++ const g = render(tp('live.chat.global'), PREPARE['live.chat.global']({ window_label: 'past 3 hours', prior_memory: 'notes', recent_labels: ['a', 'b'], messages: [{ mins_ago: 12, where: 'global', author: 'goosely', text: ' hi ' }, { mins_ago: 3, where: '#ann', author: 'bob', kind: 'emote', text: 'waves' }] }).vars);++ assert.match(g, /covers roughly the past 3 hours, across/);++ assert.match(g, /"""notes"""/);++ assert.match(g, /avoid duplicating these\):\n- a\n- b\n/);++ assert.match(g, /RECENT CHAT \(2 messages, oldest first\):\n\[12m ago\] \[global\] goosely: hi\n\[3m ago\] \[#ann\] bob: \(emote\) waves$/);++ const p = (x) => render(tp('live.chat.profile'), PREPARE['live.chat.profile'](x).vars);++ const user = p({ subject_kind: 'user', name: 'goosely', recent_24h: true, seen: 42, messages: [{ mins_ago: 1, where: 'global', author: 'goosely', text: 'x' }] });++ assert.match(user, /^You profile an individual chatter \("goosely"\)/);++ assert.match(user, /"overview_24h": "2-3 sentences on what this user has been chatting about/);++ assert.match(user, /all-time gist so far; ~42 messages seen previously\)/);++ assert.match(user, /MESSAGES FROM THIS USER IN THE LAST 24H \(oldest first\):\n\[1m ago\] goosely: x$/, 'no channel tag in a profile');++ const anon = p({ subject_kind: 'anon', name: 'anon_7', recent_24h: false, seen: 9, messages: [{ author: 'anon_7', text: 'y' }] });++ assert.match(anon, /an ANONYMOUS chatter \("anon_7", not logged in\)/);++ assert.match(anon, /They have not chatted in the last 24h; write 1 sentence/);++ assert.match(anon, /THIS ANON'S MOST RECENT MESSAGES \(oldest first\):\nanon_7: y$/);++ assert.ok(!/messages seen previously/.test(anon));++ assert.match(p({ subject_kind: 'relay', name: 'KickFan', platform: 'kick', messages: [{ author: 'KickFan', text: 'z' }] }), /an external chatter \("KickFan", bridged in from kick\)/);++ const long = PREPARE['live.chat.global']({ messages: Array.from({ length: 300 }, (_, i) => ({ mins_ago: 300 - i, author: 'u', text: 'x'.repeat(200) + i })) }).vars.messages;++ assert.ok(long.length === 9000 && long.endsWith('x299'), 'the freshest 9000 characters');++ assert.match(render(tp('live.person.overview'), { as_streamer: 'S', as_chatter: 'C' }), /AS A STREAMER:\nS\n\nAS A CHATTER:\nC$/);++ assert.match(render(tp('live.stream.titles'), PREPARE['live.stream.titles']({ summaries: ['one two', 'three'] }).vars), /\n\n0\. one two\n1\. three\n\n/);++ const rank = render(tp('live.moments.rank'), PREPARE['live.moments.rank']({ vods: [{ title: 'Rust', overview: '', views: 3, clips: 1, peak_viewers: 2 }], want: 8 }).vars);++ assert.match(rank, /\n\n0\. \[3 views · 1 clips · peak 2\] "Rust" — \(no summary\)\n\n/);++ assert.match(rank, /for the top 1, best first\.$/);++ assert.deepStrictEqual(POSTPROCESS['live.moments.rank']({ text: '[{"index": 0, "score": 140, "why": "x"}, {"index": 0, "score": 3}, {"index": 9}]' }, { vods: [{}] }).ranked, [{ index: 0, score: 100, why: 'x' }]);++ assert.deepStrictEqual(POSTPROCESS['live.chat.insight']({ json: { overview_24h: 'a', timeline: [{ label: 'L', mins_ago: '5' }] } }, { subject_kind: 'user' }), { overview_24h: 'a', overview_alltime: '', memory: '', timeline: [{ label: 'L', detail: '', mins_ago: 5 }] });++ assert.strictEqual(POSTPROCESS['live.person.overview']({ text: 'Overview: A coder.' }).overview, 'A coder.');++});++t.run();@@@ -48,6 +48,11 @@ const EXAMPLES = {};for (const k of new Set(Object.values(KIND_TO_WORKFLOW).concat('live.complete'))) EXAMPLES[k] = PASS;// Moved from passthrough to a versioned template (WS-O task 2): structured input.++EXAMPLES['live.chat.global'] = { window_label: 'past 3 hours', prior_memory: 'croutons are a running joke', recent_labels: ['the crouton war'], messages: [{ mins_ago: 12, where: 'global', author: 'goosely', kind: null, text: 'croutons are soup confetti' }, { mins_ago: 3, where: '#ann', author: 'bob', kind: 'emote', text: 'waves' }] };++EXAMPLES['live.chat.profile'] = { subject_kind: 'relay', name: 'KickFan', platform: 'kick', recent_24h: false, seen: 0, prior_memory: '', messages: [{ mins_ago: 2000, author: 'KickFan', text: 'hello from kick' }] };++EXAMPLES['live.person.overview'] = { as_streamer: 'Codes rust late at night.', as_chatter: 'Jokes about croutons.' };++EXAMPLES['live.stream.titles'] = { summaries: ['Fixed a borrow checker bug live', 'Built a robot arm and it waved'] };++EXAMPLES['live.moments.rank'] = { vods: [{ title: 'Rust night', overview: 'the build finally compiles', views: 30, clips: 2, peak_viewers: 5 }, { title: 'Chill', overview: 'sitting at a desk', views: 3, clips: 0, peak_viewers: 1 }], want: 2 };EXAMPLES['live.hero.slogans'] = { global: 'Chat argued about croutons all day.', users: [{ name: 'goosely', text: 'always defends croutons' }], streamers: [{ name: 'ann', text: 'codes rust at night' }], vods: [{ name: 'Rust night', text: 'the build finally compiles' }], usernames: ['goosely', 'ann'], count: 20 };EXAMPLES['live.easter_egg'] = { vibe: 'croutons' };EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };
@@ -75,7 +75,48 @@ const MOMENT_FLAVOR = {
@@ -75,7 +75,48 @@ const MOMENT_FLAVOR = {
};
};
const oneLine = (t, max) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, max);
const oneLine = (t, max) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, max);
+/** Chat messages as Live formatted them: "[Xm ago] [#channel] author: (kind) text", the freshest 9000 characters. */
+function chatLines(messages, withWhere) {
+ const out = (messages || []).map((m) => {
+ const when = Number.isInteger(m.mins_ago) ? `[${m.mins_ago}m ago] ` : '';
+ const where = withWhere && m.where ? `[${m.where}] ` : '';
+ const kind = m.kind && m.kind !== 'chat' ? `(${m.kind}) ` : '';
+ return `${when}${where}${m.author}: ${kind}${String(m.text || '').trim().slice(0, 220)}`;
+ }).join('\n');
+ return out.length > 9000 ? out.slice(out.length - 9000) : out;
+}
+
const PREPARE = {
const PREPARE = {
+ // Chat analysis (chat-ai.js): messages formatted as Live formatted them, freshest 9000 characters kept.
+ 'live.chat.global'(input) {
+ return { vars: {
+ window_label: input.window_label || 'past hour', prior_memory: String(input.prior_memory || '').slice(0, 1600),
+ recent_labels: (input.recent_labels || []).slice(-8).map((l) => `- ${l}`).join('\n') || '(none yet)',
+ count: (input.messages || []).length, messages: chatLines(input.messages, true),
+ } };
+ },
+ 'live.chat.profile'(input) {
+ const name = String(input.name || '').slice(0, 120);
+ const subject = input.subject_kind === 'relay' ? `an external chatter ("${name}", bridged in from ${input.platform || 'another platform'})`
+ : input.subject_kind === 'anon' ? `an ANONYMOUS chatter ("${name}", not logged in)` : `an individual chatter ("${name}")`;
+ const noun = input.subject_kind === 'anon' ? 'this anon' : 'this user';
+ const header = input.recent_24h ? `MESSAGES FROM ${noun.toUpperCase()} IN THE LAST 24H` : `${noun.toUpperCase()}'S MOST RECENT MESSAGES`;
+ return { vars: {
+ subject, noun, recent: input.recent_24h === true, seen: input.subject_kind === 'user' && input.seen ? input.seen : 0,
+ prior_memory: String(input.prior_memory || '').slice(0, 1200), messages_header: header, messages: chatLines(input.messages, false),
+ } };
+ },
+ 'live.person.overview'(input) {
+ return { vars: { as_streamer: String(input.as_streamer), as_chatter: String(input.as_chatter) } };
+ },
+ 'live.stream.titles'(input) {
+ return { vars: { list: input.summaries.map((t, i) => `${i}. ${String(t).replace(/\s+/g, ' ').slice(0, 200)}`).join('\n') } };
+ },
+ 'live.moments.rank'(input) {
+ const vods = input.vods || [];
+ const list = vods.map((v, i) => `${i}. [${v.views || 0} views · ${v.clips || 0} clips · peak ${v.peak_viewers || 0}] "${String(v.title || '').replace(/\s+/g, ' ').trim().slice(0, 70)}" — ${String(v.overview || '').replace(/\s+/g, ' ').trim().slice(0, 260) || '(no summary)'}`).join('\n');
+ return { vars: { list, top: Math.min(vods.length, Math.max(input.want || 6, 6)) } };
+ },
'live.hero.slogans'(input) {
'live.hero.slogans'(input) {
const block = (list, head, total) => (list || []).map((x) => `- ${String(x.name || '').slice(0, head)}: ${String(x.text || '').replace(/\s+/g, ' ').slice(0, 180)}`).join('\n').slice(0, total);
const block = (list, head, total) => (list || []).map((x) => `- ${String(x.name || '').slice(0, head)}: ${String(x.text || '').replace(/\s+/g, ' ').slice(0, 180)}`).join('\n').slice(0, total);
return { vars: {
return { vars: {
@@ -215,6 +256,39 @@ const PREPARE = {
@@ -215,6 +256,39 @@ const PREPARE = {
};
};
const POSTPROCESS = {
const POSTPROCESS = {
+ // Global picture or one chatter's profile (the profile's input names its subject_kind); every key the schema asks for.
+ 'live.chat.insight'(r, input) {
+ const j = r.json || parseJsonLoose(r.text);
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return null;
+ const keys = input.subject_kind ? ['overview_24h', 'overview_alltime', 'memory'] : ['recent_overview', 'memory'];
+ if (!keys.some((k) => typeof j[k] === 'string' && j[k].trim())) return null;
+ const out = {};
+ for (const k of keys) out[k] = typeof j[k] === 'string' ? j[k].slice(0, 4000) : '';
+ out.timeline = (Array.isArray(j.timeline) ? j.timeline : []).filter((t) => t && t.label).slice(0, 5)
+ .map((t) => ({ label: String(t.label).slice(0, 120), detail: String(t.detail || '').slice(0, 400), mins_ago: Math.max(0, Math.round(Number(t.mins_ago) || 0)) }));
+ return out;
+ },
+ 'live.person.overview'(r) {
+ const t = (r.json && typeof r.json.overview === 'string' ? r.json.overview : r.text || '').replace(/^\s*(combined\s+overview|overview)\s*[:\-–]\s*/i, '').trim();
+ return t ? { overview: t.slice(0, 2000) } : null;
+ },
+ 'live.stream.titles'(r, input) {
+ const j = r.json || parseJsonLoose(r.text);
+ const arr = Array.isArray(j) ? j : j && Array.isArray(j.titles) ? j.titles : null;
+ if (!arr) return null;
+ const titles = arr.filter((x) => x && Number.isInteger(Number(x.index)) && Number(x.index) >= 0 && Number(x.index) < input.summaries.length && x.title)
+ .map((x) => ({ index: Number(x.index), title: String(x.title).replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80) }));
+ return { titles };
+ },
+ 'live.moments.rank'(r, input) {
+ const j = r.json || parseJsonLoose(r.text);
+ const arr = Array.isArray(j) ? j : j && Array.isArray(j.ranked) ? j.ranked : null;
+ if (!arr) return null;
+ const seen = new Set();
+ const ranked = arr.filter((x) => x && Number.isInteger(Number(x.index)) && input.vods[Number(x.index)] != null && !seen.has(Number(x.index)) && seen.add(Number(x.index)))
+ .map((x) => ({ index: Number(x.index), score: Math.max(0, Math.min(100, Math.round(Number(x.score) || 0))), why: String(x.why || '').replace(/\s+/g, ' ').trim().slice(0, 60) }));
+ return { ranked };
+ },
'live.hero.slogans'(r) {
'live.hero.slogans'(r) {
const j = r.json || parseJsonLoose(r.text);
const j = r.json || parseJsonLoose(r.text);
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').map((x) => x.slice(0, 200)).slice(0, 60) : []);
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').map((x) => x.slice(0, 200)).slice(0, 60) : []);
@@ -79,6 +79,51 @@ const templates = [
@@ -79,6 +79,51 @@ const templates = [
user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n{{parts}}',
user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n{{parts}}',
default_route: 'live.legacy', owner: 'live',
default_route: 'live.legacy', owner: 'live',
},
},
+ {
+ key: 'live.chat.global', name: "Live: the global chat picture",
+ description: "From chat-ai.js (chat_global): what the chat is about now, the running community notes, and 0-3 notable moments.",
+ input_schema: { type: 'object' },
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["recent_overview", "memory", "timeline"], "properties": {"recent_overview": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},
+ system_prompt: "",
+ user_prompt: "You are the community analyst for a live-streaming site's chat. Analyze the RECENT chat below (covers roughly the {{window_label}}, across the global room and individual streamer channels) and update a rolling picture of the community.\n\nReturn ONLY a JSON object, no prose, with exactly these keys:\n{\n \"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.\",\n \"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.\",\n \"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.\n}\n\nPRIOR running notes (may be empty):\n\"\"\"{{prior_memory}}\"\"\"\n\nRecent timeline entries already recorded (avoid duplicating these):\n{{recent_labels}}\n\nRECENT CHAT ({{count}} messages, oldest first):\n{{messages}}",
+ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'chat_global' },
+ },
+ {
+ key: 'live.chat.profile', name: "Live: one chatter today vs overall",
+ description: "From chat-ai.js (chat_user, chat_relay, chat_anon): a signed-in, bridged or anonymous chatter.",
+ input_schema: { type: 'object' },
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview_24h", "overview_alltime", "memory", "timeline"], "properties": {"overview_24h": {"type": "string"}, "overview_alltime": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},
+ system_prompt: "",
+ user_prompt: "You profile {{subject}} on a live-streaming site, contrasting who they are TODAY vs OVERALL. Base everything only on the evidence below — do not invent.\n\nReturn ONLY a JSON object, no prose, with exactly these keys:\n{\n \"overview_24h\": \"{{#recent}}2-3 sentences on what {{noun}} has been chatting about and their mood/energy in the LAST 24 HOURS.{{/recent}}{{^recent}}They have not chatted in the last 24h; write 1 sentence noting they have been quiet recently.{{/recent}}\",\n \"overview_alltime\": \"2-3 sentences on who {{noun}} is as a chatter OVERALL: their style, recurring interests, tone, how they interact.\",\n \"memory\": \"Condensed running notes about {{noun}} carried forward (interests, catchphrases, who they talk to, patterns). Update the PRIOR notes; keep under ~1000 chars.\",\n \"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 {{noun}}, or [].\n}\n\nPRIOR running notes (all-time gist so far{{#seen}}; ~{{seen}} messages seen previously{{/seen}}):\n\"\"\"{{prior_memory}}\"\"\"\n\n{{messages_header}} (oldest first):\n{{messages}}",
+ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'chat_profile' },
+ },
+ {
+ key: 'live.person.overview', name: "Live: a person as streamer and chatter",
+ description: "From chat-ai-routes.js (combined_overview): two summaries fused into one.",
+ input_schema: { type: 'object' },
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},
+ system_prompt: "",
+ user_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{{as_streamer}}\n\nAS A CHATTER:\n{{as_chatter}}",
+ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'person_overview' },
+ },
+ {
+ key: 'live.stream.titles', name: "Live: short titles for past sessions",
+ description: "From chat-ai-routes.js (session_titles): a 3-6 word title per session summary.",
+ input_schema: { type: 'object' },
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["titles"], "properties": {"titles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer"}, "title": {"type": "string"}}}}}},
+ system_prompt: "",
+ user_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: {\"titles\": [{\"index\": <number>, \"title\": \"<3-6 word title>\"}]} with every item.",
+ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'session_titles' },
+ },
+ {
+ key: 'live.moments.rank', name: "Live: rank VODs for the highlights showcase",
+ description: "From ai-moments-job.js (moment_vod_rank).",
+ input_schema: { type: 'object' },
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["ranked"], "properties": {"ranked": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "score", "why"], "properties": {"index": {"type": "integer"}, "score": {"type": "integer"}, "why": {"type": "string"}}}}}},
+ system_prompt: "",
+ user_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.\n\n{{list}}\n\nReturn STRICT JSON only, nothing else: {\"ranked\": [{\"index\": <n>, \"score\": <1-100>, \"why\": \"<3-8 words>\"}]} for the top {{top}}, best first.",
+ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_vod_rank' },
+ },
{
{
key: 'live.hero.slogans', name: 'Live: home hero audiences and quips',
key: 'live.hero.slogans', name: 'Live: home hero audiences and quips',
description: 'From slogan-job.js (hero_slogans): the rotating "Live streaming for …" audiences and one-liners, from the community\'s real chat, streamers and VODs.',
description: 'From slogan-job.js (hero_slogans): the rotating "Live streaming for …" audiences and one-liners, from the community\'s real chat, streamers and VODs.',
@@ -236,6 +281,46 @@ const structured = [
@@ -236,6 +281,46 @@ const structured = [
steps: [{ kind: 'llm', template: 'live.media.overview', operation: 'summarize', output: 'text', prepare: 'live.media.overview', postprocess: 'live.media.overview', params: { max_tokens: 400 } }],
steps: [{ kind: 'llm', template: 'live.media.overview', operation: 'summarize', output: 'text', prepare: 'live.media.overview', postprocess: 'live.media.overview', params: { max_tokens: 400 } }],
cache_mode: 'private',
cache_mode: 'private',
},
},
+ {
+ key: 'live.chat.global', name: "The global chat picture", namespace: 'live',
+ description: "The prompt Live used to render (chat-ai.js), now a versioned template: the window, the prior notes, moments already recorded and the recent messages in.",
+ input_schema: TEXT_OUT({ window_label: STR(40), prior_memory: STR(4000), recent_labels: { type: 'array', maxItems: 20, items: STR(200) }, messages: { type: 'array', maxItems: 300, items: { type: 'object', required: ['author', 'text'], additionalProperties: false, properties: { mins_ago: { type: ['integer', 'null'], minimum: 0 }, where: { type: ['string', 'null'], maxLength: 80 }, author: STR(80), kind: { type: ['string', 'null'], maxLength: 40 }, text: STR(1000) } } } }, ['messages']),
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["recent_overview", "memory", "timeline"], "properties": {"recent_overview": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},
+ steps: [{ kind: 'llm', template: 'live.chat.global', operation: 'generate', output: 'json', prepare: 'live.chat.global', postprocess: 'live.chat.insight', params: { max_tokens: 700 } }],
+ cache_mode: 'none',
+ },
+ {
+ key: 'live.chat.profile', name: "One chatter today vs overall", namespace: 'live',
+ description: "The prompt Live used to render for signed-in, bridged and anonymous chatters (chat-ai.js), now one versioned template.",
+ input_schema: TEXT_OUT({ subject_kind: { enum: ['user', 'relay', 'anon'] }, name: STR(120), platform: STR(40), recent_24h: { type: 'boolean' }, seen: { type: 'integer', minimum: 0 }, prior_memory: STR(4000), messages: { type: 'array', maxItems: 300, items: { type: 'object', required: ['author', 'text'], additionalProperties: false, properties: { mins_ago: { type: ['integer', 'null'], minimum: 0 }, where: { type: ['string', 'null'], maxLength: 80 }, author: STR(80), kind: { type: ['string', 'null'], maxLength: 40 }, text: STR(1000) } } } }, ['subject_kind', 'name', 'messages']),
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview_24h", "overview_alltime", "memory", "timeline"], "properties": {"overview_24h": {"type": "string"}, "overview_alltime": {"type": "string"}, "memory": {"type": "string"}, "timeline": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "detail", "mins_ago"], "properties": {"label": {"type": "string"}, "detail": {"type": "string"}, "mins_ago": {"type": "integer"}}}}}},
+ steps: [{ kind: 'llm', template: 'live.chat.profile', operation: 'generate', output: 'json', prepare: 'live.chat.profile', postprocess: 'live.chat.insight', params: { max_tokens: 600 } }],
+ cache_mode: 'none',
+ },
+ {
+ key: 'live.person.overview', name: "A person as streamer and chatter", namespace: 'live',
+ description: "The prompt Live used to render (chat-ai-routes.js combined_overview).",
+ input_schema: TEXT_OUT({ as_streamer: { type: 'string', minLength: 1, maxLength: 4000 }, as_chatter: { type: 'string', minLength: 1, maxLength: 4000 } }, ['as_streamer', 'as_chatter']),
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},
+ steps: [{ kind: 'llm', template: 'live.person.overview', operation: 'generate', output: 'json', prepare: 'live.person.overview', postprocess: 'live.person.overview', params: { max_tokens: 320 } }],
+ cache_mode: 'none',
+ },
+ {
+ key: 'live.stream.titles', name: "Short titles for past sessions", namespace: 'live',
+ description: "The prompt Live used to render (chat-ai-routes.js session_titles): session summaries in, a title per index out.",
+ input_schema: TEXT_OUT({ summaries: { type: 'array', minItems: 1, maxItems: 20, items: STR(1000) } }, ['summaries']),
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["titles"], "properties": {"titles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer"}, "title": {"type": "string"}}}}}},
+ steps: [{ kind: 'llm', template: 'live.stream.titles', operation: 'generate', output: 'json', prepare: 'live.stream.titles', postprocess: 'live.stream.titles', params: { max_tokens: 700 } }],
+ cache_mode: 'none',
+ },
+ {
+ key: 'live.moments.rank', name: "Rank VODs for the highlights showcase", namespace: 'live',
+ description: "The prompt Live used to render (ai-moments-job.js): VODs with their summaries and stats in, the top ones scored out.",
+ input_schema: TEXT_OUT({ vods: { type: 'array', minItems: 1, maxItems: 25, items: { type: 'object', required: ['title'], additionalProperties: false, properties: { title: STR(200), overview: STR(1000), views: { type: 'integer', minimum: 0 }, clips: { type: 'integer', minimum: 0 }, peak_viewers: { type: 'integer', minimum: 0 } } } }, want: { type: 'integer', minimum: 1, maximum: 25 } }, ['vods']),
+ output_schema: {"type": "object", "additionalProperties": false, "required": ["ranked"], "properties": {"ranked": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "score", "why"], "properties": {"index": {"type": "integer"}, "score": {"type": "integer"}, "why": {"type": "string"}}}}}},
+ steps: [{ kind: 'llm', template: 'live.moments.rank', operation: 'generate', output: 'json', prepare: 'live.moments.rank', postprocess: 'live.moments.rank', params: { max_tokens: 700 } }],
+ cache_mode: 'none',
+ },
{
{
key: 'live.hero.slogans', name: 'Home hero audiences and quips', namespace: 'live',
key: 'live.hero.slogans', name: 'Home hero audiences and quips', namespace: 'live',
description: 'The prompt Live used to render (slogan-job.js), now a versioned template: the chat vibe, per-user notes, streamers, recent VODs and active usernames in; audiences and quips out. Live keeps its cleaning rules (no "free", no "streaming for").',
description: 'The prompt Live used to render (slogan-job.js), now a versioned template: the chat vibe, per-user notes, streamers, recent VODs and active usernames in; audiences and quips out. Live keeps its cleaning rules (no "free", no "streaming for").',
@@ -361,7 +446,6 @@ const PASSTHROUGH = [
@@ -361,7 +446,6 @@ const PASSTHROUGH = [
['live.viewers.line', 'AI viewers: single line', 'viewers/budget.js generate() — ai_viewers (chat reply).'],
['live.viewers.line', 'AI viewers: single line', 'viewers/budget.js generate() — ai_viewers (chat reply).'],
['live.viewers.fold', 'AI viewers: memory fold', 'viewers/fold.js — ai_viewers_fold.'],
['live.viewers.fold', 'AI viewers: memory fold', 'viewers/fold.js — ai_viewers_fold.'],
['live.chat.insight', 'Chat insights and session overviews', 'chat-ai.js and chat-ai-routes.js — chat_global, chat_user, chat_relay, chat_anon, combined_overview, session_titles.'],
['live.chat.insight', 'Chat insights and session overviews', 'chat-ai.js and chat-ai-routes.js — chat_global, chat_user, chat_relay, chat_anon, combined_overview, session_titles.'],
- ['live.moments.rank', 'Moments: rank VOD moments', 'ai-moments-job.js — moment_vod_rank.'],
['live.clips.confirm', 'Auto-clip confirmation', 'auto-clip-job.js — auto_clip_confirm.'],
['live.clips.confirm', 'Auto-clip confirmation', 'auto-clip-job.js — auto_clip_confirm.'],
['live.arena.persona', 'Arena: persona', 'arena-service.js — arena_persona.'],
['live.arena.persona', 'Arena: persona', 'arena-service.js — arena_persona.'],
['live.arena.quotes', 'Arena: quotes', 'arena-service.js — arena_quotes.'],
['live.arena.quotes', 'Arena: quotes', 'arena-service.js — arena_quotes.'],
@@ -71,4 +71,35 @@ t.test('slogans, the daily secret and the star render as Live rendered them', ()
@@ -71,4 +71,35 @@ t.test('slogans, the daily secret and the star render as Live rendered them', ()
assert.strictEqual(POSTPROCESS['live.home.star']({ json: { headline: 'x' } }), null);
assert.strictEqual(POSTPROCESS['live.home.star']({ json: { headline: 'x' } }), null);
});
});
+t.test('chat analysis, person overviews, session titles and VOD ranking render as Live rendered them', () => {
+ const tp = (k) => live.templates.find((x) => x.key === k).user_prompt;
+ const g = render(tp('live.chat.global'), PREPARE['live.chat.global']({ window_label: 'past 3 hours', prior_memory: 'notes', recent_labels: ['a', 'b'], messages: [{ mins_ago: 12, where: 'global', author: 'goosely', text: ' hi ' }, { mins_ago: 3, where: '#ann', author: 'bob', kind: 'emote', text: 'waves' }] }).vars);
+ assert.match(g, /covers roughly the past 3 hours, across/);
+ assert.match(g, /"""notes"""/);
+ assert.match(g, /avoid duplicating these\):\n- a\n- b\n/);
+ assert.match(g, /RECENT CHAT \(2 messages, oldest first\):\n\[12m ago\] \[global\] goosely: hi\n\[3m ago\] \[#ann\] bob: \(emote\) waves$/);
+ const p = (x) => render(tp('live.chat.profile'), PREPARE['live.chat.profile'](x).vars);
+ const user = p({ subject_kind: 'user', name: 'goosely', recent_24h: true, seen: 42, messages: [{ mins_ago: 1, where: 'global', author: 'goosely', text: 'x' }] });
+ assert.match(user, /^You profile an individual chatter \("goosely"\)/);
+ assert.match(user, /"overview_24h": "2-3 sentences on what this user has been chatting about/);
+ assert.match(user, /all-time gist so far; ~42 messages seen previously\)/);
+ assert.match(user, /MESSAGES FROM THIS USER IN THE LAST 24H \(oldest first\):\n\[1m ago\] goosely: x$/, 'no channel tag in a profile');
+ const anon = p({ subject_kind: 'anon', name: 'anon_7', recent_24h: false, seen: 9, messages: [{ author: 'anon_7', text: 'y' }] });
+ assert.match(anon, /an ANONYMOUS chatter \("anon_7", not logged in\)/);
+ assert.match(anon, /They have not chatted in the last 24h; write 1 sentence/);
+ assert.match(anon, /THIS ANON'S MOST RECENT MESSAGES \(oldest first\):\nanon_7: y$/);
+ assert.ok(!/messages seen previously/.test(anon));
+ assert.match(p({ subject_kind: 'relay', name: 'KickFan', platform: 'kick', messages: [{ author: 'KickFan', text: 'z' }] }), /an external chatter \("KickFan", bridged in from kick\)/);
+ const long = PREPARE['live.chat.global']({ messages: Array.from({ length: 300 }, (_, i) => ({ mins_ago: 300 - i, author: 'u', text: 'x'.repeat(200) + i })) }).vars.messages;
+ assert.ok(long.length === 9000 && long.endsWith('x299'), 'the freshest 9000 characters');
+ assert.match(render(tp('live.person.overview'), { as_streamer: 'S', as_chatter: 'C' }), /AS A STREAMER:\nS\n\nAS A CHATTER:\nC$/);
+ assert.match(render(tp('live.stream.titles'), PREPARE['live.stream.titles']({ summaries: ['one two', 'three'] }).vars), /\n\n0\. one two\n1\. three\n\n/);
+ const rank = render(tp('live.moments.rank'), PREPARE['live.moments.rank']({ vods: [{ title: 'Rust', overview: '', views: 3, clips: 1, peak_viewers: 2 }], want: 8 }).vars);
+ assert.match(rank, /\n\n0\. \[3 views · 1 clips · peak 2\] "Rust" — \(no summary\)\n\n/);
+ assert.match(rank, /for the top 1, best first\.$/);
+ assert.deepStrictEqual(POSTPROCESS['live.moments.rank']({ text: '[{"index": 0, "score": 140, "why": "x"}, {"index": 0, "score": 3}, {"index": 9}]' }, { vods: [{}] }).ranked, [{ index: 0, score: 100, why: 'x' }]);
+ assert.deepStrictEqual(POSTPROCESS['live.chat.insight']({ json: { overview_24h: 'a', timeline: [{ label: 'L', mins_ago: '5' }] } }, { subject_kind: 'user' }), { overview_24h: 'a', overview_alltime: '', memory: '', timeline: [{ label: 'L', detail: '', mins_ago: 5 }] });
+ assert.strictEqual(POSTPROCESS['live.person.overview']({ text: 'Overview: A coder.' }).overview, 'A coder.');
+});
+
t.run();
t.run();
@@ -48,6 +48,11 @@ const EXAMPLES = {
@@ -48,6 +48,11 @@ const EXAMPLES = {
};
};
for (const k of new Set(Object.values(KIND_TO_WORKFLOW).concat('live.complete'))) EXAMPLES[k] = PASS;
for (const k of new Set(Object.values(KIND_TO_WORKFLOW).concat('live.complete'))) EXAMPLES[k] = PASS;
// Moved from passthrough to a versioned template (WS-O task 2): structured input.
// Moved from passthrough to a versioned template (WS-O task 2): structured input.
+EXAMPLES['live.chat.global'] = { window_label: 'past 3 hours', prior_memory: 'croutons are a running joke', recent_labels: ['the crouton war'], messages: [{ mins_ago: 12, where: 'global', author: 'goosely', kind: null, text: 'croutons are soup confetti' }, { mins_ago: 3, where: '#ann', author: 'bob', kind: 'emote', text: 'waves' }] };
+EXAMPLES['live.chat.profile'] = { subject_kind: 'relay', name: 'KickFan', platform: 'kick', recent_24h: false, seen: 0, prior_memory: '', messages: [{ mins_ago: 2000, author: 'KickFan', text: 'hello from kick' }] };
+EXAMPLES['live.person.overview'] = { as_streamer: 'Codes rust late at night.', as_chatter: 'Jokes about croutons.' };
+EXAMPLES['live.stream.titles'] = { summaries: ['Fixed a borrow checker bug live', 'Built a robot arm and it waved'] };
+EXAMPLES['live.moments.rank'] = { vods: [{ title: 'Rust night', overview: 'the build finally compiles', views: 30, clips: 2, peak_viewers: 5 }, { title: 'Chill', overview: 'sitting at a desk', views: 3, clips: 0, peak_viewers: 1 }], want: 2 };
EXAMPLES['live.hero.slogans'] = { global: 'Chat argued about croutons all day.', users: [{ name: 'goosely', text: 'always defends croutons' }], streamers: [{ name: 'ann', text: 'codes rust at night' }], vods: [{ name: 'Rust night', text: 'the build finally compiles' }], usernames: ['goosely', 'ann'], count: 20 };
EXAMPLES['live.hero.slogans'] = { global: 'Chat argued about croutons all day.', users: [{ name: 'goosely', text: 'always defends croutons' }], streamers: [{ name: 'ann', text: 'codes rust at night' }], vods: [{ name: 'Rust night', text: 'the build finally compiles' }], usernames: ['goosely', 'ann'], count: 20 };
EXAMPLES['live.easter_egg'] = { vibe: 'croutons' };
EXAMPLES['live.easter_egg'] = { vibe: 'croutons' };
EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };
EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };