openvibers/openvibe.live Commits
The home slogans, the daily secret and the Star of OpenVibe send OpenVibe.AI data, not prompts (roadmap WS-O task 2; AI templates live.hero.slogans, live.easter_egg, live.home.star). The slogan job sends the chat vibe, per-user notes, streamer and VOD overviews, active usernames and the count. The secret sends today's vibe. The star sends recent stars, the previous star and the candidates with their numbers. Each keeps its own rules on the answer: the no-free and no-'streaming for' filters, the clue check that replaces clues not pointing at their token, only an eligible candidate. Each keeps its old fallback when there is no answer. test/ai-templates-jobs.test.js checks all three against a stub AI. npm test passes.
Changes
@@@ -8,6 +8,7 @@'use strict';const db = require('../db/database');const ai = require('./ai-analysis');++const aiService = require('./ai-service');let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* */ }const SETTING = 'daily_easter_egg';@@@ -97,25 +98,10 @@ async function _generate() {let vibe = '';try { const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {-- const 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}` : ''}--Return STRICT JSON only, nothing else:--{-- "title": "<punchy name for today's secret, 2-5 words>",-- "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>],-- "clues": [<EXACTLY one clue per token, same order, each ≤ 90 chars>],-- "effect": "<one of: confetti, fireworks, matrix, rainbow, shake>",-- "reward": "<a short, fun congratulatory line shown when a user cracks it>"--}--CLUE RULES (this is a game people must actually be able to win):--- 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.--- 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).--- 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.--- Keep the flavour playful and on-theme, but clarity beats cleverness.`;++ // The prompt and its clue rules are OpenVibe.AI's versioned template live.easter_egg (WS-O task 2).try {-- const text = await ai.summarizeText(prompt, 600, 'easter_egg');-- const m = text && text.match(/\{[\s\S]*\}/);-- if (m) {-- const j = JSON.parse(m[0]);++ const j = await aiService.structured('live.easter_egg', vibe ? { vibe } : {}, { meter: { kind: 'easter_egg', role: 'legacy' } });++ if (j) {const code = _sanitizeCode(j.code);if (code) {return {@@@ -216,7 +202,7 @@ function start() {console.log('[EasterEgg] Daily easter-egg job started');}--module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS };++module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS, _generate };// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`if (require.main === module) {@@@ -11,6 +11,7 @@*/'use strict';const db = require('../db/database');++const aiService = require('./ai-service');const ai = require('./ai-analysis');let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* optional */ }@@@ -21,14 +22,6 @@ const INTERVAL_MS = 7 * 60 * 60 * 1000;const TARGET = 20; // ~20 words + ~20 slogans per batchlet _timer = null, _busy = false;--function _parseJson(text) {-- if (!text) return null;-- let t = String(text).trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();-- try { return JSON.parse(t); } catch { /* */ }-- const m = t.match(/\{[\s\S]*\}/);-- if (m) { try { return JSON.parse(m[0]); } catch { /* */ } }-- return null;--}// The model has produced every wrong shape at least once: "live streaming for X", "for X",// "X live streaming", "X for live streaming". The hero prints "Live streaming for {X}", so X must// be the bare noun phrase. Strip the framing at both ends; anything still containing@@@ -91,69 +84,40 @@ async function tick() {`) || [];} catch { /* */ }const usernames = activeRows.map(r => r.username).filter(Boolean);-- let userCtx = '';++ // The prompt is OpenVibe.AI's versioned template live.hero.slogans (WS-O task 2); Live sends the real data.++ const users = [];try {-- const parts = [];for (const r of activeRows.slice(0, 8)) {let ins = null;try { ins = chatAi && chatAi.getUserInsight && chatAi.getUserInsight(r.id); } catch { /* */ }const blurb = ins && (ins.overview || ins.memory);-- if (blurb) parts.push(`- ${r.username}: ${String(blurb).replace(/\s+/g, ' ').slice(0, 180)}`);++ if (blurb) users.push({ name: String(r.username).slice(0, 120), text: String(blurb).replace(/\s+/g, ' ').slice(0, 180) });}-- userCtx = parts.join('\n').slice(0, 1600);} catch { /* */ }// ── Streamer AI overviews + recent VOD AI overviews ──-- let streamerCtx = '';++ let streamers = [];try {const rows = db.all(`SELECT u.username, COALESCE(so.overview_short, so.overview) AS ovFROM streamer_overviews so JOIN users u ON so.user_id = u.idWHERE COALESCE(u.is_banned,0)=0 AND so.overview IS NOT NULLORDER BY so.generated_at DESC LIMIT 8`) || [];-- streamerCtx = rows.map(r => `- ${r.username}: ${String(r.ov || '').replace(/\s+/g, ' ').slice(0, 180)}`).join('\n').slice(0, 1400);++ streamers = rows.map(r => ({ name: String(r.username).slice(0, 120), text: String(r.ov || '').replace(/\s+/g, ' ').slice(0, 180) }));} catch { /* */ }-- let vodCtx = '';++ let vods = [];try {// VODs live in OpenVibe.Media; overviews live in Live's vod_ai_state.const media = require('../media-client');const out = await media.listVods({ limit: 10 }).catch(() => null);-- const rows = (out?.vods || (Array.isArray(out) ? out : []))-- .map(v => ({ title: v.title, ai_overview: (db.getVodAiState && db.getVodAiState(v.id)?.ai_overview_short) || v.ai_overview_short || '' }))-- .filter(v => v.ai_overview && v.ai_overview.trim().length > 1);-- vodCtx = rows.map(r => `- ${String(r.title || '').slice(0, 60)}: ${String(r.ai_overview || '').replace(/\s+/g, ' ').slice(0, 140)}`).join('\n').slice(0, 1400);++ vods = (out?.vods || (Array.isArray(out) ? out : []))++ .map(v => ({ name: String(v.title || '').slice(0, 60), text: String((db.getVodAiState && db.getVodAiState(v.id)?.ai_overview_short) || v.ai_overview_short || '').replace(/\s+/g, ' ').slice(0, 140) }))++ .filter(v => v.text.trim().length > 1);} catch { /* */ }-- const 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.----Everything 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.----=== GLOBAL CHAT VIBE (overview + memory + timeline) ===--${global || '(quiet)'}----=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===--${userCtx || '(none yet)'}----=== STREAMERS (what they stream) ===--${streamerCtx || '(none yet)'}----=== RECENT VODS (what's been on) ===--${vodCtx || '(none yet)'}----=== ACTIVE USERNAMES you may reference kindly ===--${usernames.join(', ') || '(none yet)'}----=== TASK ===--Produce STRICT JSON, exactly this shape and nothing else:--{-- "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. ],-- "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. ]--}--Return ONLY the JSON object.`;---- const text = await ai.summarizeText(prompt, 1700, 'hero_slogans');-- if (!text) return;-- const parsed = _parseJson(text);++ const parsed = await aiService.structured('live.hero.slogans', {++ global: String(global || '').slice(0, 4000), users, streamers, vods,++ usernames: usernames.map(u => String(u).slice(0, 64)).slice(0, 40), count: TARGET,++ }, { meter: { kind: 'hero_slogans', role: 'legacy' } });if (!parsed) return;let audiences = _cleanList((parsed.audiences || []).map(_stripAudiencePrefix).filter(a => a && !_BAD_AUDIENCE.test(a) && !/^for\b/i.test(a)), 60, TARGET);// The owner's rule: no "free"/"$0" copy anywhere, whatever the model writes.@@@ -71,17 +71,11 @@ async function aiPick(cands, exclude, previous) {about: String(c.overview || c.bio || '').replace(/\s+/g, ' ').slice(0, 220) || null,}));if (!list.length) return null;-- const schema = { name: 'home_star', 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' } } } };-- const system = `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.--Pick 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.--Write 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.`;-- const user = JSON.stringify({ recent_stars: Array.from(exclude), previous_star: previous || null, candidates: list });-- let r = null;-- try { r = await llm.complete({ role: 'summary', kind: 'home_star', source: 'home', system, user, json: schema, maxTokens: 300, temperature: 0.9, timeoutMs: 30000 }); } catch (e) { console.warn('[Star] model call failed:', e.message); return null; }-- if (!r) { console.warn('[Star] model returned nothing — falling back to the score pick'); return null; }-- let out = null;-- if (r && typeof r === 'object') out = r.json || r.parsed || (r.text ? llm.parseJsonLoose(r.text) : null) || (r.content ? llm.parseJsonLoose(r.content) : null);-- else if (typeof r === 'string') out = llm.parseJsonLoose(r);++ // The picking rules are OpenVibe.AI's versioned template live.home.star (WS-O task 2); Live sends the numbers.++ const out = await require('../ai/ai-service').structured('live.home.star', {++ recent_stars: Array.from(exclude).map(s => String(s).slice(0, 64)).slice(0, 40), previous_star: previous ? String(previous).slice(0, 64) : null, candidates: list,++ }, { meter: { kind: 'home_star', role: 'summary' } });++ if (!out) { console.warn('[Star] no answer from OpenVibe.AI — falling back to the score pick'); return null; }if (!out || !out.username) return null;const c = cands.find(x => x.username.toLowerCase() === String(out.username).toLowerCase());if (!c || exclude.has(c.username.toLowerCase())) return null;@@@ -132,4 +126,4 @@ function start() {console.log('[Star] star-of-OpenVibe picker started (rotates daily, AI-picked)');}--module.exports = { start, rotate, loadPick, candidates, ROTATE_MS, STATE_KEY };++module.exports = { start, rotate, loadPick, candidates, ROTATE_MS, STATE_KEY, _aiPick: aiPick };nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++// The home slogans, the daily secret and the Star of OpenVibe are OpenVibe.AI templates (roadmap WS-O task 2): against++// a stub Network (token endpoint) and a stub AI, each job sends its data (never a prompt) to live.hero.slogans,++// live.easter_egg or live.home.star and keeps its own rules on the answer (clean lists, clues that point at their++// token, only a real candidate); no answer keeps the old fallback.++// node test/ai-templates-jobs.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-ai-jobs-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++process.env.OV_OAUTH_CLIENT_ID = 'live';++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++const runs = [];++const answers = {};++const network = http.createServer((req, res) => {++ let raw = ''; req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const f = new URLSearchParams(raw); const now = Math.floor(Date.now() / 1000);++ res.setHeader('Content-Type', 'application/json');++ res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));++ });++});++const ai = http.createServer((req, res) => {++ let raw = ''; req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const body = raw ? JSON.parse(raw) : null;++ runs.push(body);++ res.setHeader('Content-Type', 'application/json');++ const out = answers[body.workflow];++ if (!out) { res.statusCode = 502; return res.end(JSON.stringify({ code: 'provider.unavailable' })); }++ res.statusCode = 201;++ res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: body.workflow }, output: out, usage: { tokens_in: 1, tokens_out: 1, cost_usd: 0 }, provenance: { model: 'm' } } }));++ });++});++const last = (wf) => runs.filter((r) => r.workflow === wf).pop();++++(async () => {++ await new Promise((r) => network.listen(0, '127.0.0.1', r));++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;++ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;++ const db = require('../server/db/database');++ db.initDb();++ db.setSetting('ai_enabled', 'true');++ try {++ // The daily secret: AI's code, Live's clue check.++ answers['live.easter_egg'] = { title: 'Goose Run', code: ['up', 'g', 'left', 'down', 'v'], clues: ['toward the sky', "'goose' begins with it", 'the way rain falls', 'the way rain falls', "'vibe' begins with it"], effect: 'rainbow', reward: 'Honk!' };++ const egg = await require('../server/ai/easter-egg-job')._generate();++ assert.deepStrictEqual(last('live.easter_egg').input, {}, 'no vibe: an empty input, no prompt');++ assert.deepStrictEqual([egg.ai, egg.title, egg.code, egg.effect], [true, 'Goose Run', ['up', 'g', 'left', 'down', 'v'], 'rainbow']);++ assert.notStrictEqual(egg.clues[2], 'the way rain falls', 'a clue that does not point at its token is replaced');++ delete answers['live.easter_egg'];++ assert.ok(!(await require('../server/ai/easter-egg-job')._generate()).ai, 'no answer: the seeded code');++++ // The star: only a real, eligible candidate.++ const star = require('../server/home/star-job');++ const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, mic_moments: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },++ { id: 2, username: 'bob', display_name: 'Bob', sessions: 1, hours: 1 }];++ answers['live.home.star'] = { username: 'ANN', headline: 'Rust all night', reason: 'You showed up five hours this week.' };++ const pick = await star._aiPick(cands, new Set(['bob']), 'bob');++ const sent = last('live.home.star').input;++ assert.deepStrictEqual([sent.recent_stars, sent.previous_star, sent.candidates.map((c) => c.username), sent.candidates[0].about], [['bob'], 'bob', ['ann'], 'codes rust']);++ assert.deepStrictEqual([pick.cand.username, pick.headline], ['ann', 'Rust all night']);++ answers['live.home.star'] = { username: 'bob', headline: 'x', reason: 'y' };++ assert.strictEqual(await star._aiPick(cands, new Set(['bob']), 'bob'), null, 'a recent star is refused');++++ // The slogans: data in, Live's rules on the lists.++ answers['live.hero.slogans'] = { audiences: Array.from({ length: 8 }, (_, i) => `crouton fans ${i}`).concat(['free snacks', 'live streaming for coders']), quips: Array.from({ length: 8 }, (_, i) => `quip number ${i}`) };++ await require('../server/ai/slogan-job').tick();++ const sl = last('live.hero.slogans');++ assert.deepStrictEqual(Object.keys(sl.input).sort(), ['count', 'global', 'streamers', 'usernames', 'users', 'vods']);++ assert.strictEqual(sl.input.count, 20);++ assert.ok(!JSON.stringify(sl.input).includes('TASK'), 'no prompt is sent');++ const pool = JSON.parse(db.getState('home_hero_slogans'));++ assert.ok(pool.audiences.includes('crouton fans 0') && !pool.audiences.some((a) => /free/.test(a)), 'the no-free rule still applies');++ assert.ok(pool.audiences.includes('coders'), '"live streaming for" is stripped');++ } finally {++ network.close(); ai.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('AI template jobs: all checks passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
'use strict';
'use strict';
const db = require('../db/database');
const db = require('../db/database');
const ai = require('./ai-analysis');
const ai = require('./ai-analysis');
+const aiService = require('./ai-service');
let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* */ }
let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* */ }
const SETTING = 'daily_easter_egg';
const SETTING = 'daily_easter_egg';
@@ -97,25 +98,10 @@ async function _generate() {
@@ -97,25 +98,10 @@ async function _generate() {
let vibe = '';
let vibe = '';
try { const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }
try { const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }
if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
- const 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}` : ''}
-Return STRICT JSON only, nothing else:
-{
- "title": "<punchy name for today's secret, 2-5 words>",
- "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>],
- "clues": [<EXACTLY one clue per token, same order, each ≤ 90 chars>],
- "effect": "<one of: confetti, fireworks, matrix, rainbow, shake>",
- "reward": "<a short, fun congratulatory line shown when a user cracks it>"
-}
-CLUE RULES (this is a game people must actually be able to win):
-- 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.
-- 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).
-- 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.
-- Keep the flavour playful and on-theme, but clarity beats cleverness.`;
+ // The prompt and its clue rules are OpenVibe.AI's versioned template live.easter_egg (WS-O task 2).
try {
try {
- const text = await ai.summarizeText(prompt, 600, 'easter_egg');
- const m = text && text.match(/\{[\s\S]*\}/);
- if (m) {
- const j = JSON.parse(m[0]);
+ const j = await aiService.structured('live.easter_egg', vibe ? { vibe } : {}, { meter: { kind: 'easter_egg', role: 'legacy' } });
+ if (j) {
const code = _sanitizeCode(j.code);
const code = _sanitizeCode(j.code);
if (code) {
if (code) {
return {
return {
@@ -216,7 +202,7 @@ function start() {
@@ -216,7 +202,7 @@ function start() {
console.log('[EasterEgg] Daily easter-egg job started');
console.log('[EasterEgg] Daily easter-egg job started');
}
}
-module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS };
+module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS, _generate };
// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`
// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`
if (require.main === module) {
if (require.main === module) {
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
*/
*/
'use strict';
'use strict';
const db = require('../db/database');
const db = require('../db/database');
+const aiService = require('./ai-service');
const ai = require('./ai-analysis');
const ai = require('./ai-analysis');
let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* optional */ }
let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* optional */ }
@@ -21,14 +22,6 @@ const INTERVAL_MS = 7 * 60 * 60 * 1000;
@@ -21,14 +22,6 @@ const INTERVAL_MS = 7 * 60 * 60 * 1000;
const TARGET = 20; // ~20 words + ~20 slogans per batch
const TARGET = 20; // ~20 words + ~20 slogans per batch
let _timer = null, _busy = false;
let _timer = null, _busy = false;
-function _parseJson(text) {
- if (!text) return null;
- let t = String(text).trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
- try { return JSON.parse(t); } catch { /* */ }
- const m = t.match(/\{[\s\S]*\}/);
- if (m) { try { return JSON.parse(m[0]); } catch { /* */ } }
- return null;
-}
// The model has produced every wrong shape at least once: "live streaming for X", "for X",
// The model has produced every wrong shape at least once: "live streaming for X", "for X",
// "X live streaming", "X for live streaming". The hero prints "Live streaming for {X}", so X must
// "X live streaming", "X for live streaming". The hero prints "Live streaming for {X}", so X must
// be the bare noun phrase. Strip the framing at both ends; anything still containing
// be the bare noun phrase. Strip the framing at both ends; anything still containing
@@ -91,69 +84,40 @@ async function tick() {
@@ -91,69 +84,40 @@ async function tick() {
`) || [];
`) || [];
} catch { /* */ }
} catch { /* */ }
const usernames = activeRows.map(r => r.username).filter(Boolean);
const usernames = activeRows.map(r => r.username).filter(Boolean);
- let userCtx = '';
+ // The prompt is OpenVibe.AI's versioned template live.hero.slogans (WS-O task 2); Live sends the real data.
+ const users = [];
try {
try {
- const parts = [];
for (const r of activeRows.slice(0, 8)) {
for (const r of activeRows.slice(0, 8)) {
let ins = null;
let ins = null;
try { ins = chatAi && chatAi.getUserInsight && chatAi.getUserInsight(r.id); } catch { /* */ }
try { ins = chatAi && chatAi.getUserInsight && chatAi.getUserInsight(r.id); } catch { /* */ }
const blurb = ins && (ins.overview || ins.memory);
const blurb = ins && (ins.overview || ins.memory);
- if (blurb) parts.push(`- ${r.username}: ${String(blurb).replace(/\s+/g, ' ').slice(0, 180)}`);
+ if (blurb) users.push({ name: String(r.username).slice(0, 120), text: String(blurb).replace(/\s+/g, ' ').slice(0, 180) });
}
}
- userCtx = parts.join('\n').slice(0, 1600);
} catch { /* */ }
} catch { /* */ }
// ── Streamer AI overviews + recent VOD AI overviews ──
// ── Streamer AI overviews + recent VOD AI overviews ──
- let streamerCtx = '';
+ let streamers = [];
try {
try {
const rows = db.all(`SELECT u.username, COALESCE(so.overview_short, so.overview) AS ov
const rows = db.all(`SELECT u.username, COALESCE(so.overview_short, so.overview) AS ov
FROM streamer_overviews so JOIN users u ON so.user_id = u.id
FROM streamer_overviews so JOIN users u ON so.user_id = u.id
WHERE COALESCE(u.is_banned,0)=0 AND so.overview IS NOT NULL
WHERE COALESCE(u.is_banned,0)=0 AND so.overview IS NOT NULL
ORDER BY so.generated_at DESC LIMIT 8`) || [];
ORDER BY so.generated_at DESC LIMIT 8`) || [];
- streamerCtx = rows.map(r => `- ${r.username}: ${String(r.ov || '').replace(/\s+/g, ' ').slice(0, 180)}`).join('\n').slice(0, 1400);
+ streamers = rows.map(r => ({ name: String(r.username).slice(0, 120), text: String(r.ov || '').replace(/\s+/g, ' ').slice(0, 180) }));
} catch { /* */ }
} catch { /* */ }
- let vodCtx = '';
+ let vods = [];
try {
try {
// VODs live in OpenVibe.Media; overviews live in Live's vod_ai_state.
// VODs live in OpenVibe.Media; overviews live in Live's vod_ai_state.
const media = require('../media-client');
const media = require('../media-client');
const out = await media.listVods({ limit: 10 }).catch(() => null);
const out = await media.listVods({ limit: 10 }).catch(() => null);
- const rows = (out?.vods || (Array.isArray(out) ? out : []))
- .map(v => ({ title: v.title, ai_overview: (db.getVodAiState && db.getVodAiState(v.id)?.ai_overview_short) || v.ai_overview_short || '' }))
- .filter(v => v.ai_overview && v.ai_overview.trim().length > 1);
- vodCtx = rows.map(r => `- ${String(r.title || '').slice(0, 60)}: ${String(r.ai_overview || '').replace(/\s+/g, ' ').slice(0, 140)}`).join('\n').slice(0, 1400);
+ vods = (out?.vods || (Array.isArray(out) ? out : []))
+ .map(v => ({ name: String(v.title || '').slice(0, 60), text: String((db.getVodAiState && db.getVodAiState(v.id)?.ai_overview_short) || v.ai_overview_short || '').replace(/\s+/g, ' ').slice(0, 140) }))
+ .filter(v => v.text.trim().length > 1);
} catch { /* */ }
} catch { /* */ }
- const 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.
-
-Everything 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.
-
-=== GLOBAL CHAT VIBE (overview + memory + timeline) ===
-${global || '(quiet)'}
-
-=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===
-${userCtx || '(none yet)'}
-
-=== STREAMERS (what they stream) ===
-${streamerCtx || '(none yet)'}
-
-=== RECENT VODS (what's been on) ===
-${vodCtx || '(none yet)'}
-
-=== ACTIVE USERNAMES you may reference kindly ===
-${usernames.join(', ') || '(none yet)'}
-
-=== TASK ===
-Produce STRICT JSON, exactly this shape and nothing else:
-{
- "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. ],
- "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. ]
-}
-Return ONLY the JSON object.`;
-
- const text = await ai.summarizeText(prompt, 1700, 'hero_slogans');
- if (!text) return;
- const parsed = _parseJson(text);
+ const parsed = await aiService.structured('live.hero.slogans', {
+ global: String(global || '').slice(0, 4000), users, streamers, vods,
+ usernames: usernames.map(u => String(u).slice(0, 64)).slice(0, 40), count: TARGET,
+ }, { meter: { kind: 'hero_slogans', role: 'legacy' } });
if (!parsed) return;
if (!parsed) return;
let audiences = _cleanList((parsed.audiences || []).map(_stripAudiencePrefix).filter(a => a && !_BAD_AUDIENCE.test(a) && !/^for\b/i.test(a)), 60, TARGET);
let audiences = _cleanList((parsed.audiences || []).map(_stripAudiencePrefix).filter(a => a && !_BAD_AUDIENCE.test(a) && !/^for\b/i.test(a)), 60, TARGET);
// The owner's rule: no "free"/"$0" copy anywhere, whatever the model writes.
// The owner's rule: no "free"/"$0" copy anywhere, whatever the model writes.
@@ -71,17 +71,11 @@ async function aiPick(cands, exclude, previous) {
@@ -71,17 +71,11 @@ async function aiPick(cands, exclude, previous) {
about: String(c.overview || c.bio || '').replace(/\s+/g, ' ').slice(0, 220) || null,
about: String(c.overview || c.bio || '').replace(/\s+/g, ' ').slice(0, 220) || null,
}));
}));
if (!list.length) return null;
if (!list.length) return null;
- const schema = { name: 'home_star', 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' } } } };
- const system = `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.
-Pick 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.
-Write 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.`;
- const user = JSON.stringify({ recent_stars: Array.from(exclude), previous_star: previous || null, candidates: list });
- let r = null;
- try { r = await llm.complete({ role: 'summary', kind: 'home_star', source: 'home', system, user, json: schema, maxTokens: 300, temperature: 0.9, timeoutMs: 30000 }); } catch (e) { console.warn('[Star] model call failed:', e.message); return null; }
- if (!r) { console.warn('[Star] model returned nothing — falling back to the score pick'); return null; }
- let out = null;
- if (r && typeof r === 'object') out = r.json || r.parsed || (r.text ? llm.parseJsonLoose(r.text) : null) || (r.content ? llm.parseJsonLoose(r.content) : null);
- else if (typeof r === 'string') out = llm.parseJsonLoose(r);
+ // The picking rules are OpenVibe.AI's versioned template live.home.star (WS-O task 2); Live sends the numbers.
+ const out = await require('../ai/ai-service').structured('live.home.star', {
+ recent_stars: Array.from(exclude).map(s => String(s).slice(0, 64)).slice(0, 40), previous_star: previous ? String(previous).slice(0, 64) : null, candidates: list,
+ }, { meter: { kind: 'home_star', role: 'summary' } });
+ if (!out) { console.warn('[Star] no answer from OpenVibe.AI — falling back to the score pick'); return null; }
if (!out || !out.username) return null;
if (!out || !out.username) return null;
const c = cands.find(x => x.username.toLowerCase() === String(out.username).toLowerCase());
const c = cands.find(x => x.username.toLowerCase() === String(out.username).toLowerCase());
if (!c || exclude.has(c.username.toLowerCase())) return null;
if (!c || exclude.has(c.username.toLowerCase())) return null;
@@ -132,4 +126,4 @@ function start() {
@@ -132,4 +126,4 @@ function start() {
console.log('[Star] star-of-OpenVibe picker started (rotates daily, AI-picked)');
console.log('[Star] star-of-OpenVibe picker started (rotates daily, AI-picked)');
}
}
-module.exports = { start, rotate, loadPick, candidates, ROTATE_MS, STATE_KEY };
+module.exports = { start, rotate, loadPick, candidates, ROTATE_MS, STATE_KEY, _aiPick: aiPick };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+// The home slogans, the daily secret and the Star of OpenVibe are OpenVibe.AI templates (roadmap WS-O task 2): against
+// a stub Network (token endpoint) and a stub AI, each job sends its data (never a prompt) to live.hero.slogans,
+// live.easter_egg or live.home.star and keeps its own rules on the answer (clean lists, clues that point at their
+// token, only a real candidate); no answer keeps the old fallback.
+// node test/ai-templates-jobs.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-ai-jobs-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+process.env.OV_OAUTH_CLIENT_ID = 'live';
+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const runs = [];
+const answers = {};
+const network = http.createServer((req, res) => {
+ let raw = ''; req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const f = new URLSearchParams(raw); const now = Math.floor(Date.now() / 1000);
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));
+ });
+});
+const ai = http.createServer((req, res) => {
+ let raw = ''; req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const body = raw ? JSON.parse(raw) : null;
+ runs.push(body);
+ res.setHeader('Content-Type', 'application/json');
+ const out = answers[body.workflow];
+ if (!out) { res.statusCode = 502; return res.end(JSON.stringify({ code: 'provider.unavailable' })); }
+ res.statusCode = 201;
+ res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: body.workflow }, output: out, usage: { tokens_in: 1, tokens_out: 1, cost_usd: 0 }, provenance: { model: 'm' } } }));
+ });
+});
+const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
+
+(async () => {
+ await new Promise((r) => network.listen(0, '127.0.0.1', r));
+ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;
+ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;
+ const db = require('../server/db/database');
+ db.initDb();
+ db.setSetting('ai_enabled', 'true');
+ try {
+ // The daily secret: AI's code, Live's clue check.
+ answers['live.easter_egg'] = { title: 'Goose Run', code: ['up', 'g', 'left', 'down', 'v'], clues: ['toward the sky', "'goose' begins with it", 'the way rain falls', 'the way rain falls', "'vibe' begins with it"], effect: 'rainbow', reward: 'Honk!' };
+ const egg = await require('../server/ai/easter-egg-job')._generate();
+ assert.deepStrictEqual(last('live.easter_egg').input, {}, 'no vibe: an empty input, no prompt');
+ assert.deepStrictEqual([egg.ai, egg.title, egg.code, egg.effect], [true, 'Goose Run', ['up', 'g', 'left', 'down', 'v'], 'rainbow']);
+ assert.notStrictEqual(egg.clues[2], 'the way rain falls', 'a clue that does not point at its token is replaced');
+ delete answers['live.easter_egg'];
+ assert.ok(!(await require('../server/ai/easter-egg-job')._generate()).ai, 'no answer: the seeded code');
+
+ // The star: only a real, eligible candidate.
+ const star = require('../server/home/star-job');
+ const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, mic_moments: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },
+ { id: 2, username: 'bob', display_name: 'Bob', sessions: 1, hours: 1 }];
+ answers['live.home.star'] = { username: 'ANN', headline: 'Rust all night', reason: 'You showed up five hours this week.' };
+ const pick = await star._aiPick(cands, new Set(['bob']), 'bob');
+ const sent = last('live.home.star').input;
+ assert.deepStrictEqual([sent.recent_stars, sent.previous_star, sent.candidates.map((c) => c.username), sent.candidates[0].about], [['bob'], 'bob', ['ann'], 'codes rust']);
+ assert.deepStrictEqual([pick.cand.username, pick.headline], ['ann', 'Rust all night']);
+ answers['live.home.star'] = { username: 'bob', headline: 'x', reason: 'y' };
+ assert.strictEqual(await star._aiPick(cands, new Set(['bob']), 'bob'), null, 'a recent star is refused');
+
+ // The slogans: data in, Live's rules on the lists.
+ answers['live.hero.slogans'] = { audiences: Array.from({ length: 8 }, (_, i) => `crouton fans ${i}`).concat(['free snacks', 'live streaming for coders']), quips: Array.from({ length: 8 }, (_, i) => `quip number ${i}`) };
+ await require('../server/ai/slogan-job').tick();
+ const sl = last('live.hero.slogans');
+ assert.deepStrictEqual(Object.keys(sl.input).sort(), ['count', 'global', 'streamers', 'usernames', 'users', 'vods']);
+ assert.strictEqual(sl.input.count, 20);
+ assert.ok(!JSON.stringify(sl.input).includes('TASK'), 'no prompt is sent');
+ const pool = JSON.parse(db.getState('home_hero_slogans'));
+ assert.ok(pool.audiences.includes('crouton fans 0') && !pool.audiences.some((a) => /free/.test(a)), 'the no-free rule still applies');
+ assert.ok(pool.audiences.includes('coders'), '"live streaming for" is stripped');
+ } finally {
+ network.close(); ai.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('AI template jobs: all checks passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });