openvibers/openvibe.ai Commits

The AI viewers are versioned templates (roadmap WS-O task 2). live.viewers.plan, live.viewers.reply and live.viewers.fold move from passthrough to structured workflows (version 2), and live.viewers.clone is new. The director's rules, its plan schema, the fast-reply ask, the fold instructions and the clone brief come from Live's viewers/director.js, fold.js and roster.js. Live sends the stream's stable context and volatile tail it builds from the roster, settings, transcript and chat, the personas' lines and memories, or what is known about a chatter to clone. Token budgets follow the size as before (70 per line + 120, 160 per persona + 160), and the plan is normalised (targets, delays, threads). A run on a streamer's own key now works for templated workflows: every text operation is a chat completion on their provider (it refused generate), and their model is the one for the role the workflow's route names (live.director uses their director model). Tests: rendering in live-templates, end to end in workflows, and the director model on a person's key in credentials. npm test passes.

OP OpenVibers committed 28 Sept 2026 · 65b6a804e4 · 6 files, +141 −5


Changes

ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 4742dc5..c4b6fbc 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -138,9 +138,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
* request-log row per attempt under provider_key byo:<owner>:<subject>, priced by the model's list price.
*/
async function executeWithCredential(cred, operation, req, ctx = {}) {
-- if (operation !== 'chat') throw new AiError(422, 'credential.unsupported', `a person's own key is used for chat only, not ${operation}`);
++ // Text operations are all chat completions on the person's provider (templated workflows use generate).
++ if (!['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
-- const role = ctx.role && cred.models[ctx.role] ? ctx.role : null;
++ // The person's model for this role: the run's input.role, else the Live role its route names (live.director).
++ const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
++ const role = wanted && cred.models[wanted] ? wanted : null;
const model = (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || (cred.provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : 'gpt-4o-mini');
const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: ['chat', 'json', 'vision'], timeout_ms: 60000 };
const a = cred.provider === 'anthropic' ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });
ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex a4bdca2..4e14ee3 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -87,6 +87,22 @@ function chatLines(messages, withWhere) {
}
const PREPARE = {
++ // AI viewers (viewers/*.js). Live builds the stream context; the rules and the asks live here.
++ 'live.viewers.plan'(input) {
++ const n = input.max_lines || 3;
++ return { vars: { stable: input.stable, volatile: input.volatile, max_lines: n }, params: { max_tokens: 70 * n + 120 } };
++ },
++ 'live.viewers.reply'(input) {
++ return { vars: { stable: input.stable, situation: input.situation || '', bot: input.bot, streamer_line: input.streamer_line, max_words: input.max_words || 18 } };
++ },
++ 'live.viewers.fold'(input) {
++ const clip = (t, n) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, n);
++ const personas = input.personas.map((p) => `### ${p.username}\nCurrent memory: ${p.memory ? clip(p.memory, 700) : '(none)'}\nRecent lines:\n- ${(p.lines || []).map((l) => clip(l, 160)).join('\n- ')}`).join('\n\n');
++ return { vars: { channel_memory: clip(input.channel_memory, 600), personas }, params: { max_tokens: 160 * input.personas.length + 160 } };
++ },
++ 'live.viewers.clone'(input) {
++ return { vars: { name: input.name, overview: input.overview || '', memory: input.memory || '', samples: (input.samples || []).length ? `- ${input.samples.join('\n- ')}` : '' } };
++ },
'live.arena.persona'(input) {
return { vars: { facts: input.facts } };
},
@@@ -280,6 +296,28 @@ const PREPARE = {
};
const POSTPROCESS = {
++ 'live.viewers.plan'(r) {
++ const p = r.json || parseJsonLoose(r.text) || { skip: true, lines: [] };
++ const str = (v) => (v == null ? null : String(v));
++ const lines = (Array.isArray(p.lines) ? p.lines : []).filter((l) => l && typeof l === 'object' && typeof l.text === 'string' && l.bot).slice(0, 12).map((l) => ({
++ bot: String(l.bot).slice(0, 80), target: ['viewer', 'bot', 'streamer', 'ambient'].includes(l.target) ? l.target : 'ambient', reply_to: str(l.reply_to), thread: str(l.thread), topic: str(l.topic),
++ delay_ms: Math.max(0, Math.min(45000, Math.round(Number(l.delay_ms) || 0))), text: l.text.slice(0, 500), reason: String(l.reason || '').slice(0, 120),
++ }));
++ return { 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).slice(0, 20) : [] };
++ },
++ 'live.viewers.reply'(r) {
++ const t = String(r.text || '').trim();
++ return t ? { text: t.slice(0, 500) } : null;
++ },
++ 'live.viewers.fold'(r) {
++ const j = r.json || parseJsonLoose(r.text);
++ if (!j || !Array.isArray(j.memories)) return null;
++ return { memories: j.memories.filter((m) => m && m.bot).map((m) => ({ bot: String(m.bot).slice(0, 80), memory: String(m.memory || '').slice(0, 2000) })), channel_memory: String(j.channel_memory || '').slice(0, 2000) };
++ },
++ 'live.viewers.clone'(r) {
++ const t = String(r.text || '').trim();
++ return t ? { text: t.slice(0, 1000) } : null;
++ },
'live.arena.persona'(r) {
const j = r.json || parseJsonLoose(r.text);
return j && typeof j === 'object' && j.fighter_name ? j : null;
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex 5e23514..8d45f63 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -79,6 +79,42 @@ 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.viewers.plan', name: "Live: AI viewers director",
++ description: "From viewers/director.js plan() (ai_viewers_director): the rules in the system prompt, then the stream's stable context (both cached); the volatile tail is the user message.",
++ input_schema: { type: 'object' },
++ output_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"]},
++ system_prompt: "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.\n\nHard rules:\n- 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\".\n- 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.\n- 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.\n- 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.\n- \"ambient\" lines react to what is happening on stream (heard / seen) — not filler like \"nice stream\".\n- Use the transcript and screen description as the source of truth for what is happening; the timestamps tell you what is recent.\n- 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).\n- Respect the channel rules, tone, language, blocklist and the word limit. Plain text only; emoji only per the emote policy.\n- 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.\n\n{{stable}}",
++ user_prompt: "{{volatile}}\n\nPlan the next pass now (at most {{max_lines}} lines). Return JSON only.",
++ default_route: 'live.director', owner: 'live', metadata: { json_name: 'plan' },
++ },
++ {
++ key: 'live.viewers.reply', name: "Live: AI viewer fast reply to the streamer",
++ description: "From viewers/director.js quickReply() (ai_viewers_reply): one line from one viewer answering the streamer.",
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["text"], "properties": {"text": {"type": "string"}}},
++ system_prompt: "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.\n\nHard rules:\n- 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\".\n- 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.\n- 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.\n- 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.\n- \"ambient\" lines react to what is happening on stream (heard / seen) — not filler like \"nice stream\".\n- Use the transcript and screen description as the source of truth for what is happening; the timestamps tell you what is recent.\n- 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).\n- Respect the channel rules, tone, language, blocklist and the word limit. Plain text only; emoji only per the emote policy.\n- 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.\n\n{{stable}}",
++ user_prompt: "{{situation}}\n\nThe streamer just said: \"{{streamer_line}}\"\n\nWrite ONE chat line as {{bot}} answering the streamer directly and specifically (max {{max_words}} words, plain text, in character). Return only the line.",
++ default_route: 'live.chat', owner: 'live', metadata: {},
++ },
++ {
++ key: 'live.viewers.fold', name: "Live: AI viewers memory fold",
++ description: "From viewers/fold.js (ai_viewers_fold): every viewer's recent lines folded into its memory, plus the channel's running bits.",
++ input_schema: { type: 'object' },
++ output_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"]},
++ system_prompt: "",
++ user_prompt: "You maintain the memories of AI chat personas for one streaming channel.{{#channel_memory}}\n\nCurrent channel memory (running bits): {{channel_memory}}{{/channel_memory}}\n\n{{personas}}\n\nFor 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).",
++ default_route: 'live.summary', owner: 'live', metadata: { json_name: 'fold' },
++ },
++ {
++ key: 'live.viewers.clone', name: "Live: AI viewer cloned from a real chatter",
++ description: "From viewers/roster.js createCloneBot (ai_viewers): a character brief from what we know about a real viewer.",
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["text"], "properties": {"text": {"type": "string"}}},
++ system_prompt: "",
++ user_prompt: "You are profiling a chat viewer named \"{{name}}\" so an AI can role-play as them.{{#overview}}\n\nWhat we know about them: {{overview}}{{/overview}}{{#memory}}\n\nNotable memory: {{memory}}{{/memory}}{{#samples}}\n\nSample messages they've sent:\n{{samples}}{{/samples}}\n\nWrite a tight 2-3 sentence character brief capturing their vibe, interests, and how they type (tone, casing, slang, length). Second person (\"You are ...\"). No preamble.",
++ default_route: 'live.chat', owner: 'live', metadata: {},
++ },
{
key: 'live.arena.persona', name: "Live: an Arena fighter persona",
description: "From arena/arena-service.js generatePersona (arena_persona): the character-select bio, voice first, from what they said on stream.",
@@@ -317,6 +353,38 @@ 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.viewers.plan', name: "AI viewers: director plan", namespace: 'live',
++ description: "The director Live used to prompt (viewers/director.js), now a versioned template: the stream's stable context and its volatile tail in (Live builds both from the roster, settings, transcript and chat), a plan of up to max_lines lines out. Runs on a streamer's own key when the run names their credential.",
++ input_schema: TEXT_OUT({ stable: { type: 'string', maxLength: 40000 }, volatile: { type: 'string', maxLength: 60000 }, max_lines: { type: 'integer', minimum: 1, maximum: 12 }, temperature: { type: 'number', minimum: 0, maximum: 2 } }, ['stable', 'volatile']),
++ output_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"]},
++ steps: [{ kind: 'llm', template: 'live.viewers.plan', operation: 'generate', output: 'json', prepare: 'live.viewers.plan', postprocess: 'live.viewers.plan', params: { timeout_ms: 25000 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.viewers.reply', name: "AI viewers: fast streamer reply", namespace: 'live',
++ description: "One line from one viewer answering the streamer (viewers/director.js quickReply).",
++ input_schema: TEXT_OUT({ stable: { type: 'string', maxLength: 40000 }, situation: { type: 'string', maxLength: 20000 }, bot: STR(80), streamer_line: STR(400), max_words: { type: 'integer', minimum: 1, maximum: 80 } }, ['stable', 'bot', 'streamer_line']),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["text"], "properties": {"text": {"type": "string"}}},
++ steps: [{ kind: 'llm', template: 'live.viewers.reply', operation: 'generate', output: 'text', prepare: 'live.viewers.reply', postprocess: 'live.viewers.reply', params: { max_tokens: 70, temperature: 0.9, timeout_ms: 20000 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.viewers.fold', name: "AI viewers: memory fold", namespace: 'live',
++ description: "Every viewer's recent lines folded into its memory, plus the channel memory (viewers/fold.js).",
++ input_schema: TEXT_OUT({ channel_memory: STR(2000), personas: { type: 'array', minItems: 1, maxItems: 30, items: { type: 'object', required: ['username', 'lines'], additionalProperties: false, properties: { username: STR(80), memory: STR(2000), lines: { type: 'array', maxItems: 20, items: STR(400) } } } } }, ['personas']),
++ output_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"]},
++ steps: [{ kind: 'llm', template: 'live.viewers.fold', operation: 'generate', output: 'json', prepare: 'live.viewers.fold', postprocess: 'live.viewers.fold', params: { temperature: 0.4, timeout_ms: 30000 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.viewers.clone', name: "AI viewers: brief for a cloned chatter", namespace: 'live',
++ description: "A character brief from what is known about a real viewer, so a clone bot can play them (viewers/roster.js).",
++ input_schema: TEXT_OUT({ name: STR(120), overview: STR(2000), memory: STR(2000), samples: { type: 'array', maxItems: 24, items: STR(400) } }, ['name']),
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["text"], "properties": {"text": {"type": "string"}}},
++ steps: [{ kind: 'llm', template: 'live.viewers.clone', operation: 'generate', output: 'text', prepare: 'live.viewers.clone', postprocess: 'live.viewers.clone', params: { max_tokens: 160, temperature: 0.7, timeout_ms: 20000 } }],
++ cache_mode: 'none',
++ },
{
key: 'live.arena.persona', name: "Arena fighter persona", namespace: 'live',
description: "The prompt Live used to render (arena-service.js), now a versioned template: the fighter's facts in (numbers, what they said, their shit talk, rivals); the persona out.",
@@@ -509,10 +577,7 @@ const PASSTHROUGH_OUTPUT = { type: 'object', required: ['text', 'json'], propert
const PASSTHROUGH = [
['live.complete', 'Any other Live completion', 'Fallback for a Live llm.complete() kind without its own workflow yet.'],
-- ['live.viewers.plan', 'AI viewers: director plan', 'viewers/director.js plan() — ai_viewers_director.'],
-- ['live.viewers.reply', 'AI viewers: fast streamer reply', 'viewers/director.js quickReply() — ai_viewers_reply.'],
['live.viewers.line', 'AI viewers: single line', 'viewers/budget.js generate() — ai_viewers (chat reply).'],
-- ['live.viewers.fold', 'AI viewers: memory fold', 'viewers/fold.js — ai_viewers_fold.'],
['live.chat.insight', 'Chat insights and session overviews', 'chat-ai.js and chat-ai-routes.js — chat_global, chat_user, chat_relay, chat_anon, combined_overview, session_titles.'],
['live.arena.scene', 'Arena: scene description', 'arena-service.js — arena_scene.'],
['live.arena.judge', 'Arena: beef and mic judging', 'arena/listener.js — arena_beef_judge, arena_mic_judge.'],
ddiff --git a/test/credentials.test.js b/test/credentials.test.js
iindex e338c32..d643821 100644
---- a/test/credentials.test.js
++++ b/test/credentials.test.js
@@@ -87,6 +87,16 @@ t.test('a run with the credential uses that key only, uncached, and its budget s
assert.deepStrictEqual([r.status, r.body.code], [404, 'credential.not_found']);
});
++t.test("a templated workflow on a person's key uses their model for the route's role", async () => {
++ const EVE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';
++ let r = await request(h.base, 'PUT', `/api/v1/credentials/${EVE}`, { tok: live, body: { provider: 'openai', base_url: 'https://llm.example.com/v1', api_key: 'sk-test-dana-0123456789WXYZ', models: { chat: 'm-chat', director: 'm-director' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body: { workflow: 'live.viewers.plan', input: { stable: 'roster', volatile: 'chat', max_lines: 2 }, credential: { subject: EVE } } });
++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run.error || r.body.run).slice(0, 400));
++ assert.strictEqual(calls[calls.length - 1].body.model, 'm-director', 'live.director -> the director model');
++ await request(h.base, 'DELETE', `/api/v1/credentials/${EVE}`, { tok: live });
++});
++
t.test('the guarded fetch reaches only public https endpoints', async () => {
for (const [url, why] of [['http://example.com/v1', 'https only'], ['https://127.0.0.1/v1', 'IP literal'], ['https://user:[email protected]/v1', 'credentials in URL'], ['https://localhost:9/v1', 'resolves to loopback']]) {
await assert.rejects(guardedFetch(url, { method: 'POST', headers: {}, body: '{}' }), (e) => e.code === 'EADDRNOTPUBLIC', why);
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex 15e4d5a..ee43083 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -117,4 +117,20 @@ t.test('Arena persona, quotes, headline and clip confirmation render as Live ren
assert.deepStrictEqual(POSTPROCESS['live.clips.confirm']({ text: '{"clip": "yes", "title": " A B "}' }), { clip: false, title: 'A B', desc: '' });
});
++t.test('the AI viewers render as Live rendered them', () => {
++ const tp = (k) => live.templates.find((x) => x.key === k);
++ const plan = tp('live.viewers.plan');
++ assert.match(render(plan.system_prompt, PREPARE['live.viewers.plan']({ stable: 'STABLE', volatile: 'V' }).vars), /^You are the DIRECTOR of a cast of AI viewers[\s\S]*return skip=true with an empty lines array\.\n\nSTABLE$/);
++ assert.strictEqual(render(plan.user_prompt, PREPARE['live.viewers.plan']({ stable: 'S', volatile: 'VOL', max_lines: 4 }).vars), 'VOL\n\nPlan the next pass now (at most 4 lines). Return JSON only.');
++ assert.strictEqual(PREPARE['live.viewers.plan']({ stable: 'S', volatile: 'V', max_lines: 4 }).params.max_tokens, 400);
++ assert.strictEqual(render(tp('live.viewers.reply').user_prompt, PREPARE['live.viewers.reply']({ stable: 'S', situation: 'SIT', bot: 'goosebot', streamer_line: 'hi chat', max_words: 12 }).vars), 'SIT\n\nThe streamer just said: "hi chat"\n\nWrite ONE chat line as goosebot answering the streamer directly and specifically (max 12 words, plain text, in character). Return only the line.');
++ const fold = PREPARE['live.viewers.fold']({ personas: [{ username: 'goosebot', lines: ['a', 'b c'] }] });
++ assert.strictEqual(render(tp('live.viewers.fold').user_prompt, fold.vars), 'You maintain the memories of AI chat personas for one streaming channel.\n\n### goosebot\nCurrent memory: (none)\nRecent lines:\n- a\n- b c\n\nFor 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).');
++ assert.strictEqual(fold.params.max_tokens, 320);
++ const clone = render(tp('live.viewers.clone').user_prompt, PREPARE['live.viewers.clone']({ name: 'bob', samples: ['W', 'LETS GO'] }).vars);
++ assert.strictEqual(clone, 'You are profiling a chat viewer named "bob" so an AI can role-play as them.\n\nSample messages they\'ve sent:\n- W\n- LETS GO\n\nWrite a tight 2-3 sentence character brief capturing their vibe, interests, and how they type (tone, casing, slang, length). Second person ("You are ..."). No preamble.');
++ const p = POSTPROCESS['live.viewers.plan']({ json: { skip: true, notes: 'n', lines: [{ bot: 'goosebot', target: 'weird', text: 'honk', delay_ms: 99999 }, { text: 'no bot' }], threads_close: [3] } });
++ assert.deepStrictEqual([p.skip, p.lines.length, p.lines[0].target, p.lines[0].delay_ms, p.threads_close], [false, 1, 'ambient', 45000, ['3']]);
++});
++
t.run();
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex c13e437..9487805 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -48,6 +48,10 @@ 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.viewers.plan'] = { stable: 'Stream: Rust night by ann. Roster: goosebot (a goose who loves croutons), bytebot (a pedantic compiler).', volatile: 'Transcript [1:34] it compiles!\nChat: bob: LETS GO', max_lines: 2, temperature: 0.9 };
++EXAMPLES['live.viewers.reply'] = { stable: 'Stream: Rust night by ann. Roster: goosebot.', situation: 'ann is celebrating a build.', bot: 'goosebot', streamer_line: 'goosebot what do you think', max_words: 18 };
++EXAMPLES['live.viewers.fold'] = { channel_memory: 'croutons', personas: [{ username: 'goosebot', memory: '', lines: ['honk', 'croutons are soup confetti', 'compile faster'] }] };
++EXAMPLES['live.viewers.clone'] = { name: 'bob', overview: 'Hypes every build.', memory: '', samples: ['LETS GO', 'W'] };
EXAMPLES['live.arena.persona'] = { facts: { name: 'Ann', handle: 'ann', category: 'desktop', ratings: { heat: 70 }, things_they_said_on_stream: ['this borrow checker hates me'], roster_rivals: ['bob'] } };
EXAMPLES['live.arena.quotes'] = { lines: ['this borrow checker hates me and I hate it back', 'chat you are all backseat compilers', 'I have never been this cooked'] };
EXAMPLES['live.arena.headline'] = { kind: 'open', a: 'ann', b: 'bob', line: 'bob streams to his own alt accounts', rematch: false };