openvibers/openvibe.ai Commits

Arena personas, quotes and headlines and the auto-clip check are versioned templates (roadmap WS-O task 2). live.arena.persona, live.arena.quotes, live.arena.headline and live.clips.confirm move from passthrough to structured workflows (version 2 of each key), carrying Live's system prompts and JSON schemas verbatim. Live now sends data: the fighter's facts, the candidate lines, the beef event with its fighters, and the scene notes, timed speech, sounds and chat around a spike. The prepare hooks render them as Live did; the quotes schema only allows indexes of the lines given; answers without a fighter name, a valid pick or a headline are no answer, and clip is true only when the model said true. Parameters as before (persona 1200 tokens at 0.95, quotes 500 at 0.7, headline 80 at 1.0, clip check 220). test/live-templates.test.js checks the rendering; workflows.test runs all four end to end. npm test passes.

OP OpenVibers committed 28 Sept 2026 · d27143693c · 4 files, +132 −4


Changes

ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex c78ceec..a4bdca2 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -87,6 +87,30 @@ function chatLines(messages, withWhere) {
}
const PREPARE = {
++ 'live.arena.persona'(input) {
++ return { vars: { facts: input.facts } };
++ },
++ 'live.arena.quotes'(input) {
++ // Indexes can only point at the lines given.
++ const index = { type: 'integer', minimum: 0, maximum: input.lines.length - 1 };
++ const jsonSchema = { type: 'object', additionalProperties: false, required: ['picks', 'walkout', 'voice_verdict', 'mic_style'], properties: {
++ picks: { type: 'array', minItems: 1, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['index', 'why'], properties: { index, why: { type: 'string' } } } },
++ walkout: index, voice_verdict: { type: 'string' }, mic_style: { type: 'string' },
++ } };
++ return { vars: { lines: input.lines.map((t, i) => `${i}: ${t}`).join('\n') }, jsonSchema };
++ },
++ 'live.arena.headline'(input) {
++ return { vars: { payload: input } };
++ },
++ 'live.clips.confirm'(input) {
++ const clean = (t, n) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, n);
++ return { vars: {
++ scene: (input.scene || []).map((d) => `- ${clean(d, 160)}`).join('\n') || '(none)',
++ script: (input.transcript || []).map((x) => `- [${mmss(Number(x.t) || 0)}] ${clean(x.text, 140)}`).join('\n') || '(none)',
++ sounds: (input.sounds || []).map((x) => `- [${mmss(Number(x.t) || 0)}] ${x.label} (${Number(x.confidence || 0).toFixed(2)})`).join('\n') || '(none)',
++ chat: (input.chat || []).slice(-30).map((c) => `- ${clean(c, 100)}`).join('\n') || '(none)',
++ } };
++ },
// Chat analysis (chat-ai.js): messages formatted as Live formatted them, freshest 9000 characters kept.
'live.chat.global'(input) {
return { vars: {
@@@ -256,6 +280,27 @@ const PREPARE = {
};
const POSTPROCESS = {
++ 'live.arena.persona'(r) {
++ const j = r.json || parseJsonLoose(r.text);
++ return j && typeof j === 'object' && j.fighter_name ? j : null;
++ },
++ 'live.arena.quotes'(r, input) {
++ const j = r.json || parseJsonLoose(r.text);
++ if (!j || !Array.isArray(j.picks)) return null;
++ const ok = (i) => Number.isInteger(Number(i)) && Number(i) >= 0 && Number(i) < input.lines.length;
++ const picks = j.picks.filter((p) => p && ok(p.index)).slice(0, 6).map((p) => ({ index: Number(p.index), why: String(p.why || '').slice(0, 200) }));
++ if (!picks.length) return null;
++ return { picks, walkout: ok(j.walkout) ? Number(j.walkout) : picks[0].index, voice_verdict: String(j.voice_verdict || '').slice(0, 400), mic_style: String(j.mic_style || '').slice(0, 400) };
++ },
++ 'live.arena.headline'(r) {
++ const j = r.json || parseJsonLoose(r.text);
++ return j && j.headline ? { headline: String(j.headline).slice(0, 120) } : null;
++ },
++ 'live.clips.confirm'(r) {
++ const j = r.json || parseJsonLoose(r.text);
++ if (!j || typeof j !== 'object') return null;
++ return { clip: j.clip === true, title: String(j.title || '').replace(/\s+/g, ' ').trim().slice(0, 80), desc: String(j.desc || '').replace(/\s+/g, ' ').trim().slice(0, 400) };
++ },
// 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);
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex 1bcd6ee..5e23514 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -79,6 +79,42 @@ 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.arena.persona', name: "Live: an Arena fighter persona",
++ description: "From arena/arena-service.js generatePersona (arena_persona): the character-select bio, voice first, from what they said on stream.",
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["fighter_name", "title", "class", "element", "signature_move", "special", "weakness", "taunt", "taunts", "typing_style", "spoken_as", "custom_stats", "lore", "catchphrase", "entrance_music", "stat_quips"], "properties": {"fighter_name": {"type": "string", "description": "Arena ring name, 2–5 words, based on the streamer"}, "title": {"type": "string", "description": "Epithet like \"The Midnight Menace of Cozy Corner\""}, "class": {"type": "string", "description": "Fighting-game archetype, e.g. Grappler, Zoner, Rushdown, Summoner, Bard, Tank"}, "element": {"type": "string", "description": "Single word element/vibe"}, "signature_move": {"type": "object", "additionalProperties": false, "required": ["name", "description"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}}}, "special": {"type": "object", "additionalProperties": false, "required": ["name", "description"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}}}, "weakness": {"type": "string"}, "taunt": {"type": "string", "description": "Their signature ragebait line: one sentence, written EXACTLY the way this person types/talks (their punctuation, caps, slang, emoji habits, typos), aimed at rivals or their chat, designed to make people reply"}, "taunts": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "string"}, "description": "three more ragebait/troll lines in their own voice: one at a specific rival from the roster, one at their own chat, one about a topic they will not shut up about"}, "typing_style": {"type": "string", "description": "≤ 12 words describing how they TALK on mic (e.g. \"mumbles, calls everyone bud, yells at chat every 30 seconds\")"}, "custom_stats": {"type": "array", "minItems": 6, "maxItems": 6, "items": {"type": "object", "additionalProperties": false, "required": ["name", "value", "quip"], "properties": {"name": {"type": "string", "description": "≤ 14 chars, a stat that only makes sense for THIS person — named after their actual bits, habits, subjects, gear, rivals, the way they talk (e.g. \"Alt Accounts\", \"Tent Smell\", \"Baby Voice\", \"Reads Chat\", \"Cope\")"}, "value": {"type": "integer", "minimum": 1, "maximum": 99}, "quip": {"type": "string", "description": "≤ 8 words, in their own speaking voice"}}}, "description": "six CHARACTERISTICS unique to this streamer for their radar — read from what they SAY ON MIC (things_they_said_on_stream, their_best_shit_talk, who_they_call_out, what_they_rant_at) and their channel (ai_overview, what the camera saw): the bits they run, what they will not shut up about, how they treat chat, their rivals, their gear obsession, their schedule. Names must be specific to THEM (never generic like \"Hype\" or \"Skill\"), funny, ragebait-ish; values honestly spread (at least one under 30, at least one over 85) and the quip explains the number in their voice."}, "spoken_as": {"type": "array", "minItems": 2, "maxItems": 6, "items": {"type": "string"}, "description": "how OTHER people say or mishear this streamer's name out loud, as a speech-to-text engine would write it: nicknames, shortenings, phonetic misspellings, the handle split into words (e.g. \"japanese old guy\", \"matticus\", \"goose lee\", \"dan\") — lowercase"}, "lore": {"type": "string", "description": "2–3 sentence character-select bio"}, "catchphrase": {"type": "string"}, "entrance_music": {"type": "string"}, "stat_quips": {"type": "object", "additionalProperties": false, "required": ["heat", "aim", "kills", "mouth", "clapback", "stamina", "pace"], "properties": {"heat": {"type": "string", "description": "≤ 8 words explaining their heat rating in character"}, "aim": {"type": "string", "description": "≤ 8 words explaining their aim rating in character"}, "kills": {"type": "string", "description": "≤ 8 words explaining their kills rating in character"}, "mouth": {"type": "string", "description": "≤ 8 words explaining their mouth rating in character"}, "clapback": {"type": "string", "description": "≤ 8 words explaining their clapback rating in character"}, "stamina": {"type": "string", "description": "≤ 8 words explaining their stamina rating in character"}, "pace": {"type": "string", "description": "≤ 8 words explaining their pace rating in character"}}}}},
++ system_prompt: "You write fighting-game \"character select\" bios for a live-streaming site's Arena — a battle-cam where streamers talk shit about each other ON MIC and the mic is the only thing that counts. Be savage, profane, petty and funny — roast their habits, their chat, their schedule, their takes, their gimmicks, the way they talk. Trolling energy is welcome; make it quotable.\nVOICE COMES FIRST, AND IT COMES FROM WHAT THEY SAY. The PRIMARY sources are `things_they_said_on_stream` (verbatim speech-to-text, newest first — expect noise), `their_best_shit_talk` (their judged spiciest lines, with who they were aimed at), `who_they_call_out`, `who_calls_them_out`, `what_they_rant_at` and `quotes`. Their streaming profile (`ai_overview`, titles, what the camera saw) is SECONDARY flavor. Copy how they actually talk: their rhythm, pet phrases, how they address chat, how they curse, whether they mumble or yell, sentence length. A taunt must read like something THEY would actually say into the mic — not a movie trailer, not a wrestling promo, no invented catchphrases, never repeat a word for effect, no \"watch me\"/\"try to keep up\" filler.\nTAUNTS ARE THE MOST IMPORTANT PART. They must be RAGEBAIT in that voice: provocative, trolly, specific, petty — the kind of line that makes a rival pull up on stream or a chat reply instantly. Reference their real recurring subjects, rivals from the roster, the people they keep calling out and their obsessions. stat_quips are ALSO in their voice (≤ 8 words each, how THEY would describe that stat). If there is almost no transcript data, infer the voice from the overview and say so in typing_style.\nThis platform does not censor language — offensive and taboo words are allowed. The only hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Everything is Arena lore, not real-life claims. Output only the JSON.",
++ user_prompt: "Write the Arena persona for this fighter. Facts (JSON):\n{{json facts}}",
++ default_route: 'live.summary', owner: 'live', metadata: { json_name: 'arena_persona' },
++ },
++ {
++ key: 'live.arena.quotes', name: "Live: an Arena fighter's best lines",
++ description: "From arena/arena-service.js generateQuotes (arena_quotes): the most quotable lines from raw speech-to-text.",
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["picks", "walkout", "voice_verdict", "mic_style"], "properties": {"picks": {"type": "array", "minItems": 1, "maxItems": 6, "items": {"type": "object", "additionalProperties": false, "required": ["index", "why"], "properties": {"index": {"type": "integer"}, "why": {"type": "string"}}}}, "walkout": {"type": "integer"}, "voice_verdict": {"type": "string"}, "mic_style": {"type": "string"}}},
++ system_prompt: "You pick the most quotable lines a live-streamer actually said, from raw speech-to-text (expect transcription noise). Choose lines that are funny, savage, unhinged, weirdly profound, or perfect trash talk out of context — swearing and disrespect are a plus. Offensive or taboo language is allowed on this platform and is not a reason to skip a line. Skip only direct threats of violence, anything sexual about minors, doxxing, and lines that are pure transcription garbage. Prefer complete sentences. Return indexes into the list you are given. Output only the JSON.",
++ user_prompt: "Lines (index: text):\n{{lines}}",
++ default_route: 'live.summary', owner: 'live', metadata: { json_name: 'arena_quotes' },
++ },
++ {
++ key: 'live.arena.headline', name: "Live: an Arena beef headline",
++ description: "From arena/beef.js headlineFor (arena_headline).",
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["headline"], "properties": {"headline": {"type": "string", "description": "≤ 100 chars, tabloid, inflammatory but funny, names both fighters"}}},
++ system_prompt: "You write tabloid headlines for a streaming site's trash-talk arena: inflammatory, funny, screenshot-able, names both fighters, ≤ 100 chars. Profanity and disrespect welcome; this platform does not censor language. Hard line: no direct threats of violence, no doxxing. Output only the JSON.",
++ user_prompt: "{{json payload}}",
++ default_route: 'live.chat', owner: 'live', metadata: { json_name: 'arena_headline' },
++ },
++ {
++ key: 'live.clips.confirm', name: "Live: is this chat spike clip-worthy",
++ description: "From auto-clip-job.js (auto_clip_confirm): scene notes, transcript, sounds and chat around a spike.",
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["clip", "title", "desc"], "properties": {"clip": {"type": "boolean"}, "title": {"type": "string"}, "desc": {"type": "string"}}},
++ system_prompt: "",
++ user_prompt: "A live stream just had a spike in chat activity — viewers reacted to something. Decide if this is a genuinely clip-worthy standout moment (funny, dramatic, surprising, hype) worth auto-clipping, or just routine chatter.\n\nON SCREEN (recent scene notes):\n{{scene}}\n\nWHAT WAS SAID (recent transcript):\n{{script}}\n\nWHAT WAS HEARD (non-speech sounds detected):\n{{sounds}}\n\nCHAT (recent messages):\n{{chat}}\n\nReturn STRICT JSON only: {\"clip\": true|false, \"title\": \"<specific punchy 3-8 word title>\", \"desc\": \"<one vivid sentence>\"}. Set clip=false if nothing genuinely notable is happening.",
++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'auto_clip_confirm' },
++ },
{
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.",
@@@ -281,6 +317,38 @@ 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.arena.persona', name: "Arena fighter persona", namespace: 'live',
++ description: "The prompt Live used to render (arena-service.js), now a versioned template: the fighter's facts in (numbers, what they said, their shit talk, rivals); the persona out.",
++ input_schema: TEXT_OUT({ facts: { type: 'object' } }, ['facts']),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["fighter_name", "title", "class", "element", "signature_move", "special", "weakness", "taunt", "taunts", "typing_style", "spoken_as", "custom_stats", "lore", "catchphrase", "entrance_music", "stat_quips"], "properties": {"fighter_name": {"type": "string", "description": "Arena ring name, 2–5 words, based on the streamer"}, "title": {"type": "string", "description": "Epithet like \"The Midnight Menace of Cozy Corner\""}, "class": {"type": "string", "description": "Fighting-game archetype, e.g. Grappler, Zoner, Rushdown, Summoner, Bard, Tank"}, "element": {"type": "string", "description": "Single word element/vibe"}, "signature_move": {"type": "object", "additionalProperties": false, "required": ["name", "description"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}}}, "special": {"type": "object", "additionalProperties": false, "required": ["name", "description"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}}}, "weakness": {"type": "string"}, "taunt": {"type": "string", "description": "Their signature ragebait line: one sentence, written EXACTLY the way this person types/talks (their punctuation, caps, slang, emoji habits, typos), aimed at rivals or their chat, designed to make people reply"}, "taunts": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "string"}, "description": "three more ragebait/troll lines in their own voice: one at a specific rival from the roster, one at their own chat, one about a topic they will not shut up about"}, "typing_style": {"type": "string", "description": "≤ 12 words describing how they TALK on mic (e.g. \"mumbles, calls everyone bud, yells at chat every 30 seconds\")"}, "custom_stats": {"type": "array", "minItems": 6, "maxItems": 6, "items": {"type": "object", "additionalProperties": false, "required": ["name", "value", "quip"], "properties": {"name": {"type": "string", "description": "≤ 14 chars, a stat that only makes sense for THIS person — named after their actual bits, habits, subjects, gear, rivals, the way they talk (e.g. \"Alt Accounts\", \"Tent Smell\", \"Baby Voice\", \"Reads Chat\", \"Cope\")"}, "value": {"type": "integer", "minimum": 1, "maximum": 99}, "quip": {"type": "string", "description": "≤ 8 words, in their own speaking voice"}}}, "description": "six CHARACTERISTICS unique to this streamer for their radar — read from what they SAY ON MIC (things_they_said_on_stream, their_best_shit_talk, who_they_call_out, what_they_rant_at) and their channel (ai_overview, what the camera saw): the bits they run, what they will not shut up about, how they treat chat, their rivals, their gear obsession, their schedule. Names must be specific to THEM (never generic like \"Hype\" or \"Skill\"), funny, ragebait-ish; values honestly spread (at least one under 30, at least one over 85) and the quip explains the number in their voice."}, "spoken_as": {"type": "array", "minItems": 2, "maxItems": 6, "items": {"type": "string"}, "description": "how OTHER people say or mishear this streamer's name out loud, as a speech-to-text engine would write it: nicknames, shortenings, phonetic misspellings, the handle split into words (e.g. \"japanese old guy\", \"matticus\", \"goose lee\", \"dan\") — lowercase"}, "lore": {"type": "string", "description": "2–3 sentence character-select bio"}, "catchphrase": {"type": "string"}, "entrance_music": {"type": "string"}, "stat_quips": {"type": "object", "additionalProperties": false, "required": ["heat", "aim", "kills", "mouth", "clapback", "stamina", "pace"], "properties": {"heat": {"type": "string", "description": "≤ 8 words explaining their heat rating in character"}, "aim": {"type": "string", "description": "≤ 8 words explaining their aim rating in character"}, "kills": {"type": "string", "description": "≤ 8 words explaining their kills rating in character"}, "mouth": {"type": "string", "description": "≤ 8 words explaining their mouth rating in character"}, "clapback": {"type": "string", "description": "≤ 8 words explaining their clapback rating in character"}, "stamina": {"type": "string", "description": "≤ 8 words explaining their stamina rating in character"}, "pace": {"type": "string", "description": "≤ 8 words explaining their pace rating in character"}}}}},
++ steps: [{ kind: 'llm', template: 'live.arena.persona', operation: 'generate', output: 'json', prepare: 'live.arena.persona', postprocess: 'live.arena.persona', params: { max_tokens: 1200, temperature: 0.95, timeout_ms: 30000 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.arena.quotes', name: "Arena fighter best lines", namespace: 'live',
++ description: "The prompt Live used to render (arena-service.js): candidate lines in, picks by index, a walkout line and a voice verdict out.",
++ input_schema: TEXT_OUT({ lines: { type: 'array', minItems: 1, maxItems: 120, items: STR(400) } }, ['lines']),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["picks", "walkout", "voice_verdict", "mic_style"], "properties": {"picks": {"type": "array", "minItems": 1, "maxItems": 6, "items": {"type": "object", "additionalProperties": false, "required": ["index", "why"], "properties": {"index": {"type": "integer"}, "why": {"type": "string"}}}}, "walkout": {"type": "integer"}, "voice_verdict": {"type": "string"}, "mic_style": {"type": "string"}}},
++ steps: [{ kind: 'llm', template: 'live.arena.quotes', operation: 'generate', output: 'json', prepare: 'live.arena.quotes', postprocess: 'live.arena.quotes', params: { max_tokens: 500, temperature: 0.7, timeout_ms: 30000 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.arena.headline', name: "Arena beef headline", namespace: 'live',
++ description: "The prompt Live used to render (beef.js): the event (open, forfeit, score) and its fighters in; a tabloid headline out.",
++ input_schema: { type: 'object', required: ['kind'], properties: { kind: { enum: ['open', 'forfeit', 'score'] } } },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["headline"], "properties": {"headline": {"type": "string", "description": "≤ 100 chars, tabloid, inflammatory but funny, names both fighters"}}},
++ steps: [{ kind: 'llm', template: 'live.arena.headline', operation: 'generate', output: 'json', prepare: 'live.arena.headline', postprocess: 'live.arena.headline', params: { max_tokens: 80, temperature: 1.0, timeout_ms: 15000 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.clips.confirm', name: "Is this chat spike clip-worthy", namespace: 'live',
++ description: "The prompt Live used to render (auto-clip-job.js): scene notes, timed speech and sounds, and chat around a spike in; clip or not, with a title, out.",
++ input_schema: TEXT_OUT({ scene: { type: 'array', maxItems: 40, items: STR(1000) }, transcript: { type: 'array', maxItems: 60, items: { type: 'object', required: ['t', 'text'], additionalProperties: false, properties: { t: { type: 'number', minimum: 0 }, text: STR(1000) } } }, sounds: { type: 'array', maxItems: 40, items: { type: 'object', required: ['t', 'label'], additionalProperties: false, properties: { t: { type: 'number', minimum: 0 }, label: STR(120), confidence: { type: 'number' } } } }, chat: { type: 'array', maxItems: 60, items: STR(1000) } }, []),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["clip", "title", "desc"], "properties": {"clip": {"type": "boolean"}, "title": {"type": "string"}, "desc": {"type": "string"}}},
++ steps: [{ kind: 'llm', template: 'live.clips.confirm', operation: 'generate', output: 'json', prepare: 'live.clips.confirm', postprocess: 'live.clips.confirm', params: { max_tokens: 220 } }],
++ cache_mode: 'none',
++ },
{
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.",
@@@ -446,12 +514,8 @@ 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.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.'],
['live.arena.scene', 'Arena: scene description', 'arena-service.js — arena_scene.'],
['live.arena.judge', 'Arena: beef and mic judging', 'arena/listener.js — arena_beef_judge, arena_mic_judge.'],
-- ['live.arena.headline', 'Arena: headline', 'arena/beef.js — arena_headline.'],
['live.status_check', 'Provider probe', 'ai-analysis.testStatus / llm.testProvider — status_check.'],
];
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex 9e39d56..15e4d5a 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -102,4 +102,19 @@ t.test('chat analysis, person overviews, session titles and VOD ranking render a
assert.strictEqual(POSTPROCESS['live.person.overview']({ text: 'Overview: A coder.' }).overview, 'A coder.');
});
++t.test('Arena persona, quotes, headline and clip confirmation render as Live rendered them', () => {
++ const tp = (k) => live.templates.find((x) => x.key === k);
++ const facts = { name: 'Ann', numbers: { hours_live_90d: 3 } };
++ assert.strictEqual(render(tp('live.arena.persona').user_prompt, PREPARE['live.arena.persona']({ facts }).vars), `Write the Arena persona for this fighter. Facts (JSON):\n${JSON.stringify(facts)}`);
++ assert.match(tp('live.arena.persona').system_prompt, /^You write fighting-game "character select" bios/);
++ assert.strictEqual(render(tp('live.arena.quotes').user_prompt, PREPARE['live.arena.quotes']({ lines: ['a b', 'c'] }).vars), 'Lines (index: text):\n0: a b\n1: c');
++ const ctx = { kind: 'score', winner: 'ann', loser: 'bob', score_a: 3, score_b: 1 };
++ assert.strictEqual(render(tp('live.arena.headline').user_prompt, PREPARE['live.arena.headline'](ctx).vars), JSON.stringify(ctx));
++ const clip = render(tp('live.clips.confirm').user_prompt, PREPARE['live.clips.confirm']({ scene: ['a jump'], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 95, label: 'Cheering', confidence: 0.8 }], chat: [] }).vars);
++ assert.match(clip, /ON SCREEN \(recent scene notes\):\n- a jump\n\nWHAT WAS SAID \(recent transcript\):\n- \[1:34\] IT COMPILES\n\nWHAT WAS HEARD \(non-speech sounds detected\):\n- \[1:35\] Cheering \(0\.80\)\n\nCHAT \(recent messages\):\n\(none\)\n\nReturn STRICT JSON only/);
++ assert.deepStrictEqual(POSTPROCESS['live.arena.quotes']({ json: { picks: [{ index: 5, why: 'x' }, { index: 1, why: 'y' }], walkout: 9, voice_verdict: 'v', mic_style: 'm' } }, { lines: ['a', 'b'] }), { picks: [{ index: 1, why: 'y' }], walkout: 1, voice_verdict: 'v', mic_style: 'm' });
++ assert.strictEqual(POSTPROCESS['live.arena.persona']({ json: { title: 'no name' } }), null);
++ assert.deepStrictEqual(POSTPROCESS['live.clips.confirm']({ text: '{"clip": "yes", "title": " A B "}' }), { clip: false, title: 'A B', desc: '' });
++});
++
t.run();
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex b46d5bb..c13e437 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -48,6 +48,10 @@ 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.arena.persona'] = { facts: { name: 'Ann', handle: 'ann', category: 'desktop', ratings: { heat: 70 }, things_they_said_on_stream: ['this borrow checker hates me'], roster_rivals: ['bob'] } };
++EXAMPLES['live.arena.quotes'] = { lines: ['this borrow checker hates me and I hate it back', 'chat you are all backseat compilers', 'I have never been this cooked'] };
++EXAMPLES['live.arena.headline'] = { kind: 'open', a: 'ann', b: 'bob', line: 'bob streams to his own alt accounts', rematch: false };
++EXAMPLES['live.clips.confirm'] = { scene: ['streamer jumps up cheering'], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 95, label: 'Cheering', confidence: 0.8 }], chat: ['LETS GO', 'poggers'] };
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.' };