openvibers/openvibe.live Commits

The AI viewers send OpenVibe.AI their context, not prompts (roadmap WS-O task 2; AI 65b6a80 live.viewers.plan, reply, fold and clone templates). server/ai/viewers/ai-run.js runs a viewers template on the site's AI (admin switch and daily budget apply) or on the streamer's own key in AI (credential { subject }, metered as 'byo'). Anything else stays quiet: a key that never moved to AI, or a channel on its own key with no subject; the engine's provider choice no longer falls through to the site's AI there. The director sends the stable context and volatile tail it builds; the fast reply sends the situation and the streamer's line; the fold sends each persona's lines and memory; a clone sends what is known about the chatter. The director rules, plan schema and fold schema left for AI's templates. budget.generate, ai-analysis.viewerComplete and summarizeText had no callers left and are gone; only the status probe still sends a prompt. The usage recorder takes the provider from the meter. npm test 148/148.

OP OpenVibers committed 28 Sept 2026 · e3ad96df3d · 12 files, +95 −179


Changes

ddiff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
iindex 97b5285c..96eed927 100644
---- a/server/ai/ai-analysis.js
++++ b/server/ai/ai-analysis.js
@@@ -27,31 +27,15 @@ function transcriptionEnabled() {
function captureIntervalSec() { return Math.max(30, num('ai_stream_capture_interval_sec', 120)); }
function withinBudget() { return llm.withinBudget(); }
--// The transport lives in ./llm (a run on OpenVibe.AI, uniform metering). These wrappers keep the
--// existing call shapes for the features that still render their own prompt.
++// The transport lives in ./llm (a run on OpenVibe.AI, uniform metering). Every feature's prompt is an OpenVibe.AI
++// template now (roadmap WS-O task 2); this single-prompt call is left for the status probe (testStatus).
--/** Core call: legacy single-prompt shape. Returns text or null. */
++/** Core call: one prompt, text back, or null. */
async function _complete({ prompt, image = null, maxTokens = 400, kind, temperature = null, ownerUserId = null, source = null, role = 'legacy', imageMaxWidth = 1280 }) {
const r = await llm.complete({ role, user: prompt, image, imageMaxWidth, maxTokens, temperature, kind, ownerUserId, source });
return r && r.text ? r.text : null;
}
--/** Generic text completion (chat insights, moments, slogans, …): a passthrough run of the workflow that owns `kind`. */
--async function summarizeText(prompt, maxTokens = 350, kind = 'media_overview') {
-- return _complete({ prompt, maxTokens, kind });
--}
--
--/**
-- * Metered completion for the AI Chat Viewers engine on the site's AI (not a streamer's own key).
-- * Attributes the spend to a streamer (owner_user_id) under source='ai_viewers' so
-- * per-streamer daily budgets work. Returns text, or null if the admin AI is
-- * disabled / over the global budget. Supports an optional image (vision) input.
-- */
--async function viewerComplete({ system = '', user = '', image = null, maxTokens = 80, temperature = 1.0, ownerUserId = null }) {
-- const r = await llm.complete({ role: 'chat', system, user, image, imageMaxWidth: 768, maxTokens, temperature, kind: 'ai_viewers', source: 'ai_viewers', ownerUserId });
-- return r && r.text ? r.text : null;
--}
--
/** Is Live's AI usable right now (enabled + within the global budget)? viewers/budget.js asks under this name. */
function sharedKeyReady() { return isEnabled() && withinBudget(); }
@@@ -385,7 +369,7 @@ module.exports = {
isEnabled, withinBudget, pasteAnalysisEnabled, streamMemoryEnabled, transcriptionEnabled, captureIntervalSec,
analyzeImagePaste, analyzeTextPaste, analyzeStreamFrame, summarizeStreamMemories,
generateStreamerOverview, generateVodOverview, generateClipOverview, ensureVodTimeline,
-- generateVodTranscript, generateClipTranscript, summarizeText, testStatus,
-- viewerComplete, sharedKeyReady,
++ generateVodTranscript, generateClipTranscript, testStatus,
++ sharedKeyReady,
complete: llm.complete, llm,
};
ddiff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
iindex c6f5f4fd..7c5feaac 100644
---- a/server/ai/ai-service.js
++++ b/server/ai/ai-service.js
@@@ -180,4 +180,4 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
};
}
--module.exports = { enabled, run, structured, complete, usable, imageInput, ownerRef, workflowFor, setRecorder, KIND_TO_WORKFLOW, AUDIENCE };
++module.exports = { enabled, run, structured, complete, usable, meter, imageInput, ownerRef, workflowFor, setRecorder, KIND_TO_WORKFLOW, AUDIENCE };
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex 8a02b643..a31b9547 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -14,8 +14,8 @@
* • ADAPTIVE window: the global overview covers however long it takes to gather ~300
* messages, clamped to [30 min, 14 days] — busy chat → short window, quiet → wide.
* • THROTTLED + CAPPED: min-interval between refreshes, a few subjects per tick, and
-- * everything gated by the shared `ai_enabled` switch + daily USD budget (summarizeText
-- * returns null when disabled/over-budget, so we just skip).
++ * everything gated by the shared `ai_enabled` switch + daily USD budget (the tick checks both;
++ * a run that does not answer is null, so we just skip).
*/
'use strict';
ddiff --git a/server/ai/llm.js b/server/ai/llm.js
iindex 84a9e16c..f88d5fc5 100644
---- a/server/ai/llm.js
++++ b/server/ai/llm.js
@@@ -182,7 +182,7 @@ async function testProvider(override = null) {
aiService.setRecorder((r, m) => db.recordAiUsage({
kind: m.kind || (r.workflow && r.workflow.key) || 'remote', model: (r.provenance && r.provenance.model) || null,
input_tokens: (r.usage && r.usage.tokens_in) || 0, output_tokens: (r.usage && r.usage.tokens_out) || 0, cached_tokens: 0,
-- cost_usd: (r.usage && r.usage.cost_usd) || 0, owner_user_id: m.ownerUserId || null, source: m.source || null, role: m.role || null, provider: 'openvibe-ai', latency_ms: null,
++ cost_usd: (r.usage && r.usage.cost_usd) || 0, owner_user_id: m.ownerUserId || null, source: m.source || null, role: m.role || null, provider: m.provider || 'openvibe-ai', latency_ms: null,
}));
module.exports = { complete, testProvider, toVisionJpeg, parseJsonLoose, isEnabled, withinBudget, ROLES };
ddiff --git a/server/ai/viewers/ai-run.js b/server/ai/viewers/ai-run.js
nnew file mode 100644
iindex 00000000..290bd4a3
---- /dev/null
++++ b/server/ai/viewers/ai-run.js
@@@ -0,0 +1,32 @@
++/**
++ * AI viewers' runs on OpenVibe.AI (roadmap WS-O task 2). The director's rules, the fast reply, the memory fold and
++ * the clone brief are AI's versioned templates live.viewers.*; Live sends the context it builds (roster, settings,
++ * transcript, chat, memories).
++ *
++ * provider: null = the site's AI (the admin switch and the daily budget apply); { credentialSubject } = the
++ * streamer's own key stored in OpenVibe.AI (a run with credential { subject }, metered as 'byo'); anything else —
++ * a key that never moved to AI, or none — keeps the streamer's viewers quiet, never falling back to the site's AI.
++ */
++'use strict';
++const aiService = require('../ai-service');
++const llm = require('../llm');
++
++/** → { output, usage: { input, output, cached }, model, cost, latencyMs } or null (quiet). */
++async function viewerRun(workflow, input, { provider = null, ownerUserId = null, kind, role }) {
++ const own = provider ? provider.credentialSubject : null;
++ if (provider && !own) return null;
++ if (!aiService.enabled()) return null;
++ if (!own && !(llm.isEnabled() && llm.withinBudget())) return null;
++ const started = Date.now();
++ const r = await aiService.run(workflow, input, { attribution: aiService.ownerRef(ownerUserId), credentialSubject: own || null, waitMs: 60000 });
++ aiService.meter(r, { kind, role, ownerUserId, source: 'ai_viewers', provider: own ? 'byo' : 'openvibe-ai' });
++ const output = aiService.usable(r);
++ if (!output) return null;
++ return {
++ output,
++ usage: { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 },
++ model: (r.provenance && r.provenance.model) || null, cost: (r.usage && r.usage.cost_usd) || 0, latencyMs: Date.now() - started,
++ };
++}
++
++module.exports = { viewerRun };
ddiff --git a/server/ai/viewers/budget.js b/server/ai/viewers/budget.js
iindex 5945a43f..b7e2b407 100644
---- a/server/ai/viewers/budget.js
++++ b/server/ai/viewers/budget.js
@@@ -1,7 +1,7 @@
/**
* AI Chat Viewers — budget + cost routing.
*
-- * Every bot LLM call goes through generate(), a run on OpenVibe.AI either way:
++ * Every bot line is a run on OpenVibe.AI (viewers/ai-run.js, templates live.viewers.*), either way:
* - the site's AI (default), metered + capped per streamer at
* channel_ai_config.daily_budget_cents (default 20¢/day). Over the cap → bots
* go quiet (returns null). Also respects the admin master switch + global cap.
@@@ -45,35 +45,9 @@ function budgetStatus(userId) {
return { useShared, active, reason, spentToday, capUsd, cfg };
}
--/**
-- * Metered chat completion for a bot, routed by the streamer's key choice.
-- * @returns {Promise<string|null>} the generated text, or null when the streamer is
-- * quiet (AI disabled / over cap / no usable key / provider error).
-- */
--async function generate(userId, { system = '', user = '', image = null, maxTokens = 80, temperature = 1.0 } = {}) {
-- const st = budgetStatus(userId);
-- if (!st.active) return null;
--
-- if (st.useShared) {
-- // The site's AI: metered + attributed inside ai-analysis.viewerComplete.
-- return ai.viewerComplete({ system, user, image, maxTokens, temperature, ownerUserId: userId });
-- }
--
-- // Their own key in OpenVibe.AI — a run with credential { subject }, metered as 'byo' in llm.js.
-- // Never fall through to the site's AI: no usable own key means the streamer's bots stay quiet.
-- const provider = byoProvider(st.cfg);
-- if (!provider || !provider.credentialSubject) return null;
-- const r = await ai.llm.complete({
-- role: 'chat', system, user, image, imageMaxWidth: 768, maxTokens, temperature,
-- kind: 'ai_viewers', source: SOURCE, ownerUserId: userId,
-- provider,
-- });
-- return r && r.text ? r.text.trim() || null : null;
--}
--
/**
* The provider for a streamer's own-key settings (column fields + settings_json.byo). A key stored in OpenVibe.AI
-- * (byo_in_ai, WS-O task 2) is named by the streamer's subject, for llm.complete; the key itself never comes back
++ * (byo_in_ai, WS-O task 2) is named by the streamer's subject, for viewers/ai-run.js; the key itself never comes back
* here. Otherwise the typed key, address and models, which only llm.testProvider uses ("test connection" before
* the key is saved to OpenVibe.AI).
*/
@@@ -134,4 +108,4 @@ function status(userId) {
return { ...st, mode, reason, active: st.active && mode !== 'silent' };
}
--module.exports = { generate, budgetStatus, status, byoUsable, byoProvider, globalViewerSpendToday, SOURCE };
++module.exports = { budgetStatus, status, byoUsable, byoProvider, globalViewerSpendToday, SOURCE };
ddiff --git a/server/ai/viewers/director.js b/server/ai/viewers/director.js
iindex dce5f5d9..6354926c 100644
---- a/server/ai/viewers/director.js
++++ b/server/ai/viewers/director.js
@@@ -1,103 +1,41 @@
/**
* AI viewers v3 — the director.
*
-- * ONE structured LLM call per pass plans up to K lines for the whole roster: who speaks,
++ * ONE structured run per pass plans up to K lines for the whole roster: who speaks,
* to whom (a viewer, another bot in a thread, the streamer, or nobody), with what text,
-- * after what delay, and why. The stable prefix goes in the (cached) system role; the
-- * volatile tail is the user message. Batching lines per call is the single biggest
-- * token saving over the old one-call-per-line engine; the cached prefix is the second.
++ * after what delay, and why. The rules and the stable context go in the (cached) system
++ * prompt of OpenVibe.AI's template live.viewers.plan; the volatile tail is the user message.
++ * Batching lines per call is the single biggest token saving over the old one-call-per-line
++ * engine; the cached prefix is the second.
*/
'use strict';
--const llm = require('../llm');
++const { viewerRun } = require('./ai-run');
--const PLAN_SCHEMA = {
-- type: 'object',
-- additionalProperties: false,
-- properties: {
-- skip: { type: 'boolean', description: 'true when nothing worth saying right now' },
-- notes: { type: 'string', description: 'one short line of director reasoning (logged only)' },
-- lines: {
-- type: 'array',
-- items: {
-- type: 'object',
-- additionalProperties: false,
-- properties: {
-- bot: { type: 'string', description: 'username of the AI viewer that speaks' },
-- target: { type: 'string', enum: ['viewer', 'bot', 'streamer', 'ambient'] },
-- reply_to: { type: ['string', 'null'], description: 'username being replied to (viewer or bot), else null' },
-- thread: { type: ['string', 'null'], description: 'existing thread id as a string, "new" to start one, or null' },
-- topic: { type: ['string', 'null'], description: 'topic label when thread is "new"' },
-- delay_ms: { type: 'integer', minimum: 0, maximum: 45000 },
-- text: { type: 'string', description: 'the chat line, in character' },
-- reason: { type: 'string', description: 'why this bot says this now (<= 80 chars)' },
-- },
-- required: ['bot', 'target', 'reply_to', 'thread', 'topic', 'delay_ms', 'text', 'reason'],
-- },
-- },
-- threads_close: { type: 'array', items: { type: 'string' }, description: 'thread ids that reached a natural end' },
-- },
-- required: ['skip', 'notes', 'lines', 'threads_close'],
--};
--
--const DIRECTOR_RULES = `You are the DIRECTOR of a cast of AI viewers in a live stream's chat. Each pass you decide which of them speak next, to whom, and what they say — like an improv coach cueing actors. You never speak yourself.
--
--Hard rules:
--- Every line must sound like a real, casual chat message from that specific viewer, fully in character and in their typing style. Short. No quotation marks, no stage directions, no meta commentary, never mention being an AI, bot, model, or "director".
--- If the streamer addressed chat or named an AI viewer, that MUST be answered first (target "streamer"), naturally and specifically about what they said/are doing.
--- Reply to real viewers when it makes sense (target "viewer", reply_to = their username). Prefer people flagged first-time / follower / subscriber. Never reply to yourself.
--- Bots may talk to each other (target "bot", reply_to = the other bot) to continue an open thread or start one when chat is quiet — keep the bot-to-bot share at or under the target given, and keep each thread moving (a question, a joke callback, a disagreement), then let it end.
--- "ambient" lines react to what is happening on stream (heard / seen) — not filler like "nice stream".
--- Use the transcript and screen description as the source of truth for what is happening; the timestamps tell you what is recent.
--- Vary who speaks; the same viewer never posts two lines in a row; spread delay_ms so lines arrive over the pass, streamer replies first (small delay).
--- Respect the channel rules, tone, language, blocklist and the word limit. Plain text only; emoji only per the emote policy.
--- Do not repeat anything already said in chat, by anyone. If there is genuinely nothing to add, return skip=true with an empty lines array.`;
--
--function buildSystem(stableText) {
-- // Order: rules (constant) → stable context (per stream, changes rarely). Both cached.
-- return [
-- { text: DIRECTOR_RULES, cache: true },
-- { text: stableText, cache: true },
-- ];
--}
++const clip = (t, n) => String(t == null ? '' : t).slice(0, n);
/**
-- * Plan a pass. Returns { plan, usage, model, cost, latencyMs } or null (quiet).
-- * @param {object} args { stableText, volatileText, maxLines, provider (BYO override or null), ownerUserId, cacheKey, temperature }
++ * Plan a pass: live.viewers.plan (the director's rules and plan schema are AI's; the stream context is Live's).
++ * Returns { plan, usage, model, cost, latencyMs } or null (quiet).
++ * @param {object} args { stableText, volatileText, maxLines, provider (null = site AI, { credentialSubject } = own key), ownerUserId, temperature }
*/
--async function plan({ stableText, volatileText, maxLines = 3, provider = null, ownerUserId = null, cacheKey = null, temperature = 0.9 }) {
-- const r = await llm.complete({
-- role: 'director',
-- system: buildSystem(stableText),
-- user: `${volatileText}\n\nPlan the next pass now (at most ${maxLines} lines). Return JSON only.`,
-- json: { name: 'plan', schema: PLAN_SCHEMA, strict: true },
-- maxTokens: 70 * maxLines + 120,
-- temperature,
-- cacheKey,
-- kind: 'ai_viewers_director', source: 'ai_viewers', ownerUserId,
-- provider,
-- });
++async function plan({ stableText, volatileText, maxLines = 3, provider = null, ownerUserId = null, temperature = 0.9 }) {
++ const r = await viewerRun('live.viewers.plan', {
++ stable: clip(stableText, 40000), volatile: clip(volatileText, 60000), max_lines: Math.max(1, Math.min(12, Math.round(maxLines) || 3)), temperature,
++ }, { provider, ownerUserId, kind: 'ai_viewers_director', role: 'director' });
if (!r) return null;
-- let p = r.json;
-- if (!p || typeof p !== 'object') p = llm.parseJsonLoose(r.text) || { skip: true, lines: [] };
++ const p = r.output || {};
const lines = Array.isArray(p.lines) ? p.lines.filter(l => l && typeof l === 'object' && typeof l.text === 'string' && l.bot) : [];
return { plan: { skip: !!p.skip && !lines.length, notes: String(p.notes || '').slice(0, 200), lines, threads_close: Array.isArray(p.threads_close) ? p.threads_close.map(String) : [] }, usage: r.usage, model: r.model, cost: r.cost, latencyMs: r.latencyMs };
}
/**
-- * Fast path: ONE line from ONE bot answering the streamer right now (no full plan).
++ * Fast path: ONE line from ONE bot answering the streamer right now (no full plan): live.viewers.reply.
*/
--async function quickReply({ stableText, situationText, bot, streamerLine, maxWords = 18, provider = null, ownerUserId = null, cacheKey = null }) {
-- const r = await llm.complete({
-- role: 'chat',
-- system: buildSystem(stableText),
-- user: `${situationText}\n\nThe streamer just said: "${streamerLine}"\n\nWrite ONE chat line as ${bot.username} answering the streamer directly and specifically (max ${maxWords} words, plain text, in character). Return only the line.`,
-- maxTokens: 70,
-- temperature: 0.9,
-- cacheKey,
-- kind: 'ai_viewers_reply', source: 'ai_viewers', ownerUserId,
-- provider,
-- });
-- return r ? { text: r.text, usage: r.usage, model: r.model, cost: r.cost } : null;
++async function quickReply({ stableText, situationText, bot, streamerLine, maxWords = 18, provider = null, ownerUserId = null }) {
++ const r = await viewerRun('live.viewers.reply', {
++ stable: clip(stableText, 40000), situation: clip(situationText, 20000), bot: clip(bot.username, 80), streamer_line: clip(streamerLine, 400), max_words: Math.max(1, Math.min(80, Math.round(maxWords) || 18)),
++ }, { provider, ownerUserId, kind: 'ai_viewers_reply', role: 'chat' });
++ return r ? { text: r.output.text, usage: r.usage, model: r.model, cost: r.cost } : null;
}
--module.exports = { plan, quickReply, PLAN_SCHEMA, DIRECTOR_RULES, buildSystem };
++module.exports = { plan, quickReply };
ddiff --git a/server/ai/viewers/engine.js b/server/ai/viewers/engine.js
iindex b29261a3..9f2c69e1 100644
---- a/server/ai/viewers/engine.js
++++ b/server/ai/viewers/engine.js
@@@ -192,7 +192,8 @@ class AiViewersEngineV3 {
if (w.mode === 'replies_only') ms *= 2;
return ms;
}
-- _providerFor(w) { return w.cfg.use_shared_key ? null : budget.byoProvider(w.cfg); }
++ // null = the site's AI; else the streamer's own key in AI, or { none } (quiet) — never the site's AI by accident.
++ _providerFor(w) { return w.cfg.use_shared_key ? null : (budget.byoProvider(w.cfg) || { none: true }); }
async _tick(w) {
if (w.stopped || w.ticking) return;
ddiff --git a/server/ai/viewers/fold.js b/server/ai/viewers/fold.js
iindex cdbd13f0..a9af8264 100644
---- a/server/ai/viewers/fold.js
++++ b/server/ai/viewers/fold.js
@@@ -1,22 +1,14 @@
/**
-- * AI viewers v3 — memory fold. One summary-role call per channel folds every bot's recent
++ * AI viewers v3 — memory fold. One summary-role run per channel (OpenVibe.AI's live.viewers.fold) folds every bot's recent
* lines (and the threads they took part in) into their rolling memory, plus a channel-wide
* "running bits" note. Replaces the per-bot fold of v2 (N calls → 1).
*/
'use strict';
const db = require('../../db/database');
--const llm = require('../llm');
++const { viewerRun } = require('./ai-run');
function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }
--const FOLD_SCHEMA = {
-- type: 'object', additionalProperties: false,
-- properties: {
-- memories: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { bot: { type: 'string' }, memory: { type: 'string' } }, required: ['bot', 'memory'] } },
-- channel_memory: { type: 'string', description: 'running jokes / recurring topics for the whole channel, <= 90 words' },
-- },
-- required: ['memories', 'channel_memory'],
--};
/**
* @param {object} worker { userId, bots, botLines: Map<botId, string[]>, settings }
@@@ -34,20 +26,13 @@ async function foldAll(worker, { provider = null } = {}) {
let channelMemory = '';
try { channelMemory = (JSON.parse(db.getChannelAiConfig(worker.userId).settings_json || '{}') || {}).channel_memory || ''; } catch { /* */ }
-- const prompt = [
-- 'You maintain the memories of AI chat personas for one streaming channel.',
-- channelMemory ? `Current channel memory (running bits): ${clip(channelMemory, 600)}` : '',
-- ...entries.map(e => `### ${e.bot.username}\nCurrent memory: ${e.memory ? clip(e.memory, 700) : '(none)'}\nRecent lines:\n- ${e.lines.map(l => clip(l, 160)).join('\n- ')}`),
-- 'For each persona, write an updated memory: a tight paragraph (max 90 words) of durable facts — running jokes, who they talk to, opinions they formed, recurring topics. Keep the useful old stuff, drop the trivial. Also update the channel memory (running bits everyone shares).',
-- ].filter(Boolean).join('\n\n');
--
-- const r = await llm.complete({
-- role: 'summary', user: prompt, json: { name: 'fold', schema: FOLD_SCHEMA, strict: true },
-- maxTokens: 160 * entries.length + 160, temperature: 0.4,
-- kind: 'ai_viewers_fold', source: 'ai_viewers', ownerUserId: worker.userId, provider,
-- });
++ // The fold instructions and schema are OpenVibe.AI's versioned template live.viewers.fold (WS-O task 2).
++ const r = await viewerRun('live.viewers.fold', {
++ channel_memory: clip(channelMemory, 2000),
++ personas: entries.slice(0, 30).map(e => ({ username: clip(e.bot.username, 80), memory: clip(e.memory, 2000), lines: e.lines.map(l => clip(l, 400)).slice(-20) })),
++ }, { provider, ownerUserId: worker.userId, kind: 'ai_viewers_fold', role: 'summary' });
if (!r) return null;
-- const out = r.json || llm.parseJsonLoose(r.text);
++ const out = r.output;
if (!out || !Array.isArray(out.memories)) return null;
let updated = 0;
for (const m of out.memories) {
ddiff --git a/server/ai/viewers/roster.js b/server/ai/viewers/roster.js
iindex bdbdcbce..a23f4ec9 100644
---- a/server/ai/viewers/roster.js
++++ b/server/ai/viewers/roster.js
@@@ -8,6 +8,7 @@
*/
const db = require('../../db/database');
const budget = require('./budget');
++const { viewerRun } = require('./ai-run');
// ── Character archetypes (persona seed, no LLM needed for ambient) ──
const CHARACTERS = [
@@@ -131,15 +132,16 @@ async function createCloneBot(streamerId, src) {
const samples = _sampleLines(src.samples, 24);
let identity = overview ? overview.slice(0, 600) : '';
try {
-- const prompt = [
-- `You are profiling a chat viewer named "${src.displayName || src.ref}" so an AI can role-play as them.`,
-- overview ? `What we know about them: ${overview}` : '',
-- memory ? `Notable memory: ${memory}` : '',
-- samples.length ? `Sample messages they've sent:\n- ${samples.join('\n- ')}` : '',
-- `Write 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.`,
-- ].filter(Boolean).join('\n\n');
-- const brief = await budget.generate(streamerId, { system: '', user: prompt, maxTokens: 160, temperature: 0.7 });
-- if (brief && brief.trim()) identity = brief.trim();
++ // The brief's instructions are OpenVibe.AI's versioned template live.viewers.clone (WS-O task 2); metered to the
++ // streamer on the site's AI or their own key, whichever their viewers use.
++ const st = budget.budgetStatus(streamerId);
++ if (st.active) {
++ const r = await viewerRun('live.viewers.clone', {
++ name: String(src.displayName || src.ref || 'viewer').slice(0, 120), overview: String(overview).slice(0, 2000), memory: String(memory).slice(0, 2000),
++ samples: samples.map(x => String(x).slice(0, 400)).slice(0, 24),
++ }, { provider: st.useShared ? null : (budget.byoProvider(st.cfg) || { none: true }), ownerUserId: streamerId, kind: 'ai_viewers', role: 'chat' });
++ if (r && r.output && r.output.text && r.output.text.trim()) identity = r.output.text.trim();
++ }
} catch { /* fall back to overview */ }
const persona = {
ddiff --git a/test/ai-remote-only.test.js b/test/ai-remote-only.test.js
iindex 8727939e..0a6516be 100644
---- a/test/ai-remote-only.test.js
++++ b/test/ai-remote-only.test.js
@@@ -139,14 +139,13 @@ async function check(name, fn) {
assert.strictEqual(await analysis.analyzeStreamFrame(frame), null);
assert.strictEqual(await analysis.summarizeStreamMemories([{ description: 'Kai solders a board' }], sid), null);
assert.strictEqual(await analysis.generateStreamerOverview(40), null);
-- assert.strictEqual(await analysis.summarizeText('say something', 50, 'chat_global'), null);
-- assert.strictEqual(await analysis.viewerComplete({ user: 'hi', ownerUserId: 40 }), null);
++ assert.strictEqual(await require('../server/ai/viewers/ai-run').viewerRun('live.viewers.clone', { name: 'x' }, { ownerUserId: 40, kind: 'ai_viewers', role: 'chat' }), null);
assert.strictEqual(await analysis.generateVodOverview({ id: 1, stream_id: sid }), null);
assert.strictEqual(await analysis.generateClipOverview({ id: 1, stream_id: sid }), null);
assert.strictEqual((await analysis.testStatus({ probe: true })).ok, false);
assert.strictEqual(await translate.translate('みなさんこんにちは', { from: 'ja', to: 'en' }), null);
assert.deepStrictEqual(await translate.translateLines(['みなさん'], { from: 'ja' }), [null]);
-- assert.strictEqual(await budget.generate(40, { user: 'hi' }), null);
++ assert.strictEqual(await require('../server/ai/viewers/director').quickReply({ stableText: 's', situationText: '', bot: { username: 'b' }, streamerLine: 'hi', provider: null, ownerUserId: 40 }), null);
const r = await recap.buildRecap(sid);
assert.ok(r && r.write && r.write.headline, 'the template report still ships');
assert.strictEqual(r.ai, false);
@@@ -219,7 +218,7 @@ async function check(name, fn) {
// Viewers of a channel whose key never moved to OpenVibe.AI stay quiet.
db.upsertChannelAiConfig(40, { enabled: 1, use_shared_key: 0, byo_key: 'sk-EXAMPLE-0000', byo_base_url: providerUrl, byo_in_ai: 0 });
assert.strictEqual(budget.budgetStatus(40).reason, 'no_byo_key');
-- assert.strictEqual(await budget.generate(40, { user: 'hi' }), null);
++ assert.strictEqual(await require('../server/ai/viewers/director').quickReply({ stableText: 's', situationText: '', bot: { username: 'b' }, streamerLine: 'hi', provider: budget.byoProvider(db.getChannelAiConfig(40)), ownerUserId: 40 }), null);
assert.strictEqual(providerHits, 0);
});
ddiff --git a/test/byo-credentials.test.js b/test/byo-credentials.test.js
iindex 361f3711..a232bcb1 100644
---- a/test/byo-credentials.test.js
++++ b/test/byo-credentials.test.js
@@@ -89,17 +89,18 @@ const ai = http.createServer((req, res) => {
// ── The viewers run on AI with the credential ──
const n = calls.length;
-- const text = await budget.generate(30, { system: 'be a viewer', user: 'say hi', maxTokens: 20 });
-- assert.strictEqual(text, 'hi from the streamer key');
++ const director = require('../server/ai/viewers/director');
++ const reply = await director.quickReply({ stableText: 'roster', situationText: '', bot: { username: 'goosebot' }, streamerLine: 'say hi', provider: budget.byoProvider(db.getChannelAiConfig(30)), ownerUserId: 30 });
++ assert.strictEqual(reply.text, 'hi from the streamer key');
const runCall = calls.slice(n).find((c) => c.url.startsWith('/api/v1/runs'));
-- assert.deepStrictEqual([runCall.body.workflow, runCall.body.credential], ['live.viewers.line', { subject: SUBJECT }]);
++ assert.deepStrictEqual([runCall.body.workflow, runCall.body.credential], ['live.viewers.reply', { subject: SUBJECT }]);
assert.ok(!JSON.stringify(runCall.body).includes(LOCAL_KEY), 'the key is not in the run');
const usage = d.prepare("SELECT provider, owner_user_id FROM ai_usage WHERE source = 'ai_viewers' ORDER BY id DESC LIMIT 1").get();
assert.deepStrictEqual([usage.provider, usage.owner_user_id], ['byo', 30], 'metered as the streamer\'s own');
// A key in AI without a subject never falls back to the shared key.
db.upsertChannelAiConfig(31, { byo_key: '', byo_in_ai: 1 });
const m = calls.length;
-- assert.strictEqual(await budget.generate(31, { user: 'x' }), null);
++ assert.strictEqual(await director.quickReply({ stableText: 's', situationText: '', bot: { username: 'b' }, streamerLine: 'x', provider: budget.byoProvider(db.getChannelAiConfig(31)) || { none: true }, ownerUserId: 31 }), null);
assert.strictEqual(calls.length, m, 'nothing was called');
// ── Saving the config ──