openvibers/openvibe.ai Commits

The home slogans, the daily secret and the Star of OpenVibe are versioned templates (roadmap WS-O task 2). live.hero.slogans, live.easter_egg and live.home.star move from passthrough to structured workflows (version 2 of each key), with Live's prompts verbatim (slogan-job.js, easter-egg-job.js, home/star-job.js). Live now sends data: the chat vibe, per-user notes, streamers, VODs and usernames; today's vibe; recent stars and candidates with their numbers. The prepare hooks format it as Live did ('(quiet)', '(none yet)', the optional vibe line, the same JSON object). All three answer in JSON mode; the postprocess keeps string lists, maps an unknown effect to confetti and refuses a star without a username. test/live-templates.test.js checks the rendering; workflows.test runs them end to end. npm test passes.

OP OpenVibers committed 28 Sept 2026 · 7c01c6509a · 4 files, +111 −3


Changes

ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex 28fa5c9..66d748c 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -76,6 +76,23 @@ const MOMENT_FLAVOR = {
const oneLine = (t, max) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, max);
const PREPARE = {
++ '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: {
++ global: String(input.global || '').slice(0, 1800) || '(quiet)',
++ users: block((input.users || []).slice(0, 8), 64, 1600) || '(none yet)',
++ streamers: block((input.streamers || []).slice(0, 8), 64, 1400) || '(none yet)',
++ vods: (input.vods || []).slice(0, 10).map((v) => `- ${String(v.name || '').slice(0, 60)}: ${String(v.text || '').replace(/\s+/g, ' ').slice(0, 140)}`).join('\n').slice(0, 1400) || '(none yet)',
++ usernames: (input.usernames || []).join(', ') || '(none yet)',
++ target: input.count || 20,
++ } };
++ },
++ 'live.easter_egg'(input) {
++ return { vars: { vibe: String(input.vibe || '').slice(0, 400) } };
++ },
++ '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: {
@@@ -198,6 +215,23 @@ const PREPARE = {
};
const POSTPROCESS = {
++ '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) : []);
++ if (!j || (!list(j.audiences).length && !list(j.quips).length)) return null;
++ return { audiences: list(j.audiences), quips: list(j.quips) };
++ },
++ 'live.easter_egg'(r) {
++ const j = r.json || parseJsonLoose(r.text);
++ if (!j || !Array.isArray(j.code) || !j.code.length) return null;
++ const strs = (v) => (Array.isArray(v) ? v.map((x) => String(x)).slice(0, 12) : []);
++ return { title: String(j.title || '').slice(0, 80), code: strs(j.code), clues: strs(j.clues || j.hints).map((c) => c.slice(0, 200)), effect: ['confetti', 'fireworks', 'matrix', 'rainbow', 'shake'].includes(j.effect) ? j.effect : 'confetti', reward: String(j.reward || '').slice(0, 300) };
++ },
++ 'live.home.star'(r) {
++ const j = r.json || parseJsonLoose(r.text);
++ 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);
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex 908a482..e80f027 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -79,6 +79,33 @@ 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.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.',
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["audiences", "quips"], "properties": {"audiences": {"type": "array", "items": {"type": "string"}}, "quips": {"type": "array", "items": {"type": "string"}}}},
++ system_prompt: '',
++ user_prompt: "You write the rotating hero copy for OpenVibe.Live — a scrappy, open-source, community-run live-streaming site at the heart of the OpenVibe network (\"Open Live Streaming\" — good vibes, no suits). Never call anything \"free\", \"$0\" or \"no cost\". Voice: witty, warm, self-aware, anti-corporate, meme-literate, a little unhinged — but ALWAYS kind, never punching down.\n\nEverything below is REAL data about THIS community right now. Lean into it hard: reference the actual people, running jokes, recurring topics, and memes so the copy feels like an inside joke the community is in on. Reference usernames by name in good fun (no @), and NEVER mock or embarrass anyone.\n\n=== GLOBAL CHAT VIBE (overview + memory + timeline) ===\n{{global}}\n\n=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===\n{{users}}\n\n=== STREAMERS (what they stream) ===\n{{streamers}}\n\n=== RECENT VODS (what's been on) ===\n{{vods}}\n\n=== ACTIVE USERNAMES you may reference kindly ===\n{{usernames}}\n\n=== TASK ===\nProduce STRICT JSON, exactly this shape and nothing else:\n{\n \"audiences\": [ {{target}} noun phrases naming WHO the site is for. The page prints the fixed headline \"Live streaming for {phrase}\" — you supply ONLY {phrase}. The words \"live\", \"streaming\", \"stream\", \"streamers\" and \"for\" must NOT appear anywhere in a phrase. Good: \"van-dwelling coders\", \"goosely's loyal 3 viewers\", \"crouton connoisseurs\". Bad: \"live streaming for coders\", \"coders live streaming\", \"for coders\". 1-5 words, lowercase (keep usernames' casing), plural people-nouns, no trailing punctuation. Mix on-theme audiences with community in-jokes drawn from the data. ],\n \"quips\": [ {{target}} standalone one-liner taglines, punchy, <= 75 chars. Several should be clear references/memes about the real streamers, VODs, running jokes, or usernames above. ]\n}\nReturn ONLY the JSON object.",
++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'hero_slogans' },
++ },
++ {
++ key: 'live.easter_egg', name: 'Live: the daily secret code',
++ description: 'From easter-egg-job.js (easter_egg): a Konami-style code with one fair clue per token.',
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["title", "code", "clues", "effect", "reward"], "properties": {"title": {"type": "string"}, "code": {"type": "array", "items": {"type": "string"}}, "clues": {"type": "array", "items": {"type": "string"}}, "effect": {"type": "string", "enum": ["confetti", "fireworks", "matrix", "rainbow", "shake"]}, "reward": {"type": "string"}}},
++ system_prompt: '',
++ user_prompt: "Invent today's secret \"Konami-style\" code for OpenVibe.Live, a hobbyist live-streaming site.{{#vibe}}\nToday's community vibe (for flavour only): {{vibe}}{{/vibe}}\nReturn STRICT JSON only, nothing else:\n{\n \"title\": \"<punchy name for today's secret, 2-5 words>\",\n \"code\": [<5 to 7 tokens IN ORDER, each exactly one of: \"up\",\"down\",\"left\",\"right\", or a single lowercase letter a-z; use at least 2 arrows and at least 1 letter>],\n \"clues\": [<EXACTLY one clue per token, same order, each ≤ 90 chars>],\n \"effect\": \"<one of: confetti, fireworks, matrix, rainbow, shake>\",\n \"reward\": \"<a short, fun congratulatory line shown when a user cracks it>\"\n}\nCLUE RULES (this is a game people must actually be able to win):\n- Clue i describes token i and ONLY token i. Never list the token literally, but make it fair: a normal person should get it in one or two guesses.\n- Arrow clues use direction imagery (\"where the sun rises\" = right, \"toward the sky\" = up, \"the way rain falls\" = down, \"the side your heart is on\" = left).\n- Letter clues MUST quote a common word that starts with that exact letter, in quotes: \"the letter that starts 'goose'\" = g, \"'vibe' begins with it\" = v. Double-check the first letter of the quoted word.\n- Keep the flavour playful and on-theme, but clarity beats cleverness.",
++ default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'easter_egg' },
++ },
++ {
++ key: 'live.home.star', name: 'Live: Star of OpenVibe',
++ description: 'From home/star-job.js (home_star): the streamer the home page features for a day, with a headline and a reason.',
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["username", "headline", "reason"], "properties": {"username": {"type": "string", "description": "exact username from the candidates"}, "headline": {"type": "string", "description": "≤ 60 chars"}, "reason": {"type": "string", "description": "≤ 170 chars, one sentence"}}},
++ system_prompt: "You pick today's \"Star of OpenVibe\" — the one streamer OpenVibe.Live (a scrappy, open-source, community-run live-streaming site) rolls out the red carpet for on its home page for the next 24 hours.\nPick from the candidates ONLY (use the exact username). Spread the love: favour people who showed up and put in real hours, grew, got chat talking, or bring something different (a language, a niche, a robot, a vibe) — not just the biggest number. Small streamers who are consistent deserve their day. Never pick anyone in the \"recent stars\" list.\nWrite a \"headline\" (≤ 60 chars, punchy, warm, no quotes) and a \"reason\" (≤ 170 chars, one sentence, second person is fine, reference their real numbers or what they do — no emojis, no hashtags, no sarcasm, never mock). Output only JSON.",
++ 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.',
@@@ -209,6 +236,36 @@ 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.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").',
++ input_schema: TEXT_OUT({
++ global: STR(4000), users: { type: 'array', maxItems: 20, items: { type: 'object', required: ['name', 'text'], additionalProperties: false, properties: { name: STR(120), text: STR(1000) } } }, streamers: { type: 'array', maxItems: 20, items: { type: 'object', required: ['name', 'text'], additionalProperties: false, properties: { name: STR(120), text: STR(1000) } } }, vods: { type: 'array', maxItems: 20, items: { type: 'object', required: ['name', 'text'], additionalProperties: false, properties: { name: STR(120), text: STR(1000) } } },
++ usernames: { type: 'array', maxItems: 40, items: STR(64) }, count: { type: 'integer', minimum: 1, maximum: 40 },
++ }, []),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["audiences", "quips"], "properties": {"audiences": {"type": "array", "items": {"type": "string"}}, "quips": {"type": "array", "items": {"type": "string"}}}},
++ steps: [{ kind: 'llm', template: 'live.hero.slogans', operation: 'generate', output: 'json', prepare: 'live.hero.slogans', postprocess: 'live.hero.slogans', params: { max_tokens: 1700 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.easter_egg', name: 'The daily secret code', namespace: 'live',
++ description: 'The prompt Live used to render (easter-egg-job.js), now a versioned template: today\'s vibe in (optional); a code, clues, effect and reward out. Live checks every clue and replaces any that do not point at its token.',
++ input_schema: TEXT_OUT({ vibe: STR(1000) }, []),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["title", "code", "clues", "effect", "reward"], "properties": {"title": {"type": "string"}, "code": {"type": "array", "items": {"type": "string"}}, "clues": {"type": "array", "items": {"type": "string"}}, "effect": {"type": "string", "enum": ["confetti", "fireworks", "matrix", "rainbow", "shake"]}, "reward": {"type": "string"}}},
++ steps: [{ kind: 'llm', template: 'live.easter_egg', operation: 'generate', output: 'json', prepare: 'live.easter_egg', postprocess: 'live.easter_egg', params: { max_tokens: 600 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.home.star', name: 'Star of OpenVibe', namespace: 'live',
++ description: 'The prompt Live used to render (home/star-job.js), now a versioned template: recent stars, the previous star and up to 25 candidates with their numbers in; one username, a headline and a reason out.',
++ input_schema: TEXT_OUT({
++ recent_stars: { type: 'array', maxItems: 40, items: STR(64) }, previous_star: { type: ['string', 'null'], maxLength: 64 },
++ candidates: { type: 'array', minItems: 1, maxItems: 25, items: { type: 'object', required: ['username'], properties: { username: STR(64) } } },
++ }, ['candidates']),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["username", "headline", "reason"], "properties": {"username": {"type": "string", "description": "exact username from the candidates"}, "headline": {"type": "string", "description": "≤ 60 chars"}, "reason": {"type": "string", "description": "≤ 170 chars, one sentence"}}},
++ 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.',
@@@ -306,9 +363,6 @@ const PASSTHROUGH = [
['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.hero.slogans', 'Home hero slogans', 'slogan-job.js — hero_slogans.'],
-- ['live.easter_egg', 'Easter eggs', 'easter-egg-job.js — easter_egg.'],
-- ['live.home.star', 'Star of OpenVibe write-up', 'home/star-job.js — home_star.'],
['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.'],
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex 8568d6b..eb37e9b 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -54,4 +54,21 @@ t.test('the Arena judges send what Live sent', () => {
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);
++ assert.match(sl, /=== GLOBAL CHAT VIBE \(overview \+ memory \+ timeline\) ===\n\(quiet\)/);
++ assert.match(sl, /PER-USER CHAT ANALYSIS \(running jokes \/ personalities\) ===\n- goosely: loves croutons/);
++ assert.match(sl, /STREAMERS \(what they stream\) ===\n\(none yet\)/);
++ assert.match(sl, /"audiences": \[ 20 noun phrases/);
++ assert.ok(!/\{\{|\$\{/.test(sl), 'nothing left unrendered');
++ const egg = tp('live.easter_egg').user_prompt;
++ assert.match(render(egg, PREPARE['live.easter_egg']({ vibe: 'croutons' }).vars), /site\.\nToday's community vibe \(for flavour only\): croutons\nReturn STRICT JSON/);
++ assert.match(render(egg, PREPARE['live.easter_egg']({}).vars), /site\.\nReturn STRICT JSON/, 'no vibe line without a vibe');
++ const star = { recent_stars: ['bob'], previous_star: null, candidates: [{ username: 'ann' }] };
++ assert.strictEqual(render(tp('live.home.star').user_prompt, PREPARE['live.home.star'](star).vars), JSON.stringify(star));
++ assert.deepStrictEqual(POSTPROCESS['live.easter_egg']({ json: { title: 'T', code: ['up', 'g'], clues: ['sky', "'goose'"], effect: 'lava', reward: 'yay' } }).effect, 'confetti');
++ assert.strictEqual(POSTPROCESS['live.home.star']({ json: { headline: 'x' } }), null);
++});
++
t.run();
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex 4b68241..2eb3843 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -48,6 +48,9 @@ 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.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 } }] };
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] };