openvibers/openvibe.live Commits

Arena personas, quotes and headlines and the auto-clip check send OpenVibe.AI data, not prompts (roadmap WS-O task 2; AI d271436). generatePersona sends the fighter's facts; generateQuotes sends the candidate lines; headlineFor sends the event (open, forfeit, or score for a points win or draw) with its fighters; the auto-clip check sends scene notes, timed speech, sounds and chat around the spike, attributed to the stream. The prompts and JSON schemas left for AI's templates. Live keeps materialising quotes, storing personas (persona_model now names the workflow) and its fallbacks. npm test 148/148.

OP OpenVibers committed 28 Sept 2026 · e7ebaf536a · 3 files, +23 −67


Changes

ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex 69702a17..2f2a0c96 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -16,6 +16,7 @@
* off (a much stronger chat spike is then required).
*/
const db = require('../db/database');
++const aiService = require('./ai-service');
const ai = require('./ai-analysis');
const media = require('../media-client');
const recorder = require('../streaming/recorder');
@@@ -68,44 +69,25 @@ async function _confirmLiveMoment(stream) {
const memories = (db.getStreamMemories(stream.id) || []).filter(m => m.description).slice(-5);
const transcript = (db.getStreamTranscriptSegments(stream.id) || []).slice(-14);
const chat = db.getRecentChatText(stream.id, WINDOW_SEC, 40) || [];
-- const scene = memories.map(m => `- ${_clean(m.description, 160)}`).join('\n');
-- // Timestamps are now used, not discarded: recent speech is anchored in time so the
-- // model can line up what was said with the sounds and the chat spike.
-- const _mmss = (n) => `${Math.floor(n / 60)}:${String(Math.floor(n % 60)).padStart(2, '0')}`;
-- const script = transcript.map(s => `- [${_mmss(s.start || 0)}] ${_clean(s.text, 140)}`).join('\n');
-- // Sound events are a strong clip signal on their own — a burst of gunfire, an
-- // explosion or laughter is exactly the kind of thing viewers clip, and it is often
-- // the reason chat spiked in the first place.
++ if (!_aiOn()) return { clip: null }; // caller decides via the stricter no-AI threshold
++ // Sound events are a strong clip signal on their own — a burst of gunfire, an explosion or laughter is exactly
++ // the kind of thing viewers clip, and it is often the reason chat spiked in the first place.
let sounds = [];
try {
const since = Math.max(0, ((Date.now() - new Date(stream.started_at + 'Z').getTime()) / 1000) - WINDOW_SEC);
sounds = (db.getTimeline(stream.id, { kind: 'sound', from: since, limit: 40 }) || [])
.filter(e => Number(e.confidence || 0) >= 0.4).slice(-12);
} catch { /* timeline optional */ }
-- const soundBlock = sounds.map(e => `- [${_mmss(e.start_sec || 0)}] ${e.label} (${Number(e.confidence || 0).toFixed(2)})`).join('\n');
-- const chatBlock = chat.slice(-30).map(c => `- ${_clean(c, 100)}`).join('\n');
-- if (!_aiOn()) return { clip: null }; // caller decides via the stricter no-AI threshold
--
-- const 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.
--
--ON SCREEN (recent scene notes):
--${scene || '(none)'}
--
--WHAT WAS SAID (recent transcript):
--${script || '(none)'}
--
--WHAT WAS HEARD (non-speech sounds detected):
--${soundBlock || '(none)'}
--
--CHAT (recent messages):
--${chatBlock || '(none)'}
--
--Return 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.`;
++ // The decision rules are OpenVibe.AI's versioned template live.clips.confirm (WS-O task 2); Live sends what it saw,
++ // heard and read, with times so the model can line up speech, sounds and the chat spike.
try {
-- const text = await ai.summarizeText(prompt, 220, 'auto_clip_confirm');
-- const m = text && text.match(/\{[\s\S]*\}/);
-- if (!m) return { clip: false };
-- const j = JSON.parse(m[0]);
++ const j = await aiService.structured('live.clips.confirm', {
++ scene: memories.map(m => _clean(m.description, 160)).filter(Boolean).slice(0, 40),
++ transcript: transcript.map(x => ({ t: Math.max(0, Number(x.start) || 0), text: _clean(x.text, 140) })).filter(x => x.text).slice(-60),
++ sounds: sounds.map(e => ({ t: Math.max(0, Number(e.start_sec) || 0), label: String(e.label || '').slice(0, 120), confidence: Number(e.confidence || 0) })),
++ chat: chat.slice(-30).map(c => _clean(c, 100)).filter(Boolean),
++ }, { target: { service: 'live', type: 'stream', id: String(stream.id) }, meter: { kind: 'auto_clip_confirm', role: 'legacy', ownerUserId: stream.user_id } });
++ if (!j) return { clip: false };
return { clip: j.clip === true, title: _clean(j.title, 80), desc: _clean(j.desc, 400) };
} catch { return { clip: false }; }
}
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 8f24cd8f..ab0ade06 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -275,32 +275,6 @@ function gatherContext(userId) {
return ctx;
}
--const PERSONA_SCHEMA = {
-- name: 'arena_persona',
-- 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: STAT_KEYS, properties: Object.fromEntries(STAT_KEYS.map(k => [k, { type: 'string', description: `≤ 8 words explaining their ${k} rating in character` }])) },
-- },
-- },
--};
--const PERSONA_SYSTEM = `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.
--VOICE 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.
--TAUNTS 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.
--This 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.`;
async function generatePersona(userId, { force = false } = {}) {
ensureTables();
@@@ -321,11 +295,11 @@ async function generatePersona(userId, { force = false } = {}) {
their_best_shit_talk: ctx.shit_talk || [], who_they_call_out: ctx.called_out || [], who_calls_them_out: ctx.called_out_by || [], what_they_rant_at: ctx.aimed_at || [], quotes: ctx.quotes || [], mic_style: ctx.mic_style || null,
roster_rivals: (() => { try { return loadRoster().order.filter(id => id !== userId).slice(0, 8).map(id => { const p = parseJson(profileRow(id)?.persona_json); return `${loadRoster().byId[id].user.username}${p?.fighter_name ? ` (${p.fighter_name})` : ''}`; }); } catch { return []; } })(),
};
-- const r = await llm.complete({ role: 'summary', kind: 'arena_persona', source: 'arena', ownerUserId: userId, system: PERSONA_SYSTEM, user: `Write the Arena persona for this fighter. Facts (JSON):\n${JSON.stringify(facts)}`, json: PERSONA_SCHEMA, maxTokens: 1200, temperature: 0.95, timeoutMs: 30000 });
-- const persona = r && r.json && r.json.fighter_name ? r.json : null;
++ // The bio's rules and shape are OpenVibe.AI's versioned template live.arena.persona (WS-O task 2); Live sends the facts.
++ const persona = await require('../ai/ai-service').structured('live.arena.persona', { facts }, { meter: { kind: 'arena_persona', role: 'summary', ownerUserId: userId, source: 'arena' } });
if (!persona) { console.warn(`[Arena] persona generation failed for user ${userId}`); return row ? parseJson(row.persona_json) : null; }
db.run(`INSERT INTO arena_profiles (user_id, persona_json, persona_model, persona_generated_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
-- ON CONFLICT(user_id) DO UPDATE SET persona_json = excluded.persona_json, persona_model = excluded.persona_model, persona_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`, [userId, JSON.stringify(persona), r.model || null]);
++ ON CONFLICT(user_id) DO UPDATE SET persona_json = excluded.persona_json, persona_model = excluded.persona_model, persona_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`, [userId, JSON.stringify(persona), 'openvibe-ai:live.arena.persona']);
console.log(`[Arena] persona for ${entry.user.username}: "${persona.fighter_name}" (${persona.class})`);
return persona;
}
@@@ -358,8 +332,6 @@ function quoteCandidates(userId, limit = 90) {
for (let i = 0; i < rest.length && picked.length < limit; i += step) picked.push(rest[i]);
return picked;
}
--const QUOTES_SCHEMA = { name: 'arena_quotes', 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' } } } };
--const QUOTES_SYSTEM = `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.`;
function materializeQuotes(candidates, sel) {
const pick = (i, why) => { const c = candidates[i]; return c && !isBannedText(c.text) ? { text: c.text, stream_id: c.stream_id, vod_id: c.vod_id, start_sec: Math.max(0, Math.floor(Number(c.start_sec) || 0) - 2), why } : null; };
const picks = (sel.picks || []).map(p => pick(p.index, p.why)).filter(Boolean);
@@@ -374,8 +346,9 @@ async function generateQuotes(userId, { force = false } = {}) {
let result = null;
if (aiOn()) {
try {
-- const r = await llm.complete({ role: 'summary', kind: 'arena_quotes', source: 'arena', ownerUserId: userId, system: QUOTES_SYSTEM, user: `Lines (index: text):\n${candidates.map((c, i) => `${i}: ${c.text}`).join('\n')}`, json: QUOTES_SCHEMA, maxTokens: 500, temperature: 0.7, timeoutMs: 30000 });
-- if (r && r.json && Array.isArray(r.json.picks)) result = materializeQuotes(candidates, r.json);
++ // The picking rules are OpenVibe.AI's versioned template live.arena.quotes (WS-O task 2); Live sends the lines.
++ const sel = await require('../ai/ai-service').structured('live.arena.quotes', { lines: candidates.map(c => String(c.text).slice(0, 400)).slice(0, 120) }, { meter: { kind: 'arena_quotes', role: 'summary', ownerUserId: userId, source: 'arena' } });
++ if (sel && Array.isArray(sel.picks)) result = materializeQuotes(candidates, sel);
} catch (e) { console.warn('[Arena] quotes:', e.message); }
}
if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: candidates.slice(0, 5).map((c, i) => ({ index: i, why: 'straight from the transcript' })), walkout: 0 });
ddiff --git a/server/arena/beef.js b/server/arena/beef.js
iindex 4c6e55a8..1cc5c43c 100644
---- a/server/arena/beef.js
++++ b/server/arena/beef.js
@@@ -83,13 +83,14 @@ function mic() { return require('./mic'); }
function isLive(userId) { return !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }
function nowIso() { return new Date().toISOString(); }
function nameOf(userId) { return mic().nameOf(userId); }
--const HEADLINE_SCHEMA = { name: 'arena_headline', schema: { type: 'object', additionalProperties: false, required: ['headline'], properties: { headline: { type: 'string', description: '≤ 100 chars, tabloid, inflammatory but funny, names both fighters' } } } };
async function headlineFor(kind, ctx) {
try {
const llm = require('../ai/llm');
if (!(llm.isEnabled() && llm.withinBudget())) return null;
-- const r = await llm.complete({ role: 'chat', kind: 'arena_headline', source: 'arena', system: '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: JSON.stringify({ kind, ...ctx }), json: HEADLINE_SCHEMA, maxTokens: 80, temperature: 1.0, timeoutMs: 15000 });
-- return r && r.json && r.json.headline ? String(r.json.headline).slice(0, 120) : null;
++ // The headline rules are OpenVibe.AI's versioned template live.arena.headline (WS-O task 2).
++ const event = kind === 'open' || kind === 'forfeit' ? kind : 'score'; // a draw or a points win is a score
++ const out = await require('../ai/ai-service').structured('live.arena.headline', { kind: event, ...ctx }, { meter: { kind: 'arena_headline', role: 'chat', source: 'arena' } });
++ return out && out.headline ? String(out.headline).slice(0, 120) : null;
} catch { return null; }
}
function templateHeadline(kind, ctx) {