openvibers/openvibe.ai Commits

The Arena's templates and workflows go (Live#264 removed every caller), and seeding archives what is stored of them, so nothing runs it and it leaves the admin lists (#130)

OP OpenVibers committed 25 days ago · 31a42a8048 · 5 files, +35 −206

Changes

ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex 8cf7131..96bac3e 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -115,24 +115,6 @@ const PREPARE = {
'live.viewers.clone'(input) {
return { vars: { name: input.name, overview: input.overview || '', memory: input.memory || '', samples: (input.samples || []).length ? `- ${input.samples.join('\n- ')}` : '' } };
},
-- '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.arena.drama'(input) {
-- return { vars: { payload: input } };
-- },
// The Rundown (Live rundown/desk.js): transcript lines as "[seconds] text", the known topics one per line.
'live.rundown.segment'(input) {
const clean = (t, n) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, n);
@@@ -205,16 +187,6 @@ const PREPARE = {
'live.home.star'(input) {
return { vars: { payload: { recent_stars: input.recent_stars || [], previous_star: input.previous_star || null, candidates: input.candidates } } };
},
-- // The Arena judges send what Live sent before, as one JSON object, in the same order.
-- 'live.arena.judge_beef'(input) {
-- return { vars: { payload: {
-- target_names: input.target_names, target_as_transcribed: input.target_as_transcribed || [], target_named_in_new_speech: input.target_named_in_new_speech !== false,
-- how_the_name_was_matched: input.how_the_name_was_matched || 'exact', what_speaker_already_said_about_target: input.what_speaker_already_said_about_target || null, new_speech: input.new_speech,
-- } } };
-- },
-- 'live.arena.judge_mic'(input) {
-- return { vars: { payload: { speech: input.speech } } };
-- },
'live.moments.pick'(input) {
const lines = (list, max) => (list || []).map((x) => `[${mmss(Number(x.t) || 0)}] ${oneLine(x.text || x.label, max)}`).join('\n');
const times = (list, n) => ((list || []).length ? list.slice(0, n).map((t) => mmss(Number(t) || 0)).join(', ') : 'none');
@@@ -356,28 +328,6 @@ const POSTPROCESS = {
const t = String(r.text || '').trim();
return t ? { text: t.slice(0, 1000) } : null;
},
-- '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.arena.drama'(r) {
-- const j = r.json || parseJsonLoose(r.text);
-- if (!j || !j.headline || !j.blurb) return null;
-- const alert = Math.min(5, Math.max(1, Math.round(Number(j.alert) || 1)));
-- return { headline: String(j.headline).replace(/\s+/g, ' ').trim().slice(0, 130), blurb: String(j.blurb).replace(/\s+/g, ' ').trim().slice(0, 480), alert };
-- },
// The Rundown: every field clamped; a segment without a topic, title or what is dropped; times stay inside the lines'.
'live.rundown.segment'(r, input) {
const j = r.json || parseJsonLoose(r.text);
@@@ -471,12 +421,6 @@ const POSTPROCESS = {
if (!j || !j.username) return null;
return { username: String(j.username).slice(0, 64), headline: String(j.headline || '').trim().slice(0, 60), reason: String(j.reason || '').trim().slice(0, 170) };
},
-- // A judgement without a numeric quality is no answer (Live then uses its own heuristic).
-- 'live.arena.judge'(r) {
-- const j = r.json || parseJsonLoose(r.text);
-- if (!j || typeof j !== 'object' || typeof j.quality !== 'number') return null;
-- return { ...j, quality: Math.max(0, Math.min(10, Math.round(j.quality))) };
-- },
'live.moments.pick'(r) {
const j = r.json || parseJsonLoose(r.text);
const t = j && Number(j.t);
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex b8e6fd9..cadbe0c 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -8,7 +8,7 @@
* (stream memory / overview + inferred category), streamer overview, VOD/clip media overview,
* stream recap, speech-to-text, and Network's footer copy (the dependency the plan reverses).
* - PASSTHROUGH workflows carry a prompt Live still renders itself (chat AI viewers, chat
-- * insights, moment ranking, slogans, easter eggs, arena, home star …). Each Live feature has
++ * insights, moment ranking, slogans, easter eggs, The Rundown, home star …). Each Live feature has
* its own workflow key so every run stays attributable to a workflow + model + run (§33);
* their prompts move into templates feature by feature (docs/migration.md).
*
@@@ -115,42 +115,6 @@ const templates = [
user_prompt: "You are profiling a chat viewer named \"{{name}}\" so an AI can role-play as them.{{#overview}}\n\nWhat we know about them: {{overview}}{{/overview}}{{#memory}}\n\nNotable memory: {{memory}}{{/memory}}{{#samples}}\n\nSample messages they've sent:\n{{samples}}{{/samples}}\n\nWrite a tight 2-3 sentence character brief capturing their vibe, interests, and how they type (tone, casing, slang, length). Second person (\"You are ...\"). No preamble.",
default_route: 'live.chat', owner: 'live', metadata: {},
},
-- {
-- 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.arena.drama', name: "Live: an Arena Alert story",
-- description: "From arena/drama.js (arena_drama): one measured event on the site (a rage quit, a viewer crash, a meltdown, a beef verdict…) and its facts in; a drama-news headline and blurb out.",
-- input_schema: { type: 'object' },
-- output_schema: {"type": "object", "additionalProperties": false, "required": ["headline", "blurb", "alert"], "properties": {"headline": {"type": "string", "description": "≤ 110 chars"}, "blurb": {"type": "string", "description": "1-3 sentences, ≤ 420 chars"}, "alert": {"type": "integer", "minimum": 1, "maximum": 5}}},
-- system_prompt: "You are the anchor of ARENA ALERT, the drama-news desk of a small live-streaming site, in the style of internet drama channels: breaking-news hype, an ALL-CAPS headline, a savage roast, lolcow commentary, short punchy jokes. Rules: (1) Write each name exactly as given in streamer and other: same spelling, same capitals. (2) Use only the facts given. Copy numbers exactly; never add numbers up or invent one (a sounds map is already the count per sound). (3) Never invent an event, motive, relationship, substance, crime or accusation; speculate only as an obvious joke question. (4) Roast what happened on stream, never who they are: no slurs or jokes about race, religion, sexuality, gender, disability or looks; no sexual content, threats, doxxing or self-harm jokes. Swearing is fine. (5) quote is their own words: you may quote it verbatim, in quotation marks. (6) what is a plain account of what happened, written from the facts: keep its meaning and build on it; never print the facts as a list. (7) Call people by their name or \"they\"; never guess a pronoun (no he, him, his, she, her). Write: headline (starts with 🚨, ALL CAPS except the names as given, at most 100 characters, one savage hook), blurb (two short sentences: the facts with their numbers, then one roast line), alert (1 minor, 3 notable, 5 the biggest thing on the site today). Examples. In: {\"kind\":\"rage_quit\",\"streamer\":\"goosely\",\"facts\":{\"minutes_live\":6,\"usual_minutes\":140}} Out: {\"headline\":\"🚨 goosely RAGE QUITS AFTER 6 MINUTES\",\"blurb\":\"goosely usually lasts 140 minutes. Today the stream died in 6, faster than a microwave burrito.\",\"alert\":3} In: {\"kind\":\"ghost_town\",\"streamer\":\"finditfixit\",\"facts\":{\"minutes\":94,\"peak\":0,\"messages\":0}} Out: {\"headline\":\"🚨 finditfixit STREAMS 94 MINUTES TO ABSOLUTELY NOBODY\",\"blurb\":\"Peak viewers: 0. Chat messages: 0. The only thing watching was the encoder.\",\"alert\":2} Output only the JSON.",
-- user_prompt: "{{json payload}}",
-- default_route: 'live.creative', owner: 'live', metadata: { json_name: 'arena_drama' },
-- },
{
key: 'live.rundown.segment', name: "Live: what a streamer talked about (The Rundown)",
description: "From rundown/desk.js: a stretch of one stream's speech-to-text lines in; the segments of what the streamer talked about out (topic, headline, what, take, the desk's quip, verbatim quotes).",
@@@ -259,24 +223,6 @@ const templates = [
user_prompt: '{{json payload}}',
default_route: 'live.summary', owner: 'live', metadata: { json_name: 'home_star' },
},
-- {
-- key: 'live.arena.judge_beef', name: 'Live: Arena beef judge',
-- description: 'From arena/listener.js judgeBeef (arena_beef_judge): is the newest speech still about the target, is it trash talk at them, how good, the best quotable line.',
-- input_schema: { type: 'object' },
-- output_schema: {"type": "object", "additionalProperties": false, "required": ["about_target", "aimed_at_target", "quality", "best_line", "about", "announcer", "flagged"], "properties": {"about_target": {"type": "boolean", "description": "true if this speech is (still) about the target — by name OR by reference (\"he\", \"his stream\", \"that guy\", \"the beef\", continuing the same rant)"}, "aimed_at_target": {"type": "boolean", "description": "true if the speaker is trash-talking, roasting, calling out, dunking on or bragging over the target (in good fun) — requires about_target"}, "announcer": {"type": "string", "description": "one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)"}, "quality": {"type": "integer", "minimum": 0, "maximum": 10, "description": "how good the trash talk is: spice, wit, specificity, quotability"}, "best_line": {"type": "string", "description": "the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own, words as spoken (fix casing/punctuation, drop stutters and filler, never change or add words). Empty if no clean line exists — a garbled fragment must not be quoted"}, "about": {"type": "string", "description": "≤ 10 words: what they said about the target"}, "flagged": {"type": "boolean", "description": "ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language"}}},
-- system_prompt: "You judge live streamer-vs-streamer shit talk (battle-cam style). You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person (\"he\", \"his chat\", \"that guy\", \"the beef\", the same story) or have moved on. DEFAULT TO about_target=false: gameplay chatter, mumbling and generic sentences are NOT about them even if their name was said a minute ago; if how_the_name_was_matched is not \"exact\" (a sound-alike), be extra strict. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus a neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.",
-- user_prompt: '{{json payload}}',
-- default_route: 'live.chat', owner: 'live', metadata: { json_name: 'arena_beef_judgement' },
-- },
-- {
-- key: 'live.arena.judge_mic', name: 'Live: Arena mic judge',
-- description: 'From arena/listener.js judgeMic (arena_mic_judge): is free mic chatter real trash talk with a clean quotable line.',
-- input_schema: { type: 'object' },
-- output_schema: {"type": "object", "additionalProperties": false, "required": ["is_trash_talk", "garbled", "quality", "best_line", "about", "aimed_at", "announcer", "flagged"], "properties": {"is_trash_talk": {"type": "boolean", "description": "true ONLY if the speaker is actually talking shit: roasting, calling someone out, bragging over someone, ranting AT someone (chat, a group, a person), disrespect, trolling, \"come see me\" energy — AND there is a clean, complete, quotable line for it"}, "garbled": {"type": "boolean", "description": "true if the transcript is too mangled (misheard words, word salad, fragments) to quote a coherent line — then is_trash_talk must be false"}, "quality": {"type": "integer", "minimum": 0, "maximum": 10, "description": "how good the shit talk is: 8–10 = a stranger would laugh or screenshot it; 6–7 = solid, specific, quotable; ≤5 = generic, lazy or half a thought; 0 when not trash talk"}, "best_line": {"type": "string", "description": "the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own. Copy the words as spoken — you may fix casing and punctuation and drop stutters / filler (uh, um, repeated words) but never change, add or reorder words. Empty if no clean line exists"}, "about": {"type": "string", "description": "≤ 10 words: what the shit talk was about"}, "aimed_at": {"type": "string", "description": "who or what it is aimed at, as said or clearly implied: a streamer name, \"chat\", \"the mods\", \"twitch streamers\", a game, \"nobody\" — ≤ 6 words, lowercase"}, "announcer": {"type": "string", "description": "one-line ring-announcer call of the moment, ≤ 110 chars, hype and funny (empty if not trash talk)"}, "flagged": {"type": "boolean", "description": "ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language"}}},
-- system_prompt: "You judge a live streamer's raw mic chatter for BATTLE-CAM style shit talk. You get ~60–90 seconds of speech-to-text — it is noisy: names get misheard, words get mangled, sentences get cut. DEFAULT TO is_trash_talk=false: gameplay narration, reading chat, small talk, \"um\", stories, neutral opinions → false, quality 0. Say true only when they are genuinely talking shit — roasting someone, calling someone out, bragging over someone, ranting AT chat or a group, disrespect, trolling, \"pull up\" energy — AND you can quote a clean, complete line for it. QUOTABILITY IS THE BAR: best_line must be a full sentence of at least 6 words that a reader understands with no context. If the good part is garbled or only a fragment, set garbled=true and is_trash_talk=false rather than quoting nonsense; a half-line in the feed is worse than nothing. Score the craft honestly: 8–10 only for lines a stranger would laugh at or screenshot; 6–7 solid and specific; 5 or below generic or lazy. Say who it is aimed at (a name if one is said or clearly meant, else \"chat\", \"the mods\", a group, a game, or \"nobody\"), summarise in ≤ 10 words, and write a one-line ring-announcer call. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.",
-- user_prompt: '{{json payload}}',
-- default_route: 'live.chat', owner: 'live', metadata: { json_name: 'arena_mic_judgement' },
-- },
{
key: 'live.moments.pick', name: 'Live: pick one moment of a VOD',
description: 'From ai-moments-job.js _findBestMoment (moment_pick): the most interesting visible moment for a screenshot paste or a clip, away from moments already used.',
@@@ -300,7 +246,7 @@ const templates = [
type: 'object', additionalProperties: false, required: ['headline', 'summary', 'moment', 'tags', 'grade'], properties: {
headline: { type: 'string', description: '≤ 70 chars, like a sports-page headline about this stream, no quotes, no emojis' },
summary: { type: 'string', description: '2–3 sentences, ≤ 420 chars, what happened and how it went, concrete, warm, second person is fine' },
-- moment: { type: 'string', description: '≤ 160 chars, the single moment of the night (from the mic lines, chat spike, a clip title or the transcript) — or empty string' },
++ moment: { type: 'string', description: '≤ 160 chars, the single moment of the night (from the best lines, chat spike, a clip title or the transcript) — or empty string' },
tags: { type: 'array', items: { type: 'string' }, description: '3 short vibe tags, 1–2 words each, lowercase' },
grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },
},
@@@ -421,38 +367,6 @@ const structured = [
steps: [{ kind: 'llm', template: 'live.viewers.clone', operation: 'generate', output: 'text', prepare: 'live.viewers.clone', postprocess: 'live.viewers.clone', params: { max_tokens: 160, temperature: 0.7, timeout_ms: 20000 } }],
cache_mode: 'none',
},
-- {
-- 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.arena.drama', name: "Arena Alert story", namespace: 'live',
-- description: "arena/drama.js: one measured event (kind, the streamers, a plain account of what happened, the facts, an optional quote) in; a grounded drama-news headline, blurb and alert level out.",
-- input_schema: { type: 'object', required: ['kind', 'facts'], properties: { kind: { type: 'string', maxLength: 40 }, streamer: { type: 'string', maxLength: 80 }, other: { type: ['string', 'null'], maxLength: 80 }, what: { type: 'string', maxLength: 600 }, facts: { type: 'object' }, quote: { type: ['string', 'null'], maxLength: 400 } } },
-- output_schema: {"type": "object", "additionalProperties": false, "required": ["headline", "blurb", "alert"], "properties": {"headline": {"type": "string"}, "blurb": {"type": "string"}, "alert": {"type": "integer", "minimum": 1, "maximum": 5}}},
-- steps: [{ kind: 'llm', template: 'live.arena.drama', operation: 'generate', output: 'json', prepare: 'live.arena.drama', postprocess: 'live.arena.drama', params: { max_tokens: 220, temperature: 1.0, timeout_ms: 20000 } }],
-- cache_mode: 'none',
-- },
{
key: 'live.rundown.segment', name: "The Rundown: what a streamer talked about", namespace: 'live',
description: "rundown/desk.js: a stretch of a stream's transcript lines (with the stream's title and category, the known topics and what came earlier) in; up to 4 segments out, each with a topic, headline, what, take, quip, score and verbatim quotes.",
@@@ -555,29 +469,6 @@ const structured = [
steps: [{ kind: 'llm', template: 'live.home.star', operation: 'generate', output: 'json', prepare: 'live.home.star', postprocess: 'live.home.star', params: { max_tokens: 300, temperature: 0.9, timeout_ms: 30000 } }],
cache_mode: 'none',
},
-- {
-- key: 'live.arena.judge_beef', name: 'Arena: judge a callout at another fighter', namespace: 'live',
-- description: 'The prompt Live used to render (arena/listener.js), now a versioned template: the target names, how the name was heard, what was already said about them, and the newest speech in; the judgement out.',
-- input_schema: TEXT_OUT({
-- target_names: { type: 'array', minItems: 1, maxItems: 8, items: STR(120) },
-- target_as_transcribed: { type: 'array', maxItems: 8, items: STR(120) },
-- target_named_in_new_speech: { type: 'boolean' },
-- how_the_name_was_matched: STR(40),
-- what_speaker_already_said_about_target: { type: ['string', 'null'], maxLength: 4000 },
-- new_speech: { type: 'string', minLength: 1, maxLength: 6000 },
-- }, ['target_names', 'new_speech']),
-- output_schema: {"type": "object", "additionalProperties": false, "required": ["about_target", "aimed_at_target", "quality", "best_line", "about", "announcer", "flagged"], "properties": {"about_target": {"type": "boolean", "description": "true if this speech is (still) about the target — by name OR by reference (\"he\", \"his stream\", \"that guy\", \"the beef\", continuing the same rant)"}, "aimed_at_target": {"type": "boolean", "description": "true if the speaker is trash-talking, roasting, calling out, dunking on or bragging over the target (in good fun) — requires about_target"}, "announcer": {"type": "string", "description": "one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)"}, "quality": {"type": "integer", "minimum": 0, "maximum": 10, "description": "how good the trash talk is: spice, wit, specificity, quotability"}, "best_line": {"type": "string", "description": "the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own, words as spoken (fix casing/punctuation, drop stutters and filler, never change or add words). Empty if no clean line exists — a garbled fragment must not be quoted"}, "about": {"type": "string", "description": "≤ 10 words: what they said about the target"}, "flagged": {"type": "boolean", "description": "ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language"}}},
-- steps: [{ kind: 'llm', template: 'live.arena.judge_beef', operation: 'generate', output: 'json', prepare: 'live.arena.judge_beef', postprocess: 'live.arena.judge', params: { max_tokens: 240, temperature: 0.4, timeout_ms: 25000 } }],
-- cache_mode: 'none',
-- },
-- {
-- key: 'live.arena.judge_mic', name: 'Arena: judge free mic chatter', namespace: 'live',
-- description: 'The prompt Live used to render (arena/listener.js), now a versioned template: 60-90 seconds of speech in; whether it is quotable trash talk out.',
-- input_schema: TEXT_OUT({ speech: { type: 'string', minLength: 1, maxLength: 6000 } }, ['speech']),
-- output_schema: {"type": "object", "additionalProperties": false, "required": ["is_trash_talk", "garbled", "quality", "best_line", "about", "aimed_at", "announcer", "flagged"], "properties": {"is_trash_talk": {"type": "boolean", "description": "true ONLY if the speaker is actually talking shit: roasting, calling someone out, bragging over someone, ranting AT someone (chat, a group, a person), disrespect, trolling, \"come see me\" energy — AND there is a clean, complete, quotable line for it"}, "garbled": {"type": "boolean", "description": "true if the transcript is too mangled (misheard words, word salad, fragments) to quote a coherent line — then is_trash_talk must be false"}, "quality": {"type": "integer", "minimum": 0, "maximum": 10, "description": "how good the shit talk is: 8–10 = a stranger would laugh or screenshot it; 6–7 = solid, specific, quotable; ≤5 = generic, lazy or half a thought; 0 when not trash talk"}, "best_line": {"type": "string", "description": "the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own. Copy the words as spoken — you may fix casing and punctuation and drop stutters / filler (uh, um, repeated words) but never change, add or reorder words. Empty if no clean line exists"}, "about": {"type": "string", "description": "≤ 10 words: what the shit talk was about"}, "aimed_at": {"type": "string", "description": "who or what it is aimed at, as said or clearly implied: a streamer name, \"chat\", \"the mods\", \"twitch streamers\", a game, \"nobody\" — ≤ 6 words, lowercase"}, "announcer": {"type": "string", "description": "one-line ring-announcer call of the moment, ≤ 110 chars, hype and funny (empty if not trash talk)"}, "flagged": {"type": "boolean", "description": "ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language"}}},
-- steps: [{ kind: 'llm', template: 'live.arena.judge_mic', operation: 'generate', output: 'json', prepare: 'live.arena.judge_mic', postprocess: 'live.arena.judge', params: { max_tokens: 240, temperature: 0.4, timeout_ms: 25000 } }],
-- cache_mode: 'none',
-- },
{
key: 'live.moments.pick', name: 'Pick one moment of a VOD (a screenshot paste or a clip)', namespace: 'live',
description: 'The prompt Live used to render (ai-moments-job.js), now a versioned template: timeline, transcript, sounds, clip and chat signals in; one moment out.',
@@@ -647,8 +538,6 @@ const PASSTHROUGH = [
['live.complete', 'Any other Live completion', 'Fallback for a Live llm.complete() kind without its own workflow yet.'],
['live.viewers.line', 'AI viewers: single line', 'viewers/budget.js generate() — ai_viewers (chat reply).'],
['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.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.status_check', 'Provider probe', 'ai-analysis.testStatus / llm.testProvider — status_check.'],
];
@@@ -665,7 +554,6 @@ const KIND_TO_WORKFLOW = {
ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',
-- arena_persona: 'live.arena.persona', arena_quotes: 'live.arena.quotes', arena_scene: 'live.arena.scene', arena_beef_judge: 'live.arena.judge', arena_mic_judge: 'live.arena.judge', arena_headline: 'live.arena.headline',
status_check: 'live.status_check',
};
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex d1d3d9d..95a441d 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -67,6 +67,24 @@ function providerRecords(config, env) {
return out;
}
++// Workflows and templates whose callers are gone: the Arena's (Live#264, 2026-10-19; it became The Rundown).
++const RETIRED = ['live.arena.persona', 'live.arena.quotes', 'live.arena.headline', 'live.arena.drama', 'live.arena.judge_beef', 'live.arena.judge_mic',
++ 'live.arena.scene', 'live.arena.judge'];
++
++/** Retired with their callers: what is stored is archived, so nothing runs it and it leaves the admin lists. */
++async function retire({ registry, db, keys = RETIRED }) {
++ let n = 0;
++ for (const key of keys) {
++ for (const [kind, table] of [['workflow', 'workflows'], ['template', 'templates']]) {
++ for (const r of await db.prepare(`SELECT version FROM ${table} WHERE key = ? AND status <> 'archived'`).all(key)) {
++ await registry.setStatus(kind, key, r.version, 'archived', { actor: 'seed' });
++ n++;
++ }
++ }
++ }
++ return n;
++}
++
async function seed({ registry, quotas, config, env = process.env, db }) {
// Providers
for (const p of providerRecords(config, env)) {
@@@ -127,9 +145,9 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'food.vision', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 1200, response_format: 'json', timeout_ms: 60000, alias_of: 'default.json' });
// OpenVibe.Pics' alt text (pics.describe_image): JSON from an image, on the JSON pool like food.vision.
await registry.seedVersioned('route', 'pics.vision', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 400, response_format: 'json', timeout_ms: 60000, alias_of: 'default.json' });
-- // Writing people read for fun (the Arena's drama desk, live.arena.drama): the chat pool with a stronger model as its
++ // Writing people read for fun (The Rundown's desk, live.rundown.*): the chat pool with a stronger model as its
// authority (AI_CREATIVE_MODEL, gpt-5-mini on OpenAI's API by default; the shared default elsewhere). The nano default
-- // misspelled names and wrote flat copy (2026-10-16); the desk makes at most ~20 short calls an hour.
++ // misspelled names and wrote flat copy (2026-10-16); the desk makes at most 40 calls an hour.
const creativeModel = env.AI_CREATIVE_MODEL || (shared && shared.kind === 'openai' && /api\.openai\.com/i.test(shared.base_url || '') ? 'gpt-5-mini' : null);
if (creativeModel && !await registry.getModel('shared', creativeModel)) await registry.upsertModel({ provider_key: 'shared', model_key: creativeModel, type: 'chat', supports: { json: true, vision: true } }, { actor: 'seed' });
await registry.seedVersioned('route', 'live.creative', pool(ROLE_TIMEOUT.chat, creativeModel ? { constraints: { objective: 'correctness', latency_class: 'interactive', model: creativeModel, authority: `shared:${creativeModel}` } } : {}));
@@@ -149,6 +167,7 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
++ await retire({ registry, db });
// Quotas
const seedQuota = async (q) => {
@@@ -160,4 +179,4 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
if (config.quotas.serviceRequestsPerDay > 0) await seedQuota({ scope_type: 'service', scope_id: '*', window: 'day', max_requests: config.quotas.serviceRequestsPerDay });
}
--module.exports = { seed, providerRecords, HISTORICAL_ROUTES };
++module.exports = { seed, retire, providerRecords, HISTORICAL_ROUTES, RETIRED };
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex eb095be..e98d508 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -3,8 +3,6 @@
// live.moments.pick: the prepare hook formats the timeline, transcript and sounds as [m:ss] lines, names clip and
// chat signals, adds the flavor hint, and writes the "already used" line only when there is something to avoid;
// nothing to work from is no run; the postprocess refuses an answer without a time.
--// live.arena.judge_beef / judge_mic: the user message is the same JSON object Live sent, keys in the same order;
--// the system prompt and the JSON schema are Live's; an answer without a numeric quality is no answer.
const assert = require('assert');
const { suite } = require('./helpers');
const { PREPARE, POSTPROCESS } = require('../server/workflows/hooks');
@@@ -39,21 +37,6 @@ t.test('the answer is read like Live read it', () => {
assert.strictEqual(post({ text: 'no json at all' }), null);
});
--t.test('the Arena judges send what Live sent', () => {
-- const beef = live.templates.find((x) => x.key === 'live.arena.judge_beef');
-- const mic = live.templates.find((x) => x.key === 'live.arena.judge_mic');
-- assert.ok(beef && mic);
-- assert.match(beef.system_prompt, /^You judge live streamer-vs-streamer shit talk/);
-- assert.deepStrictEqual(beef.output_schema.required, ['about_target', 'aimed_at_target', 'quality', 'best_line', 'about', 'announcer', 'flagged']);
-- const input = { target_names: ['ann'], target_as_transcribed: ['an'], target_named_in_new_speech: false, how_the_name_was_matched: 'sound-alike', what_speaker_already_said_about_target: 'called her washed', new_speech: 'she is still washed' };
-- const user = render(beef.user_prompt, PREPARE['live.arena.judge_beef'](input).vars);
-- assert.strictEqual(user, JSON.stringify(input), 'the same object, keys in the same order');
-- assert.strictEqual(render(mic.user_prompt, PREPARE['live.arena.judge_mic']({ speech: 'x y' }).vars), JSON.stringify({ speech: 'x y' }));
-- const post = POSTPROCESS['live.arena.judge'];
-- assert.strictEqual(post({ json: { quality: 'high' } }), null);
-- assert.strictEqual(post({ json: { is_trash_talk: true, quality: 14.6 } }).quality, 10);
--});
--
t.test('slogans, the daily secret and the star render as Live rendered them', () => {
const tp = (k) => live.templates.find((x) => x.key === k);
const sl = render(tp('live.hero.slogans').user_prompt, PREPARE['live.hero.slogans']({ users: [{ name: 'goosely', text: 'loves croutons' }], usernames: ['goosely'], count: 20 }).vars);
@@@ -102,18 +85,10 @@ 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', () => {
++t.test('clip confirmation renders as Live rendered it', () => {
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: '' });
});
@@@ -150,7 +125,6 @@ t.test("a channel description is public prose: never about looks, no lists or la
assert.match(tp('live.hero.slogans').user_prompt, /nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes/);
assert.match(tp('live.stream.describe_frame').user_prompt, /caption naming what happens, never what someone looks like or wears/);
assert.match(tp('live.stream.titles').user_prompt, /never what anyone looks like or wears/);
-- assert.match(tp('live.arena.drama').system_prompt, /never guess a pronoun/);
const out = POSTPROCESS['live.streamer.overview']({ text: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios\n- Tone: calm, patient\n\nAnn streams games: mostly Rust.' });
assert.deepStrictEqual(out, { overview: 'finditfixit (@finditfixit). repairs old TVs and radios. calm, patient. Ann streams games: mostly Rust.' });
assert.strictEqual(POSTPROCESS['live.streamer.overview']({ text: '## Overview\n' }), null, 'nothing but a heading is no overview');
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex 5c44eaf..61b6269 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -62,10 +62,6 @@ EXAMPLES['live.viewers.plan'] = { stable: 'Stream: Rust night by ann. Roster: go
EXAMPLES['live.viewers.reply'] = { stable: 'Stream: Rust night by ann. Roster: goosebot.', situation: 'ann is celebrating a build.', bot: 'goosebot', streamer_line: 'goosebot what do you think', max_words: 18 };
EXAMPLES['live.viewers.fold'] = { channel_memory: 'croutons', personas: [{ username: 'goosebot', memory: '', lines: ['honk', 'croutons are soup confetti', 'compile faster'] }] };
EXAMPLES['live.viewers.clone'] = { name: 'bob', overview: 'Hypes every build.', memory: '', samples: ['LETS GO', 'W'] };
--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.arena.drama'] = { kind: 'rage_quit', streamer: 'bob', other: null, what: 'bob usually goes 140 minutes. Today it lasted 6.', facts: { minutes_live: 6, usual_minutes: 140, last_title: 'ranked grind' }, quote: 'this game is rigged' };
EXAMPLES['live.rundown.segment'] = { streamer: 'goosely', title: 'Tech news', category: 'Science & Technology', topics: ['Supermarket fridges hacked in France'], earlier: 'Opened with a router rant.', lines: [{ t: 1081, text: 'The person that deals with refrigeration, I am not going to say is stupid.' }, { t: 1096, text: 'Having the internet inside of a refrigerator is a bad idea.' }] };
EXAMPLES['live.rundown.episode'] = { streamer: 'goosely', title: 'Tech news', category: 'Science & Technology', minutes: 140, peak_viewers: 6, chat_messages: 210, segments: [{ t: 1081, topic: 'Supermarket fridges hacked in France', title: 'Fridges on the internet', what: 'They read about hacked supermarket fridges.', take: 'Internet fridges are a bad idea.', quotes: ['Having the internet inside of a refrigerator is a bad idea.'] }] };
EXAMPLES['live.rundown.topic'] = { topic: 'Supermarket fridges hacked in France', takes: [{ streamer: 'goosely', when: '2026-10-18', what: 'They read about hacked supermarket fridges.', take: 'Internet fridges are a bad idea.', quote: 'Having the internet inside of a refrigerator is a bad idea.' }] };
@@@ -84,8 +80,6 @@ EXAMPLES['live.easter_egg'] = { vibe: 'croutons' };
EXAMPLES['space.app.build'] = { prompt: 'A guestbook where people sign in and leave a one-line note', name: 'Guestbook', current: [{ path: 'index.html', content: '<!doctype html><title>Guestbook</title><script src="/_openvibe/sdk.js"></script>' }] };
EXAMPLES['space.app.ask'] = { space: 'Pirate helper', instructions: 'Talk like a pirate.', transcript: 'visitor: hi\nai: Ahoy!', message: 'What is a good name for a parrot?' };
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.arena.judge_beef'] = { target_names: ['ann', 'Ann the Great'], target_as_transcribed: ['ann', 'an'], target_named_in_new_speech: true, how_the_name_was_matched: 'exact', what_speaker_already_said_about_target: null, new_speech: 'ann your stream is so boring even your bots left chat' };
--EXAMPLES['live.arena.judge_mic'] = { speech: 'chat you guys are the worst mods I have ever seen in my life' };
EXAMPLES['live.moments.pick'] = { title: 'Rust night', flavor: 'clip', timeline: [{ t: 12, text: 'the build fails' }, { t: 95, text: 'streamer jumps up cheering' }], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 96, label: 'Cheering' }], clipped: [95], spikes: [96], avoid: [300] };
const t = suite('workflows');
@@@ -114,6 +108,16 @@ t.test('boot seeds every required workflow key', async () => {
}
});
++t.test("a retired workflow (the Arena's, Live#264) is never seeded and what is stored is archived", async () => {
++ const { retire, RETIRED } = require('../server/workflows/seed');
++ const keys = (await h.registry.listWorkflows()).map((w) => w.key);
++ assert.ok(!RETIRED.some((k) => keys.includes(k)), 'a fresh database has none of them');
++ const calls = [];
++ const db = { prepare: (sql) => ({ all: async (key) => (/FROM workflows/.test(sql) && key === 'live.arena.drama' ? [{ version: 1 }, { version: 2 }] : []) }) };
++ assert.strictEqual(await retire({ registry: { setStatus: async (...a) => calls.push(a.slice(0, 4)) }, db }), 2);
++ assert.deepStrictEqual(calls, [['workflow', 'live.arena.drama', 1, 'archived'], ['workflow', 'live.arena.drama', 2, 'archived']], 'every stored version is archived');
++});
++
t.test('every workflow has compiling, versioned input and output schemas and an example', async () => {
for (const w of await h.registry.listWorkflows()) {
assert.strictEqual(w.version, 1);