openvibers/openvibe.live Commits
AI viewers: remove the legacy per-line engine; flatten v3 into server/ai/viewers; drop the engine setting/override
Changes
@@@ -463,24 +463,19 @@ router.get('/ai/usage', (req, res) => {router.get('/ai/viewers/status', (req, res) => {try {const viewers = require('../ai/viewers');-- const v3 = viewers.v3(); const v2 = viewers.v2();const running = [];-- for (const w of v3.workers.values()) {++ for (const w of viewers.engine.workers.values()) {const u = db.getUserById(w.userId);running.push({ engine: 'v3', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length, mode: w.mode, paused: w.paused, stats: w.stats, started_at: w.startedAt });}-- for (const w of v2.workers.values()) {-- const u = db.getUserById(w.userId);-- running.push({ engine: 'v2', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length });-- }const channels = db.all(`SELECT c.user_id, u.username, c.enabled, c.use_shared_key, c.daily_budget_cents, c.settings_json,(SELECT COUNT(*) FROM channel_ai_bots b WHERE b.channel_user_id = c.user_id AND b.is_active = 1) AS botsFROM channel_ai_config c JOIN users u ON u.id = c.user_id ORDER BY c.enabled DESC, u.username`).map(r => {let sj = {}; try { sj = JSON.parse(r.settings_json || '{}') || {}; } catch { /* */ }let spent = 0; try { spent = db.getAiCostTodayForUser(r.user_id, 'ai_viewers'); } catch { /* */ }-- return { user_id: r.user_id, username: r.username, enabled: !!r.enabled, shared_key: !!r.use_shared_key, cap_usd: (r.daily_budget_cents || 0) / 100, spent_today_usd: spent, bots: r.bots, engine: sj.engine || null, activity: sj.activity || null };++ return { user_id: r.user_id, username: r.username, enabled: !!r.enabled, shared_key: !!r.use_shared_key, cap_usd: (r.daily_budget_cents || 0) / 100, spent_today_usd: spent, bots: r.bots, activity: sj.activity || null };});-- res.json({ engine_default: viewers.version, kill_switch: db.getSetting('ai_viewers_enabled') === false, global_spend_today_usd: require('../ai/viewers/budget').globalViewerSpendToday(), running, channels });++ res.json({ kill_switch: db.getSetting('ai_viewers_enabled') === false, global_spend_today_usd: require('../ai/viewers/budget').globalViewerSpendToday(), running, channels });} catch (err) { res.status(500).json({ error: err.message }); }});router.post('/ai/viewers/:userId/:action', (req, res) => {ddeleted file mode 100644@@@ -1,59 +0,0 @@--/**-- * AI Chat Viewers — persistent brains + rolling memory.-- *-- * A bot's brain_json holds a condensed identity + rolling memory + a short timeline-- * that persists across streams (stored on channel_ai_bots). Periodically we fold the-- * bot's recent activity into that memory with a cheap metered LLM pass, so a bot that-- * chatted last week remembers running jokes and who it talked to.-- */--const db = require('../../db/database');--const budget = require('./budget');----function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }----/**-- * Fold a bot's recent lines + notable events into its rolling memory.-- * @param {number} streamerId-- * @param {object} bot channel_ai_bots row-- * @param {string[]} recentLines what the bot said / reacted to since the last fold-- * @returns {Promise<boolean>} whether the brain was updated-- */--async function foldBrain(streamerId, bot, recentLines) {-- if (!bot || !recentLines || recentLines.length < 3) return false;-- let brain = {};-- try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }-- const prevMemory = brain.memory || '';---- const prompt = [-- `You maintain the memory of a chat persona named "${bot.display_name || bot.username}".`,-- prevMemory ? `Current memory:\n${clip(prevMemory, 800)}` : `No memory yet.`,-- `Recent things they said/saw in chat:\n- ${recentLines.map(l => clip(l, 160)).slice(-20).join('\n- ')}`,-- `Update their memory: a tight paragraph (max 90 words) of durable facts — running jokes, who they talk to, opinions they've formed, recurring topics. Keep the useful old stuff, drop the trivial. Return ONLY the updated memory paragraph.`,-- ].join('\n\n');---- let updated;-- try {-- updated = await budget.generate(streamerId, { system: '', user: prompt, maxTokens: 160, temperature: 0.4 });-- } catch { return false; }-- if (!updated || !updated.trim()) return false;---- brain.memory = clip(updated, 900);-- brain.timeline = (brain.timeline || []).slice(-8);-- brain.timeline.push({ n: bot.msg_count || 0, note: clip(recentLines[recentLines.length - 1], 80) });-- try {-- db.updateChannelAiBot(bot.id, { brain_json: brain });-- return true;-- } catch { return false; }--}----/** Clear a bot's rolling memory (keeps its identity/persona). */--function clearBrain(botId) {-- const bot = db.getChannelAiBot(botId);-- if (!bot) return false;-- let persona = {};-- try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }-- db.updateChannelAiBot(botId, { brain_json: { memory: '', timeline: [], identity: persona.identity || '' } });-- return true;--}----module.exports = { foldBrain, clearBrain };ddeleted file mode 100644@@@ -1,117 +0,0 @@--/**-- * AI Chat Viewers — context assembly.-- *-- * Builds a compact, layered prompt for one bot line from data we already produce:-- * - the streamer's profile (streamer_overviews)-- * - what's happening now (live "situation": audio transcript + on-screen vision)-- * - room mood (global chat insight)-- * - who they're replying to (that viewer's insight/brain)-- * - the bot's own persistent persona + brain-- *-- * The shared per-tick context (streamer/global/situation/recent chat) is computed-- * once by refreshShared() and reused by every bot in the tick; only the per-target-- * viewer insight varies per line.-- */--const db = require('../../db/database');--const chatAi = require('../chat-ai');----function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }----/** Compute the per-tick shared context for a stream (cached on the worker). */--function refreshShared(worker) {-- const s = worker.stream || {};-- const streamerId = worker.userId;-- let streamerOverview = '';-- try {-- const ov = db.getStreamerOverview(streamerId);-- streamerOverview = clip(ov && (ov.overview_short || ov.overview), 700);-- } catch { /* */ }-- let globalMood = '';-- try {-- const g = chatAi.getGlobalInsight();-- if (g) globalMood = clip(g.overview, 300);-- } catch { /* */ }-- worker.shared = {-- streamerOverview,-- globalMood,-- streamMeta: {-- title: clip(s.title, 140),-- category: clip(s.category, 60),-- streamer: clip(s.display_name || s.username, 60),-- },-- at: Date.now(),-- };-- return worker.shared;--}----// Recent chat lines the bot can "see" (worker keeps a rolling buffer).--function recentChatText(worker, max = 14) {-- const lines = (worker.recentChat || []).slice(-max);-- return lines.map(l => `${l.username}: ${clip(l.message, 160)}`).join('\n');--}----// Insight text for the viewer a bot is replying to (real or relay).--function targetInsightText(target) {-- if (!target) return '';-- try {-- let ins = null;-- if (target.kind === 'user' && target.userId) ins = chatAi.getUserInsight(target.userId);-- else if (target.kind === 'relay' && target.relayId) ins = chatAi.getRelayUserInsight(target.relayId);-- if (!ins) return '';-- const ov = ins.overview_alltime || ins.overview_24h || '';-- const mem = ins.memory || '';-- return clip([ov, mem].filter(Boolean).join(' — '), 400);-- } catch { return ''; }--}----/**-- * Build {system, user} for one bot line.-- * @param {object} worker-- * @param {object} bot channel_ai_bots row-- * @param {{targetUser?:object, addressed?:boolean, situationLine?:string}} [opts]-- */--function buildBotContext(worker, bot, opts = {}) {-- const shared = worker.shared || refreshShared(worker);-- let persona = {}; let brain = {};-- try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }-- try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }---- const charBlurb = (persona.character && persona.character.blurb) || 'a chat viewer';-- const styleRules = (persona.style && persona.style.rules) || 'casual, short';-- const identity = persona.identity || brain.identity || '';-- const memory = brain.memory || '';---- const sys = [-- `You are "${bot.display_name || bot.username}", a viewer in a live stream's chat. You are ${charBlurb}.`,-- identity ? `Your identity: ${clip(identity, 500)}` : '',-- `Typing style: ${styleRules}.`,-- memory ? `Things you remember from this channel: ${clip(memory, 500)}` : '',-- shared.streamerOverview ? `About the streamer (${shared.streamMeta.streamer}): ${shared.streamerOverview}` : '',-- shared.globalMood ? `Site chat mood right now: ${shared.globalMood}` : '',-- `Rules: Reply with ONE short chat message only (usually under 15 words). Stay fully in character. Never say you are an AI, a bot, or a language model. No quotation marks, no emojis unless your style uses them, no meta commentary. Just the message text.`,-- ].filter(Boolean).join('\n');---- const situation = opts.situationLine || worker.situationLine || '';-- const recent = recentChatText(worker);-- const targetTxt = opts.targetUser ? targetInsightText(opts.targetUser) : '';---- const userParts = [-- `Stream: "${shared.streamMeta.title}"${shared.streamMeta.category ? ` [${shared.streamMeta.category}]` : ''}.`,-- situation ? `What's happening on stream right now: ${situation}` : '',-- recent ? `Recent chat:\n${recent}` : 'Chat is quiet right now.',-- ];-- if (opts.targetUser) {-- const who = opts.targetUser.username || 'someone';-- userParts.push(targetTxt-- ? `You're replying to ${who}. What you know about ${who}: ${targetTxt}`-- : `You're replying to ${who}.`);-- if (opts.targetUser.message) userParts.push(`${who} just said: "${clip(opts.targetUser.message, 200)}"`);-- } else if (opts.addressed) {-- userParts.push(`The streamer just addressed chat. React naturally.`);-- }-- userParts.push(`Write your next chat message as ${bot.username}:`);---- return { system: sys, user: userParts.filter(Boolean).join('\n\n') };--}----module.exports = { refreshShared, buildBotContext, recentChatText, targetInsightText };nnew file mode 100644@@@ -0,0 +1,103 @@++/**++ * AI viewers v3 — the director.++ *++ * ONE structured LLM call 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.++ */++'use strict';++const llm = require('../llm');++++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 },++ ];++}++++/**++ * 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 }++ */++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,++ });++ if (!r) return null;++ let p = r.json;++ if (!p || typeof p !== 'object') p = llm.parseJsonLoose(r.text) || { skip: true, lines: [] };++ 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).++ */++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;++}++++module.exports = { plan, quickReply, PLAN_SCHEMA, DIRECTOR_RULES, buildSystem };@@@ -1,364 +1,428 @@/**-- * AI Chat Viewers 2.0 — engine + lifecycle.++ * AI Chat Viewers v3 — engine.*-- * Persistent per-channel bot roster reacts to the live situation (audio + vision),-- * the room mood, and the specific viewers they reply to. One bot speaks at a time,-- * paced to feel human; every LLM call is metered + budget-capped per streamer.-- *-- * Wired at the same lifecycle hooks the old ai-chatbot-service used (startForStream /-- * stopForStream / onRealChatMessage / applyConfigForUser / hasWorker / stopForUser),-- * so ../integrations/ai-chatbot-service.js re-exports this singleton.++ * Per live stream: a worker holds the roster, settings, a cached stable prompt prefix,++ * a scheduler, and pending intents. A DIRECTOR pass runs on a cadence (or immediately++ * on a priority intent), assembles the unified context (only deltas), makes ONE++ * structured LLM call that plans several lines, and hands them to the scheduler.++ * Streamer mentions get a guaranteed answer via a fast path when the next pass is far.++ * Nothing runs when nothing changed. Spend degrades gracefully toward the streamer's cap.*/++'use strict';const db = require('../../db/database');--const budget = require('./budget');++const settingsMod = require('./settings');const roster = require('./roster');--const context = require('./context');--const brain = require('./brain');++const budget = require('./budget');++const context = require('../context');++const director = require('./director');++const poster = require('./poster');++const mention = require('./mention');++const fold = require('./fold');++const { Scheduler } = require('./scheduler');--// ── Pacing constants ──────────────────────────────────────────--const GLOBAL_MIN_GAP_MS = 3500; // hard floor between any two bot lines--const ACTIVE_GAP_MS = [5000, 11000]; // target gap when chat/stream is active--const IDLE_GAP_MS = [14000, 30000]; // target gap when quiet--const TICK_MS = 2500; // pacer resolution--const ACTIVE_WINDOW_MS = 25000; // "active" = real input within this window--const SITUATION_AUDIO_MS = 30000; // how often to sample mic (free local STT)--const SITUATION_VISION_MS = 75000; // how often to sample the screen (metered)--const BRAIN_FOLD_MS = 180000; // how often to fold bots' rolling memory--const RECENT_CHAT_MAX = 30;--const REPLY_PROB = 0.6; // chance a real viewer msg gets a bot reply++const ACTIVE_WINDOW_MS = 45000;++const SKIP_LOG_EVERY_MS = 5 * 60 * 1000;++const STREAMER_QUIET_MS = 8000; // after answering the streamer, hold ambient chatter briefly--function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }++function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }++function norm(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); }--class AiViewersEngine {-- constructor() {-- /** @type {Map<number, object>} streamId → worker */-- this.workers = new Map();-- }++class AiViewersEngineV3 {++ constructor() { this.workers = new Map(); this.version = 'v3'; }hasWorker(streamId) { return this.workers.has(streamId); }++ workerForUser(userId) { for (const w of this.workers.values()) if (w.userId === userId) return w; return null; }++++ _killSwitch() { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; }// ── Lifecycle ─────────────────────────────────────────────async startForStream(stream) {try {if (!stream || !stream.id || !stream.user_id) return;if (this.workers.has(stream.id)) return;++ if (this._killSwitch()) return;const cfg = db.getChannelAiConfig(stream.user_id);if (!cfg.enabled) return;---- // Full row for capture (needs protocol/managed_stream_key).++ const settings = settingsMod.getSettings(stream.user_id, cfg);let full = stream;try { full = db.getStreamById(stream.id) || stream; } catch { /* */ }---- const target = Math.max(0, Math.min(12, cfg.num_ambient_bots || 0));-- const bots = roster.ensureRoster(stream.user_id, target);-- if (!bots.length) return; // nothing to say with++ if (full.managed_stream_id && settings.slots && settings.slots[String(full.managed_stream_id)] === false) {++ console.log(`[AI-Viewers] slot ${full.managed_stream_id} has AI viewers off — not starting for stream ${stream.id}`);++ return;++ }++ const bots = roster.ensureRoster(stream.user_id, settings.roster_size);++ if (!bots.length) return;const worker = {-- streamId: stream.id,-- userId: stream.user_id,-- stream: full,-- config: cfg,-- bots,-- recentChat: [],-- situationLine: '',-- lastPostAt: 0,-- lastPoster: null,-- nextGapMs: rint(IDLE_GAP_MS[0], IDLE_GAP_MS[1]),-- lastRealInputAt: 0,-- intents: [], // [{prio, botId, target, addressed}]-- botLines: new Map(), // botId → recent lines (for brain fold)-- generating: false,-- stopped: false,-- paceTimer: null, audioTimer: null, visionTimer: null, brainTimer: null,++ streamId: stream.id, userId: stream.user_id, stream: full, cfg, settings, bots,++ stable: null, // cached stable prefix {text,hash,at}++ lastChatId: db.getMaxChatMessageIdForChannel(stream.user_id),++ lastTickAt: 0, nextTickAt: 0, tickTimer: null, foldTimer: null, ticking: false,++ lastRealInputAt: 0, lastStreamerReplyAt: 0, lastSkipLogAt: 0, lastVolatileHash: null,++ intents: [], // [{ kind:'streamer'|'mention'|'sound'|'scene', text, bots:[], at }]++ botLines: new Map(), // botId → recent lines (memory fold)++ recentLines: [], // normalized last 30 bot lines (dedupe)++ lastSeenMemoryId: null, lastPeriodicVisionAt: 0,++ paused: !!(settings.runtime && settings.runtime.paused),++ mode: 'normal',++ stats: { ticks: 0, lines: 0, skips: 0, cost: 0 },++ startedAt: Date.now(), stopped: false,};-- context.refreshShared(worker);++ worker.scheduler = new Scheduler(worker, {++ post: (line) => this._post(worker, line),++ dropped: (line, why) => poster.log(worker, { event: 'skip', bot_username: line.bot, text: line.text, reason: why }),++ });this.workers.set(stream.id, worker);---- worker.paceTimer = setTimeout(() => this._tick(worker), TICK_MS);-- if (cfg.transcribe_enabled) worker.audioTimer = setInterval(() => this._captureAudio(worker), SITUATION_AUDIO_MS);-- if (cfg.vision_enabled) worker.visionTimer = setInterval(() => this._captureVision(worker), SITUATION_VISION_MS);-- worker.brainTimer = setInterval(() => this._foldBrains(worker), BRAIN_FOLD_MS);---- console.log(`[AI-Viewers] Started for stream ${stream.id} (streamer ${stream.user_id}, ${bots.length} bots)`);-- } catch (e) {-- console.warn('[AI-Viewers] startForStream failed:', e.message);-- }++ poster.log(worker, { event: 'info', reason: `engine v3 started — ${bots.length} viewer(s), ${settings.activity} activity, director every ${settings.director_interval_sec}s` });++ console.log(`[AI-Viewers v3] started for stream ${stream.id} (user ${stream.user_id}, ${bots.length} bots)`);++ this._scheduleTick(worker, 4000);++ worker.foldTimer = setInterval(() => this._fold(worker).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000);++ } catch (e) { console.warn('[AI-Viewers v3] start failed:', e.message); }}stopForStream(streamId) {-- const worker = this.workers.get(streamId);-- if (!worker) return;-- worker.stopped = true;-- clearTimeout(worker.paceTimer);-- clearInterval(worker.audioTimer);-- clearInterval(worker.visionTimer);-- clearInterval(worker.brainTimer);++ const w = this.workers.get(streamId);++ if (!w) return;++ w.stopped = true;++ if (w.tickTimer) clearTimeout(w.tickTimer);++ if (w.foldTimer) clearInterval(w.foldTimer);++ w.scheduler.stop();this.workers.delete(streamId);-- console.log(`[AI-Viewers] Stopped for stream ${streamId}`);-- }---- stopForUser(userId) {-- for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid);++ try { db.closeAllAiViewerThreads(w.userId); } catch { /* */ }++ this._fold(w).catch(() => {});++ poster.log(w, { event: 'info', reason: `stopped — ${w.stats.lines} line(s), ${w.stats.ticks} pass(es), $${w.stats.cost.toFixed(4)}` });++ console.log(`[AI-Viewers v3] stopped for stream ${streamId}`);}++ stopForUser(userId) { for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid); }-- // Hot-apply config changes to any live streams for this user.++ /** Hot-apply config: reload settings/roster on live workers; start/stop as needed. */applyConfigForUser(userId) {-- for (const [, w] of this.workers) {++ const cfg = db.getChannelAiConfig(userId);++ const settings = settingsMod.getSettings(userId, cfg);++ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();++ for (const [sid, w] of this.workers) {if (w.userId !== userId) continue;-- const cfg = db.getChannelAiConfig(userId);-- w.config = cfg;-- if (!cfg.enabled) { this.stopForStream(w.streamId); continue; }-- const target = Math.max(0, Math.min(12, cfg.num_ambient_bots || 0));-- w.bots = roster.ensureRoster(userId, target);-- // Re-arm situation timers to match toggles.-- clearInterval(w.audioTimer); clearInterval(w.visionTimer);-- w.audioTimer = cfg.transcribe_enabled ? setInterval(() => this._captureAudio(w), SITUATION_AUDIO_MS) : null;-- w.visionTimer = cfg.vision_enabled ? setInterval(() => this._captureVision(w), SITUATION_VISION_MS) : null;++ if (!cfg.enabled || this._killSwitch() || (w.stream.managed_stream_id && settings.slots && settings.slots[String(w.stream.managed_stream_id)] === false)) { this.stopForStream(sid); continue; }++ w.cfg = cfg; w.settings = settings; w.paused = !!(settings.runtime && settings.runtime.paused);++ try { w.bots = roster.ensureRoster(userId, settings.roster_size); } catch { /* */ }++ w.stable = null; // prefix inputs may have changed++ if (w.foldTimer) { clearInterval(w.foldTimer); w.foldTimer = setInterval(() => this._fold(w).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000); }++ poster.log(w, { event: 'info', reason: 'settings applied' });}-- // If enabled but not yet running for a live stream, the next go-live picks it up.++ if (cfg.enabled && !this._killSwitch()) for (const s of live) if (!this.workers.has(s.id)) this.startForStream(s).catch(() => {});}reloadForUser(userId) { return this.applyConfigForUser(userId); }-- // ── Reactive: real chat / streamer input ──────────────────-- onRealChatMessage(streamId, { username, message, userId }) {-- const worker = this.workers.get(streamId);-- if (!worker || worker.stopped) return;-- // Ignore our own bots (defensive; bots don't route through here).-- if (worker.bots.some(b => (b.username || '').toLowerCase() === (username || '').toLowerCase())) return;---- worker.recentChat.push({ username, message, userId, ts: Date.now() });-- if (worker.recentChat.length > RECENT_CHAT_MAX) worker.recentChat.shift();-- worker.lastRealInputAt = Date.now();---- const isStreamer = userId && userId === worker.userId;-- const addressedChat = isStreamer || /\b(chat|guys|everyone|yall|y'all)\b/i.test(message || '');---- // Decide whether a bot replies to this specific viewer.-- if (!isStreamer && Math.random() < REPLY_PROB) {-- const bot = this._pickBot(worker);-- if (bot) {-- worker.intents.push({-- prio: 2, botId: bot.id,-- target: { kind: userId ? 'user' : 'relay', userId: userId || null, relayId: null, username, message },-- addressed: false,-- });-- }-- } else if (addressedChat) {-- const bot = this._pickBot(worker);-- if (bot) worker.intents.push({ prio: isStreamer ? 3 : 1, botId: bot.id, target: null, addressed: true });++ // ── Chat events (native, relayed, anon — everything public) ─++ onRealChatMessage(streamId, ev) { return this.onChatEvent(streamId, ev); }++ onChatEvent(streamId, ev = {}) {++ const w = this.workers.get(streamId);++ if (!w || w.stopped) return;++ const uname = String(ev.username || '');++ if (ev.isBot || w.bots.some(b => b.username.toLowerCase() === uname.toLowerCase())) return; // never react to our own lines++ if (ev.platform && !w.settings.reply_to_relay_chat) return;++ const isStreamer = !!ev.isStreamer || (ev.userId && ev.userId === w.userId);++ const text = String(ev.message || '');++ // Streamer/mod shortcut: "!ai pause" etc. also works from relayed chat for the streamer.++ if (/^!ai\b/i.test(text) && (isStreamer || ev.isMod)) {++ try { this.onModCommand(w.userId, streamId, text.split(/\s+/).slice(1), { by: uname }); } catch { /* */ }++ return;}-- // Keep the intent queue small.-- if (worker.intents.length > 6) worker.intents = worker.intents.slice(-6);-- }---- // ── Pacer ─────────────────────────────────────────────────-- _tick(worker) {-- if (worker.stopped) return;-- worker.paceTimer = setTimeout(() => this._tick(worker), TICK_MS);-- if (worker.generating) return;---- const now = Date.now();-- const gap = now - worker.lastPostAt;-- if (gap < GLOBAL_MIN_GAP_MS) return;---- const active = (now - worker.lastRealInputAt) < ACTIVE_WINDOW_MS;-- if (gap < worker.nextGapMs) {-- // Not yet due for ambient — but a queued reactive intent can jump the gap.-- if (!worker.intents.length) return;++ w.lastRealInputAt = Date.now();++ const det = mention.detect(text, w.bots, { isStreamer });++ if (isStreamer && (det.addressedChat || det.bots.length)) {++ this._pushIntent(w, { kind: 'streamer', text: `The streamer (${uname}) said: "${clip(text, 220)}"${det.bots.length ? ` — addressed to ${det.bots.join(', ')}` : ' — addressed to chat'}. A reply is REQUIRED.`, bots: det.bots, line: text, at: Date.now(), msgId: ev.msgId || null });++ this._fastPath(w, { line: text, bots: det.bots, replyTo: uname, msgId: ev.msgId || null }).catch(() => {});++ return;}---- // Pick the work: highest-priority queued intent, else maybe ambient filler.-- let intent = null;-- if (worker.intents.length) {-- worker.intents.sort((a, b) => b.prio - a.prio);-- intent = worker.intents.shift();-- } else {-- // Ambient filler only when due and (probabilistically) when active/idle.-- if (gap < worker.nextGapMs) return;-- const prob = active ? 0.7 : 0.4;-- if (Math.random() > prob) { worker.nextGapMs = this._nextGap(active); worker.lastPostAt = now; return; }-- const bot = this._pickBot(worker);-- if (!bot) return;-- intent = { botId: bot.id, target: null, addressed: false };++ if (det.bots.length) {++ this._pushIntent(w, { kind: 'mention', text: `${uname} addressed ${det.bots.join(', ')}: "${clip(text, 200)}" — that viewer should be answered.`, bots: det.bots, at: Date.now(), replyTo: uname });++ this._scheduleTick(w, 2500);++ return;}---- this._emit(worker, intent).catch(e => console.warn('[AI-Viewers] emit failed:', e.message));++ if (isStreamer) { this._scheduleTick(w, Math.min(6000, w.settings.director_interval_sec * 1000)); return; }++ // Regular viewer message: the director decides; reply probability trims how often++ // we even suggest it, and a burst of new chat pulls the next pass closer.++ if (Math.random() < (w.settings.reply_probability == null ? 0.6 : w.settings.reply_probability)) {++ this._pushIntent(w, { kind: 'viewer', text: `${uname} said: "${clip(text, 200)}" — consider replying to them.`, bots: [], at: Date.now(), replyTo: uname, soft: true });++ }++ if (Date.now() - w.lastTickAt > 8000) this._scheduleTick(w, 6000);++ }++ _pushIntent(w, intent) {++ w.intents.push(intent);++ // Hard intents first; keep the queue short so a burst can't stack ten replies.++ w.intents.sort((a, b) => (a.soft ? 1 : 0) - (b.soft ? 1 : 0) || a.at - b.at);++ if (w.intents.length > 6) w.intents = w.intents.slice(0, 6);}-- _nextGap(active) {-- const r = active ? ACTIVE_GAP_MS : IDLE_GAP_MS;-- return rint(r[0], r[1]);++ // ── Mod commands: /ai … or !ai … ─────────────────────────++ onModCommand(channelUserId, streamId, args = [], { by = null } = {}) {++ const [cmd, ...rest] = (args || []).map(a => String(a || '').trim()).filter(Boolean);++ const w = this.workerForUser(channelUserId);++ const settings = settingsMod.getSettings(channelUserId);++ const runtime = { paused: !!(settings.runtime && settings.runtime.paused), muted: (settings.runtime && settings.runtime.muted) || [] };++ const save = () => { settingsMod.updateSettings(channelUserId, { runtime }); if (w) { w.settings = settingsMod.getSettings(channelUserId); w.paused = runtime.paused; w.stable = null; } };++ switch ((cmd || 'status').toLowerCase()) {++ case 'pause': runtime.paused = true; save(); if (w) { w.scheduler.clear(); poster.log(w, { event: 'pause', reason: `paused by ${by || 'mod'}` }); } return 'AI viewers paused.';++ case 'resume': runtime.paused = false; save(); if (w) { poster.log(w, { event: 'pause', reason: `resumed by ${by || 'mod'}` }); this._scheduleTick(w, 3000); } return 'AI viewers resumed.';++ case 'mute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); if (!n) return 'Usage: /ai mute <viewer>'; if (!runtime.muted.includes(n)) runtime.muted.push(n); save(); return `Muted ${n}.`; }++ case 'unmute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); runtime.muted = runtime.muted.filter(x => x !== n); save(); return `Unmuted ${n}.`; }++ case 'nudge': if (w) { this._scheduleTick(w, 500); return 'Nudged the director.'; } return 'AI viewers are not running right now.';++ default: {++ if (!w) return `AI viewers: ${runtime.paused ? 'paused' : 'not running for a live stream'}${runtime.muted.length ? `, muted: ${runtime.muted.join(', ')}` : ''}.`;++ return `AI viewers: ${w.paused ? 'paused' : 'running'} · ${w.bots.length} viewers · mode ${w.mode} · ${w.stats.lines} lines this stream${runtime.muted.length ? ` · muted: ${runtime.muted.join(', ')}` : ''}. Commands: pause, resume, mute <name>, unmute <name>, nudge.`;++ }++ }}-- // Pick a bot, avoiding the last one that posted (anti-domination).-- _pickBot(worker) {-- const pool = worker.bots.filter(b => b.id !== worker.lastPoster);-- const list = pool.length ? pool : worker.bots;-- if (!list.length) return null;-- return list[rint(0, list.length - 1)];++ // ── Director loop ────────────────────────────────────────++ _scheduleTick(w, delayMs) {++ if (w.stopped) return;++ const at = Date.now() + Math.max(250, delayMs);++ if (w.tickTimer && w.nextTickAt <= at) return; // an earlier tick is already armed++ if (w.tickTimer) clearTimeout(w.tickTimer);++ w.nextTickAt = at;++ w.tickTimer = setTimeout(() => { w.tickTimer = null; this._tick(w).catch(e => console.warn('[AI-Viewers v3] tick error:', e.message)); }, at - Date.now());}++ _cadenceMs(w) {++ const s = w.settings;++ const active = (Date.now() - w.lastRealInputAt) < ACTIVE_WINDOW_MS;++ let ms = (active ? s.director_interval_sec : s.idle_interval_sec) * 1000;++ if (w.mode === 'economy') ms *= 1.5;++ if (w.mode === 'replies_only') ms *= 2;++ return ms;++ }++ _providerFor(w) { return w.cfg.use_shared_key ? null : budget.byoProvider(w.cfg); }-- async _emit(worker, intent) {-- const bot = worker.bots.find(b => b.id === intent.botId) || this._pickBot(worker);-- if (!bot) return;-- worker.generating = true;++ async _tick(w) {++ if (w.stopped || w.ticking) return;++ w.ticking = true;++ const s = w.settings;try {-- const { system, user } = context.buildBotContext(worker, bot, {-- targetUser: intent.target || null,-- addressed: !!intent.addressed,++ if (this._killSwitch()) { this.stopForStream(w.streamId); return; }++ try { w.stream = db.getStreamById(w.streamId) || w.stream; } catch { /* */ }++ if (!w.stream || !w.stream.is_live) { this.stopForStream(w.streamId); return; }++ const hardIntents = w.intents.filter(i => !i.soft);++ const st = budget.status(w.userId);++ w.mode = st.mode;++ const quiet = settingsMod.isQuietNow(s);++ if (w.paused || (quiet && !(s.quiet_allow_replies && hardIntents.length)) || st.mode === 'silent' || !st.active) {++ w.intents = [];++ this._logSkip(w, w.paused ? 'paused' : quiet ? 'quiet hours' : (st.reason || st.mode));++ return;++ }++ if (st.mode === 'streamer_only' && !hardIntents.some(i => i.kind === 'streamer')) { w.intents = []; this._logSkip(w, 'budget: streamer replies only'); return; }++ if (st.mode === 'replies_only') w.intents = w.intents.filter(i => !i.soft ? true : i.kind === 'viewer');++++ // Vision: look at the screen through the shared memory pipeline when it is stale.++ await this._maybeVision(w, hardIntents.length > 0);++++ const bots = w.bots.filter(b => !((s.runtime && s.runtime.muted) || []).includes(b.username.toLowerCase()));++ if (!bots.length) { this._logSkip(w, 'all viewers muted'); return; }++ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots, settings: s, cacheHolder: w });++ const linesAllowed = Math.max(1, Math.min(s.lines_per_tick, st.mode === 'economy' ? s.lines_per_tick - 1 : s.lines_per_tick));++ const tail = context.volatileTail({++ userId: w.userId, stream: w.stream, settings: s, sinceChatId: w.lastChatId,++ botNames: new Set(w.bots.map(b => b.username.toLowerCase())),++ intents: w.intents.slice(0, 5), mode: st.mode, linesAllowed, botShare: w.scheduler.botShare(),++ });++ const awaiting = tail.threads.some(t => t.awaiting);++ const sceneChanged = tail.delta.memoryId && tail.delta.memoryId !== w.lastSeenMemoryId && s.react_to_scene_changes;++ const sound = s.react_to_sounds && tail.delta.latestSound && (Date.now() - w.lastTickAt) < 120000 ? tail.delta.latestSound : null;++ const changed = tail.hash !== w.lastVolatileHash;++ if (!changed && !w.intents.length && !awaiting && !sceneChanged) { this._logSkip(w, 'nothing new'); return; }++ if (!tail.delta.newChat && !tail.delta.speechLines && !w.intents.length && !awaiting && !sceneChanged && w.scheduler.pending()) { this._logSkip(w, 'lines still queued'); return; }++ w.lastVolatileHash = tail.hash;++ if (sceneChanged) w.lastSeenMemoryId = tail.delta.memoryId;++ let volatileText = tail.text;++ if (sceneChanged && !w.intents.length) volatileText += '\n\nNote: what is on screen just changed — an ambient reaction is welcome.';++ if (sound) volatileText += `\n\nNote: a "${sound.label}" sound was just detected on stream.`;++++ const consumed = w.intents.splice(0, w.intents.length);++ const res = await director.plan({++ stableText: stable.text, volatileText, maxLines: linesAllowed,++ provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`,});-- const text = await budget.generate(worker.userId, {-- system, user,-- maxTokens: 60,-- temperature: intent.target ? 0.9 : 1.05,++ w.lastTickAt = Date.now();++ w.lastChatId = Math.max(w.lastChatId, tail.chatMaxId || 0);++ w.stats.ticks++;++ if (!res) { poster.log(w, { event: 'error', reason: 'director call failed or provider quiet' }); w.intents.unshift(...consumed.filter(i => !i.soft)); return; }++ w.stats.cost += res.cost || 0;++ const accepted = this._acceptPlan(w, res.plan, bots, consumed);++ poster.log(w, {++ event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),++ text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,++ tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,});-- if (!text) return; // quiet: over budget / disabled / error-- const clean = this._clean(text);-- if (!clean) return;-- this._inject(worker, bot, clean);++ for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { db.closeAiViewerThread(n); } catch { /* */ } } }++ try { db.closeStaleAiViewerThreads(w.userId, s.thread_idle_close_sec, s.max_thread_turns); } catch { /* */ }} finally {-- worker.generating = false;++ w.ticking = false;++ if (!w.stopped) this._scheduleTick(w, this._cadenceMs(w));}}-- _clean(text) {-- let t = (text || '').trim();-- t = t.replace(/^["'`]+|["'`]+$/g, ''); // strip wrapping quotes-- t = t.replace(/\s+/g, ' ');-- if (t.length > 240) t = t.slice(0, 240);-- // Drop obvious AI disclaimers if the model slipped.-- if (/\b(as an ai|language model|i am a bot|i'm a bot)\b/i.test(t)) return '';-- return t.trim();++ _logSkip(w, reason) {++ w.stats.skips++;++ if (Date.now() - w.lastSkipLogAt > SKIP_LOG_EVERY_MS) { w.lastSkipLogAt = Date.now(); poster.log(w, { event: 'skip', reason }); }}-- // ── Posting ───────────────────────────────────────────────-- _inject(worker, bot, message) {-- const streamId = worker.streamId;-- let color = '#8a8aff';-- try { color = (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || color; } catch { color = bot.avatar_color || color; }---- let id = null;-- try {-- const res = db.saveChatMessage({-- stream_id: streamId,-- channel_user_id: worker.userId,-- user_id: null,-- username: bot.username,-- message,-- message_type: 'chat',-- is_global: false,-- source_platform: 'ai', // marker → excluded from chat-AI feeds-- metadata: { bot: 1, bot_id: bot.id, source: bot.source },-- });-- id = res && res.lastInsertRowid;-- } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }---- const chatMsg = {-- type: 'chat', id: id || undefined,-- username: bot.username, core_username: null,-- user_id: null, anon_id: null, role: 'user',-- message, stream_id: streamId, is_global: false,-- avatar_url: null, profile_color: color,-- is_ai: true, source_platform: 'ai',-- filtered: false, timestamp: new Date().toISOString(),-- };-- try {-- const chatServer = require('../../chat/chat-server');-- chatServer.broadcastToStream(streamId, chatMsg);-- chatServer.forwardToGlobal(streamId, chatMsg);-- chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);-- } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }---- // ...and into the streamer's PowerChat unified overlay. AI viewers inject here-- // directly (never through handleChatMessage where the normal relay lives), so-- // they were invisible on PowerChat while every human chatter showed up.-- try {-- const pc = require('../../integrations/powerchat-platform');-- if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {-- pc.forwardChat(worker.userId, {-- chatterName: bot.username,-- externalChatterId: `ai:${bot.username.toLowerCase()}`,-- message,-- messageId: id ? `ov-${id}` : undefined,-- avatarFallback: '🤖',-- });++ /** Validate + enqueue a plan. Returns the number of accepted lines. */++ _acceptPlan(w, plan, bots, consumed) {++ if (!plan || plan.skip || !plan.lines.length) return 0;++ const s = w.settings;++ const byName = new Map(bots.map(b => [b.username.toLowerCase(), b]));++ const streamerIntent = consumed.find(i => i.kind === 'streamer');++ const out = [];++ let botTargets = 0;++ let lastBot = null;++ for (const raw of plan.lines) {++ const bot = byName.get(String(raw.bot || '').toLowerCase());++ if (!bot) continue;++ if (lastBot === bot.username) continue;++ let text = poster.clean(raw.text, s.max_words);++ if (!text) continue;++ const n = norm(text);++ if (w.recentLines.includes(n)) continue;++ const mod = poster.moderate(text);++ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); continue; }++ const target = ['viewer', 'bot', 'streamer', 'ambient'].includes(raw.target) ? raw.target : 'ambient';++ if (target === 'bot') {++ const maxBot = Math.floor((s.bot_to_bot_ratio || 0) * Math.max(1, plan.lines.length) + 0.5);++ if (botTargets >= Math.max(0, maxBot) && (s.bot_to_bot_ratio || 0) < 0.79) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: 'bot-to-bot ratio' }); continue; }++ botTargets++;}-- } catch { /* non-critical */ }++ // Threads++ let threadId = null;++ try {++ if (raw.thread === 'new') {++ const kind = target === 'bot' ? 'bot_bot' : target === 'streamer' ? 'bot_streamer' : 'bot_viewer';++ const open = db.getOpenAiViewerThreads(w.userId, 10);++ if (open.length < (s.max_open_threads || 3)) {++ const t = db.createAiViewerThread({ channel_user_id: w.userId, stream_id: w.streamId, kind, participants: [bot.username, raw.reply_to].filter(Boolean), topic: raw.topic ? clip(raw.topic, 80) : null, awaiting: target === 'bot' ? raw.reply_to : null });++ threadId = t && t.id;++ }++ } else if (raw.thread && /^\d+$/.test(String(raw.thread))) threadId = parseInt(raw.thread, 10);++ } catch { threadId = null; }++ lastBot = bot.username;++ w.recentLines.push(n); if (w.recentLines.length > 30) w.recentLines.shift();++ out.push({ bot: bot.username, botRow: bot, text, target, replyTo: raw.reply_to || null, threadId, delay_ms: Math.max(0, Math.min(45000, parseInt(raw.delay_ms, 10) || 0)), reason: clip(raw.reason, 120), replyToId: (target === 'streamer' && streamerIntent) ? streamerIntent.msgId : null });++ }++ if (out.length) w.scheduler.enqueue(out);++ return out.length;++ }-- worker.lastPostAt = Date.now();-- worker.lastPoster = bot.id;-- worker.nextGapMs = this._nextGap((Date.now() - worker.lastRealInputAt) < ACTIVE_WINDOW_MS);-- try { db.touchChannelAiBot(bot.id); } catch { /* */ }-- // Buffer this line for the bot's periodic brain fold.-- const buf = worker.botLines.get(bot.id) || [];-- buf.push(message);-- if (buf.length > 24) buf.shift();-- worker.botLines.set(bot.id, buf);-- // Also let bots see each other in chat.-- worker.recentChat.push({ username: bot.username, message, ts: Date.now() });-- if (worker.recentChat.length > RECENT_CHAT_MAX) worker.recentChat.shift();++ _post(w, line) {++ if (w.stopped) return;++ const bot = line.botRow || w.bots.find(b => b.username === line.bot);++ if (!bot) return;++ const id = poster.post(w, bot, line.text, { threadId: line.threadId, replyToId: line.replyToId });++ w.stats.lines++;++ if (line.target === 'streamer') w.lastStreamerReplyAt = Date.now();++ poster.log(w, { event: 'line', bot_username: bot.username, target: line.target + (line.replyTo ? `:${line.replyTo}` : ''), thread_id: line.threadId || null, chat_message_id: id, text: line.text, reason: line.reason });++ const buf = w.botLines.get(bot.id) || []; buf.push(line.text); if (buf.length > 24) buf.shift(); w.botLines.set(bot.id, buf);++ if (line.threadId) {++ try { db.touchAiViewerThread(line.threadId, { line: line.text, by: bot.username, awaiting: line.target === 'bot' ? (line.replyTo || null) : null }); } catch { /* */ }++ // A bot↔bot thread waiting on the other bot pulls the next pass closer.++ if (line.target === 'bot') this._scheduleTick(w, Math.min(this._cadenceMs(w), 12000));++ }}-- // ── Situation: what's happening on stream ─────────────────-- async _captureAudio(worker) {-- if (worker.stopped) return;-- let ai, transcribe;-- try { ai = require('../ai-analysis'); transcribe = require('../transcribe'); } catch { return; }-- if (!ai.transcriptionEnabled || !ai.transcriptionEnabled()) return;-- let wav = null;-- try {-- const streamAudio = require('../stream-audio');-- wav = await streamAudio.captureAudioChunk(worker.stream, 12);-- if (!wav) return;-- const res = await transcribe.transcribeWavDetailed(wav);-- const text = res && res.ok ? (res.text || '').trim() : '';-- if (text) this._mergeSituation(worker, `heard: "${text.slice(0, 240)}"`);-- } catch (e) { /* capture is best-effort */ }-- finally { if (wav) { try { require('fs').unlink(wav, () => {}); } catch { /* */ } } }++ // ── Streamer fast path ───────────────────────────────────++ async _fastPath(w, { line, bots: named, replyTo, msgId }) {++ const s = w.settings;++ const waitMs = Math.max(0, (s.mention_fast_path_sec || 0) * 1000);++ const nextIn = w.nextTickAt ? w.nextTickAt - Date.now() : Infinity;++ if (nextIn <= waitMs + 1500) { this._scheduleTick(w, Math.min(nextIn, 1500)); return; } // the director will handle it in time++ const st = budget.status(w.userId);++ if (!st.active || st.mode === 'silent' || w.paused) return;++ const muted = new Set(((s.runtime && s.runtime.muted) || []).map(x => String(x).toLowerCase()));++ const pool = w.bots.filter(b => !muted.has(b.username.toLowerCase()));++ if (!pool.length) return;++ const bot = (named.length && pool.find(b => b.username === named[0])) || pool[Math.floor(Math.random() * pool.length)];++ await this._maybeVision(w, true);++ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: pool, settings: s, cacheHolder: w });++ const heard = context.heardBlock(w.stream, s);++ const seen = context.seenBlock(w.stream);++ const situation = [++ heard.text ? `Heard on stream recently:\n${heard.text.split('\n').slice(-6).join('\n')}` : '',++ seen.text ? `On screen (${context.ago(seen.ageMs)}): ${seen.text}` : '',++ ].filter(Boolean).join('\n\n');++ const r = await director.quickReply({ stableText: stable.text, situationText: situation, bot, streamerLine: clip(line, 220), maxWords: s.max_words, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}` });++ if (!r || !r.text) return;++ const text = poster.clean(r.text, s.max_words);++ const mod = text ? poster.moderate(text) : { ok: false, reason: 'empty' };++ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); return; }++ w.stats.cost += r.cost || 0;++ w.intents = w.intents.filter(i => i.kind !== 'streamer'); // handled — the next pass must not double-reply++ w.recentLines.push(norm(text)); if (w.recentLines.length > 30) w.recentLines.shift();++ poster.log(w, { event: 'mention', bot_username: bot.username, target: `streamer:${replyTo}`, text, reason: 'fast path — streamer addressed chat', tokens_in: r.usage.input, tokens_cached: r.usage.cached, tokens_out: r.usage.output, cost_usd: r.cost, model: r.model });++ w.scheduler.enqueue([{ bot: bot.username, botRow: bot, text, target: 'streamer', replyTo, threadId: null, delay_ms: 0, reason: 'answering the streamer', replyToId: msgId || null }], { basePrio: 3 });}-- async _captureVision(worker) {-- if (worker.stopped) return;++ // ── Vision policy ────────────────────────────────────────++ async _maybeVision(w, addressed) {++ const s = w.settings;++ if (s.vision_policy === 'off') return;++ const seen = context.seenBlock(w.stream);++ const stale = seen.ageMs == null || seen.ageMs > (s.vision_max_age_sec || 180) * 1000;++ const periodicDue = s.vision_policy === 'periodic' && (Date.now() - w.lastPeriodicVisionAt) > (s.vision_periodic_sec || 300) * 1000 && (Date.now() - w.lastRealInputAt) < 5 * 60000;++ if (!stale && !periodicDue) return;++ const allowFfmpeg = s.vision_policy !== 'thumbnail';++ if (addressed || periodicDue) {++ if (periodicDue) w.lastPeriodicVisionAt = Date.now();++ const p = context.wantFreshFrame(w.stream, { maxAgeSec: s.vision_max_age_sec, allowFfmpeg });++ // Addressed: wait up to 12s so the answer can reference the screen; otherwise fire and forget.++ if (addressed) { const t = new Promise(res => setTimeout(() => res(false), 12000)); const started = await Promise.race([p, t]); if (started) poster.log(w, { event: 'vision', reason: 'fresh frame analyzed (shared memory pipeline)' }); }++ else p.then(ok => { if (ok) poster.log(w, { event: 'vision', reason: 'periodic frame analyzed' }); }).catch(() => {});++ }++ }++++ // ── Memory fold ──────────────────────────────────────────++ async _fold(w) {try {-- const streamVision = require('../stream-vision');-- const frame = await streamVision.captureFrame(worker.stream);-- if (!frame) return;-- const desc = await budget.generate(worker.userId, {-- system: 'You describe a single video frame from a live stream in one short vivid sentence.',-- user: 'What is on screen right now?',-- image: frame, maxTokens: 60, temperature: 0.4,-- });-- if (desc && desc.trim()) this._mergeSituation(worker, `seen: ${desc.trim().slice(0, 200)}`);-- } catch (e) { /* best-effort */ }++ const r = await fold.foldAll(w, { provider: this._providerFor(w) });++ if (r) { w.stats.cost += r.cost || 0; poster.log(w, { event: 'fold', reason: `memory updated for ${r.updated} viewer(s)`, tokens_in: r.usage && r.usage.input, tokens_cached: r.usage && r.usage.cached, tokens_out: r.usage && r.usage.output, cost_usd: r.cost, model: r.model });++ for (let i = 0; i < w.bots.length; i++) { const fresh = db.getChannelAiBot(w.bots[i].id); if (fresh) w.bots[i] = fresh; }++ w.stable = null;++ }++ } catch (e) { poster.log(w, { event: 'error', reason: `fold: ${e.message}` }); }}-- _mergeSituation(worker, line) {-- // Keep the most recent heard/seen fragments as the current situation.-- const parts = (worker.situationLine || '').split(' | ').filter(Boolean);-- const kind = line.split(':')[0];-- const kept = parts.filter(p => !p.startsWith(kind));-- kept.push(line);-- worker.situationLine = kept.slice(-2).join(' | ');-- context.refreshShared(worker); // refresh streamer/global insight alongside++ // ── Status / preview (control panel) ─────────────────────++ status(userId) {++ const w = this.workerForUser(userId);++ const st = budget.status(userId);++ const timelineOn = (() => { try { return require('../timeline-job').timelineEnabled(); } catch { return false; } })();++ const base = { engine: 'v3', running: !!w, mode: st.mode, budget_active: st.active, budget_reason: st.reason, spent_today_usd: st.spentToday, cap_usd: st.capUsd, timeline_enabled: timelineOn, kill_switch: this._killSwitch() };++ if (!w) return base;++ const seen = context.seenBlock(w.stream);++ const heard = context.heardBlock(w.stream, w.settings);++ return { ...base, stream_id: w.streamId, paused: w.paused, bots: w.bots.length, stats: w.stats, last_tick_at: w.lastTickAt || null, next_tick_in_ms: w.nextTickAt ? Math.max(0, w.nextTickAt - Date.now()) : null,++ queued: w.scheduler.pending(), bot_share: w.scheduler.botShare(), lines_last_minute: w.scheduler.linesLastMinute(),++ senses: { heard_age_ms: heard.ageMs, heard_lines: heard.count, seen_age_ms: seen.ageMs, seen_text: seen.text, stable_prefix_chars: w.stable ? w.stable.text.length : null, stable_cached_since: w.stable ? w.stable.at : null } };}-- async _foldBrains(worker) {-- if (worker.stopped) return;-- for (const bot of worker.bots) {-- const lines = worker.botLines.get(bot.id);-- if (!lines || lines.length < 3) continue;-- try {-- const fresh = db.getChannelAiBot(bot.id) || bot;-- const ok = await brain.foldBrain(worker.userId, fresh, lines);-- if (ok) { worker.botLines.set(bot.id, []); /* refresh cached row */-- const updated = db.getChannelAiBot(bot.id);-- const idx = worker.bots.findIndex(b => b.id === bot.id);-- if (updated && idx >= 0) worker.bots[idx] = updated;-- }-- } catch { /* */ }-- if (worker.stopped) return;++ /** Dry run: assemble the real context and ask the director what it would do (nothing posted). */++ async preview(userId) {++ let w = this.workerForUser(userId);++ let temp = false;++ if (!w) {++ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();++ if (!live.length) return { error: 'Go live first — the preview runs on your real stream context.' };++ const cfg = db.getChannelAiConfig(userId); const settings = settingsMod.getSettings(userId, cfg);++ const bots = roster.ensureRoster(userId, settings.roster_size || 3);++ w = { streamId: live[0].id, userId, stream: db.getStreamById(live[0].id) || live[0], cfg, settings, bots, lastChatId: Math.max(0, db.getMaxChatMessageIdForChannel(userId) - 40), intents: [], scheduler: { botShare: () => 0 } };++ temp = true;}++ const s = w.settings;++ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: w.bots, settings: s, cacheHolder: temp ? null : w });++ const tail = context.volatileTail({ userId: w.userId, stream: w.stream, settings: s, sinceChatId: Math.max(0, (w.lastChatId || 0) - 25), botNames: new Set(w.bots.map(b => b.username.toLowerCase())), intents: w.intents, mode: 'normal', linesAllowed: s.lines_per_tick, botShare: w.scheduler.botShare() });++ const res = await director.plan({ stableText: stable.text, volatileText: tail.text, maxLines: s.lines_per_tick, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`, temperature: 0.9 });++ return { plan: res ? res.plan : null, usage: res ? res.usage : null, cost: res ? res.cost : null, model: res ? res.model : null, context: { stable_chars: stable.text.length, volatile_chars: tail.text.length, sources: tail.sources }, stable_preview: stable.text.slice(0, 1200), volatile_preview: tail.text.slice(0, 1500) };}++++ nudge(userId) { const w = this.workerForUser(userId); if (!w) return false; this._scheduleTick(w, 500); return true; }}--module.exports = new AiViewersEngine();++module.exports = new AiViewersEngineV3();nnew file mode 100644@@@ -0,0 +1,82 @@++/**++ * AI viewers v3 — memory fold. One summary-role call per channel 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');++++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 }++ * @returns {Promise<{updated:number, cost:number}|null>}++ */++async function foldAll(worker, { provider = null } = {}) {++ const entries = [];++ for (const bot of worker.bots) {++ const lines = worker.botLines.get(bot.id) || [];++ if (lines.length < 3) continue;++ let brain = {}; try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }++ entries.push({ bot, lines: lines.slice(-20), memory: brain.memory || '' });++ }++ if (!entries.length) return 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,++ });++ if (!r) return null;++ const out = r.json || llm.parseJsonLoose(r.text);++ if (!out || !Array.isArray(out.memories)) return null;++ let updated = 0;++ for (const m of out.memories) {++ const e = entries.find(x => x.bot.username.toLowerCase() === String(m.bot || '').toLowerCase());++ if (!e || !m.memory || !String(m.memory).trim()) continue;++ let brain = {}; try { brain = JSON.parse(e.bot.brain_json || '{}'); } catch { /* */ }++ brain.memory = clip(m.memory, 900);++ brain.timeline = (brain.timeline || []).slice(-8);++ brain.timeline.push({ n: e.bot.msg_count || 0, note: clip(e.lines[e.lines.length - 1], 80) });++ try { db.updateChannelAiBot(e.bot.id, { brain_json: brain }); worker.botLines.set(e.bot.id, []); updated++; } catch { /* */ }++ }++ if (out.channel_memory && String(out.channel_memory).trim()) {++ try {++ const cfg = db.getChannelAiConfig(worker.userId);++ let sj = {}; try { sj = JSON.parse(cfg.settings_json || '{}') || {}; } catch { /* */ }++ sj.channel_memory = clip(out.channel_memory, 600);++ db.upsertChannelAiConfig(worker.userId, { settings_json: JSON.stringify(sj) });++ } catch { /* */ }++ }++ return { updated, cost: r.cost || 0, usage: r.usage, model: r.model };++}++++/** Clear a bot's rolling memory (keeps its identity/persona). */++function clearBrain(botId) {++ const bot = db.getChannelAiBot(botId);++ if (!bot) return false;++ let persona = {}; try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }++ db.updateChannelAiBot(botId, { brain_json: { memory: '', timeline: [], identity: persona.identity || '' } });++ return true;++}++++module.exports = { foldAll, clearBrain };@@@ -1,47 +1,24 @@/**-- * AI Chat Viewers — engine selector.-- * site_settings.ai_viewers_engine ('v2' | 'v3') picks the default; a channel can override-- * with settings_json.engine. Both engines expose the same lifecycle surface, so the rest-- * of the server (go-live hooks, chat hook, routes) never cares which one is running.++ * AI Chat Viewers — public surface. The director engine (./engine) is the only engine;++ * every lifecycle/chat hook in the server and the routes go through this module.*/'use strict';--const db = require('../../db/database');----function _v2() { return require('./engine'); }--function _v3() { return require('./v3/engine'); }----function engineForUser(userId) {-- let override = '';-- try { override = (JSON.parse(db.getChannelAiConfig(userId).settings_json || '{}') || {}).engine || ''; } catch { /* */ }-- const site = String(db.getSetting('ai_viewers_engine') || 'v2');-- return (override || site) === 'v3' ? _v3() : _v2();--}--function engineForStream(streamId) {-- if (_v3().hasWorker(streamId)) return _v3();-- if (_v2().hasWorker(streamId)) return _v2();-- return null;--}++const engine = require('./engine');module.exports = {-- get version() { return String(db.getSetting('ai_viewers_engine') || 'v2'); },-- engineForUser,-- hasWorker(streamId) { return !!engineForStream(streamId); },-- startForStream(stream) { if (!stream || !stream.user_id) return; return engineForUser(stream.user_id).startForStream(stream); },-- stopForStream(streamId) { const e = engineForStream(streamId); if (e) return e.stopForStream(streamId); },-- stopForUser(userId) { _v2().stopForUser(userId); _v3().stopForUser(userId); },-- applyConfigForUser(userId) {-- // If the channel switched engines, stop the other one first.-- const want = engineForUser(userId);-- const other = want === _v3() ? _v2() : _v3();-- try { other.stopForUser(userId); } catch { /* */ }-- return want.applyConfigForUser(userId);-- },-- reloadForUser(userId) { return this.applyConfigForUser(userId); },-- onRealChatMessage(streamId, ev) { const e = engineForStream(streamId); if (e) return e.onRealChatMessage(streamId, ev); },-- onChatEvent(streamId, ev) { const e = engineForStream(streamId); if (e && e.onChatEvent) return e.onChatEvent(streamId, ev); if (e) return e.onRealChatMessage(streamId, ev); },-- onModCommand(channelUserId, streamId, args, opts) { const e = engineForUser(channelUserId); return e.onModCommand ? e.onModCommand(channelUserId, streamId, args, opts) : 'This channel runs the legacy AI viewers engine — switch to v3 for chat commands.'; },-- status(userId) { const e = engineForUser(userId); return e.status ? e.status(userId) : { engine: 'v2', running: _v2().hasWorker ? [..._v2().workers.values()].some(w => w.userId === userId) : false }; },-- preview(userId) { const e = engineForUser(userId); return e.preview ? e.preview(userId) : Promise.resolve({ error: 'Preview needs the v3 engine' }); },-- nudge(userId) { const e = engineForUser(userId); return e.nudge ? e.nudge(userId) : false; },-- v2: _v2, v3: _v3,++ version: 'v3',++ engineForUser() { return engine; },++ hasWorker: (streamId) => engine.hasWorker(streamId),++ startForStream: (stream) => engine.startForStream(stream),++ stopForStream: (streamId) => engine.stopForStream(streamId),++ stopForUser: (userId) => engine.stopForUser(userId),++ applyConfigForUser: (userId) => engine.applyConfigForUser(userId),++ reloadForUser: (userId) => engine.applyConfigForUser(userId),++ onRealChatMessage: (streamId, ev) => engine.onChatEvent(streamId, ev),++ onChatEvent: (streamId, ev) => engine.onChatEvent(streamId, ev),++ onModCommand: (channelUserId, streamId, args, opts) => engine.onModCommand(channelUserId, streamId, args, opts),++ status: (userId) => engine.status(userId),++ preview: (userId) => engine.preview(userId),++ nudge: (userId) => engine.nudge(userId),++ engine,};nnew file mode 100644@@@ -0,0 +1,29 @@++/**++ * AI viewers v3 — who is being addressed?++ * detect(message, bots, { isStreamer }) → { bots: [usernames named], addressedChat, question }++ * A streamer talking to "chat"/"guys"/"everyone" or asking a question addresses everyone;++ * "@name" or a bare bot username/display name addresses that bot (anyone can do that).++ */++'use strict';++++const CHAT_WORDS = /\b(chat|guys|gang|everyone|everybody|yall|y'all|you all|anyone|anybody|viewers|lads|folks|people)\b/i;++++function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }++++function detect(message, bots, { isStreamer = false } = {}) {++ const text = String(message || '');++ const lower = text.toLowerCase();++ const named = [];++ for (const b of bots || []) {++ const names = [b.username, b.display_name].filter(Boolean).map(n => String(n).toLowerCase());++ for (const n of names) {++ if (n.length < 3) continue;++ if (lower.includes('@' + n) || new RegExp(`(^|[^a-z0-9_])${escapeRe(n)}([^a-z0-9_]|$)`, 'i').test(text)) { named.push(b.username); break; }++ }++ }++ const question = /\?\s*$/.test(text.trim()) || /\b(what do you|whatd?ya|do you (guys|all)|should i|thoughts\??|opinions?\??|who here|anyone (here|know|want))\b/i.test(text);++ const addressedChat = isStreamer && (CHAT_WORDS.test(text) || question || /^(so|ok|okay|alright|hey|yo)\b/i.test(text.trim()) && text.length < 120);++ return { bots: [...new Set(named)], addressedChat, question };++}++++module.exports = { detect };nnew file mode 100644@@@ -0,0 +1,106 @@++/**++ * AI viewers v3 — posting a bot line into chat (+ moderation + activity log).++ * Ported from the v2 engine's _inject, with the bits it skipped: word filter, per-bot++ * TTS toggle, PowerChat forward toggle, and a log row for the control panel.++ */++'use strict';++const db = require('../../db/database');++++function botColor(bot) {++ try { return (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || '#8a8aff'; } catch { return bot.avatar_color || '#8a8aff'; }++}++function botPersona(bot) { try { return JSON.parse(bot.persona_json || '{}') || {}; } catch { return {}; } }++++/** Strip quotes/disclaimers; enforce a word cap. Returns '' when the line must be dropped. */++function clean(text, maxWords = 18) {++ let t = String(text || '').trim();++ t = t.replace(/^["'`“”]+|["'`“”]+$/g, '').replace(/\s+/g, ' ').trim();++ if (!t) return '';++ if (/\b(as an ai|language model|i am a bot|i'm a bot|as a bot|openai|anthropic)\b/i.test(t)) return '';++ if (/^\w+:\s/.test(t) && t.length < 80) t = t.replace(/^\w+:\s/, ''); // "botname: text" echo++ const words = t.split(' ');++ if (words.length > maxWords) t = words.slice(0, maxWords).join(' ').replace(/[,;:]$/, '');++ if (t.length > 240) t = t.slice(0, 240);++ return t.trim();++}++++/** Word-filter gate — returns { ok, text, reason }. */++function moderate(text) {++ try {++ const wf = require('../../chat/word-filter');++ const r = wf.check(text);++ if (!r.safe) return { ok: false, text, reason: `word filter: ${r.matches.slice(0, 3).join(', ')}` };++ if (typeof wf.isSpam === 'function' && wf.isSpam(text)) return { ok: false, text, reason: 'spam pattern' };++ } catch { /* filter unavailable → allow */ }++ return { ok: true, text, reason: null };++}++++/**++ * Post a line. Returns the chat message id (or null).++ * @param {object} worker { streamId, userId, settings }++ */++function post(worker, bot, message, { threadId = null, replyToId = null } = {}) {++ const streamId = worker.streamId;++ const persona = botPersona(bot);++ const color = botColor(bot);++ let id = null;++ try {++ const res = db.saveChatMessage({++ stream_id: streamId,++ channel_user_id: worker.userId,++ user_id: null,++ username: bot.username,++ message,++ message_type: 'chat',++ is_global: false,++ reply_to_id: replyToId || null,++ source_platform: 'ai', // marker → excluded from chat-AI feeds++ metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },++ });++ id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;++ } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }++++ const chatMsg = {++ type: 'chat', id: id || undefined,++ username: bot.username, core_username: null,++ user_id: null, anon_id: null, role: 'user',++ message, stream_id: streamId, is_global: false,++ reply_to_id: replyToId || null,++ avatar_url: null, profile_color: color,++ is_ai: true, source_platform: 'ai',++ filtered: false, timestamp: new Date().toISOString(),++ };++ try {++ const chatServer = require('../../chat/chat-server');++ chatServer.broadcastToStream(streamId, chatMsg);++ chatServer.forwardToGlobal(streamId, chatMsg);++ const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;++ if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);++ } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }++++ if (worker.settings.powerchat_forward !== false) {++ try {++ const pc = require('../../integrations/powerchat-platform');++ if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {++ pc.forwardChat(worker.userId, {++ chatterName: bot.username,++ externalChatterId: `ai:${bot.username.toLowerCase()}`,++ message,++ messageId: id ? `ov-${id}` : undefined,++ avatarFallback: '🤖',++ });++ }++ } catch { /* non-critical */ }++ }++ try { db.touchChannelAiBot(bot.id); } catch { /* */ }++ return id;++}++++/** Append a row to ai_viewer_log (best-effort). */++function log(worker, row) {++ try {++ db.addAiViewerLog({ channel_user_id: worker.userId, stream_id: worker.streamId || null, ...row });++ } catch { /* */ }++}++++module.exports = { post, clean, moderate, log, botPersona, botColor };@@@ -17,7 +17,7 @@ const { requireAuth } = require('../../auth/auth');const chatAi = require('../chat-ai');const budget = require('./budget');const roster = require('./roster');--const brain = require('./brain');++const { clearBrain } = require('./fold');const engine = require('./index');const settingsMod = require('./settings');const ai = require('../ai-analysis');@@@ -209,7 +209,7 @@ router.post('/bots/:id/clear-memory', requireAuth, (req, res) => {try {const bot = ownBotOr404(req, res);if (!bot) return;-- brain.clearBrain(bot.id);++ clearBrain(bot.id);res.json({ ok: true, bot: botSummary(db.getChannelAiBot(bot.id)) });} catch (e) {res.status(500).json({ error: 'Failed to clear memory' });@@@ -271,33 +271,9 @@ router.post('/clone', requireAuth, async (req, res) => {// ── Preview one line (no posting) ─────────────────────────────router.post('/preview', requireAuth, async (req, res) => {try {-- if (engine.engineForUser(req.user.id).version === 'v3') {-- const out = await engine.preview(req.user.id);-- if (out && out.error) return res.status(400).json({ error: out.error });-- return res.json(out);-- }-- const st = budget.budgetStatus(req.user.id);-- if (!st.active) {-- const why = st.reason === 'shared_ai_disabled' ? 'Shared AI is currently disabled by the admin.'-- : st.reason === 'over_daily_cap' ? "You've hit today's budget cap."-- : st.reason === 'no_byo_key' ? 'Add your API key (or a public self-hosted URL) first.'-- : 'AI viewers are not active.';-- return res.status(400).json({ error: why });-- }-- const cfg = db.getChannelAiConfig(req.user.id);-- const persona = roster.makeAmbientPersona((req.user.id * 7 + 3) & 0x7fffffff);-- const system = [-- `You are a viewer in a live stream's chat. You are ${persona.character.blurb}.`,-- cfg.persona ? `Channel vibe: ${cfg.persona}` : '',-- `Typing style: ${persona.style.rules}.`,-- `Reply with ONE short chat message only. Never say you are an AI or a bot. Just the message.`,-- ].filter(Boolean).join('\n');-- const text = await budget.generate(req.user.id, {-- system, user: 'Chat is a bit quiet. Say something to get things going.',-- maxTokens: 50, temperature: 1.05,-- });-- if (!text) return res.status(502).json({ error: 'No response from the AI provider.' });-- res.json({ line: text });++ const out = await engine.preview(req.user.id);++ if (out && out.error) return res.status(400).json({ error: out.error });++ res.json(out);} catch (e) {res.status(500).json({ error: 'Preview failed' });}nnew file mode 100644@@@ -0,0 +1,97 @@++/**++ * AI viewers v3 — pacing. Planned lines go into a per-stream queue and are released++ * humanly: a minimum gap between any two bot lines, a per-minute ceiling, slow-mode++ * awareness, no bot twice in a row, and a rolling bot-to-bot share cap.++ */++'use strict';++++function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }++++class Scheduler {++ constructor(worker, deps) {++ this.worker = worker; // { streamId, settings, ... }++ this.deps = deps; // { post(line), now() }++ this.queue = []; // [{ bot, text, target, replyTo, threadId, postAt, prio, reason }]++ this.recent = []; // [{ at, target, bot }] last 40 posted++ this.lastPostAt = 0;++ this.lastBot = null;++ this.timer = null;++ this.stopped = false;++ }++++ settings() { return this.worker.settings || {}; }++++ /** Queue planned lines (from a director pass). */++ enqueue(lines, { basePrio = 1 } = {}) {++ const now = Date.now();++ for (const l of lines) {++ const jitter = rint(0, 2500);++ const prio = l.target === 'streamer' ? 3 : basePrio;++ this.queue.push({ ...l, prio, postAt: now + (prio === 3 ? Math.min(l.delay_ms || 0, 4000) : (l.delay_ms || 0)) + jitter });++ }++ // Keep the queue bounded: newest plans win over stale unposted ambient chatter.++ if (this.queue.length > 12) this.queue = this.queue.sort((a, b) => b.prio - a.prio || a.postAt - b.postAt).slice(0, 12);++ this._arm();++ }++++ /** Drop everything not yet posted (config change / pause / stop). */++ clear() { this.queue = []; if (this.timer) { clearTimeout(this.timer); this.timer = null; } }++++ stop() { this.stopped = true; this.clear(); }++++ pending() { return this.queue.length; }++++ /** Share of bot→bot lines over the last 20 posts (0..1). */++ botShare() {++ const last = this.recent.slice(-20);++ if (!last.length) return 0;++ return last.filter(r => r.target === 'bot').length / last.length;++ }++ linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }++++ _slowModeMs() {++ try { const cs = require('../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }++ }++++ _arm(delay = null) {++ if (this.stopped || this.timer || !this.queue.length) return;++ const next = Math.min(...this.queue.map(q => q.postAt));++ const wait = delay != null ? delay : Math.max(250, next - Date.now());++ this.timer = setTimeout(() => { this.timer = null; this._drain(); }, wait);++ }++++ _drain() {++ if (this.stopped || !this.queue.length) return;++ const s = this.settings();++ const now = Date.now();++ const minGap = Math.max((s.min_gap_sec || 4) * 1000, this._slowModeMs());++ if (now - this.lastPostAt < minGap) return this._arm(minGap - (now - this.lastPostAt));++ const perMin = Math.max(0.5, s.lines_per_min || 3);++ if (this.linesLastMinute() >= perMin) return this._arm(5000);++++ // Pick the highest-priority due line that doesn't violate the alternation/ratio rules.++ const due = this.queue.filter(q => q.postAt <= now).sort((a, b) => b.prio - a.prio || a.postAt - b.postAt);++ if (!due.length) return this._arm();++ let pick = null;++ for (const q of due) {++ if (q.bot === this.lastBot && due.length > 1) continue;++ if (q.target === 'bot' && this.botShare() >= (s.bot_to_bot_ratio || 0) + 0.05 && q.prio < 3) continue;++ pick = q; break;++ }++ if (!pick) {++ // Everything due is blocked by the ratio/alternation rules — drop stale bot↔bot chatter.++ const stale = due.find(q => q.target === 'bot' && q.prio < 3);++ if (stale) { this.queue = this.queue.filter(q => q !== stale); this.deps.dropped && this.deps.dropped(stale, 'bot-to-bot share cap'); }++ return this._arm(3000);++ }++ this.queue = this.queue.filter(q => q !== pick);++ try { this.deps.post(pick); } catch (e) { console.warn('[AI-Viewers] post failed:', e.message); }++ this.lastPostAt = Date.now();++ this.lastBot = pick.bot;++ this.recent.push({ at: this.lastPostAt, target: pick.target, bot: pick.bot });++ if (this.recent.length > 40) this.recent.shift();++ this._arm();++ }++}++++module.exports = { Scheduler };@@@ -12,7 +12,6 @@ const db = require('../../db/database');// key → { def, type, min, max, values, max_len, help }const SCHEMA = {-- engine: { def: '', type: 'enum', values: ['', 'v2', 'v3'], help: 'Engine override (blank = site default)' },// Rosterroster_size: { def: 3, type: 'int', min: 0, max: 12, help: 'How many ambient AI viewers hang out in chat' },// Activityddeleted file mode 100644@@@ -1,103 +0,0 @@--/**-- * AI viewers v3 — the director.-- *-- * ONE structured LLM call 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.-- */--'use strict';--const llm = require('../../llm');----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 },-- ];--}----/**-- * 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 }-- */--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,-- });-- if (!r) return null;-- let p = r.json;-- if (!p || typeof p !== 'object') p = llm.parseJsonLoose(r.text) || { skip: true, lines: [] };-- 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).-- */--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;--}----module.exports = { plan, quickReply, PLAN_SCHEMA, DIRECTOR_RULES, buildSystem };ddeleted file mode 100644@@@ -1,428 +0,0 @@--/**-- * AI Chat Viewers v3 — engine.-- *-- * Per live stream: a worker holds the roster, settings, a cached stable prompt prefix,-- * a scheduler, and pending intents. A DIRECTOR pass runs on a cadence (or immediately-- * on a priority intent), assembles the unified context (only deltas), makes ONE-- * structured LLM call that plans several lines, and hands them to the scheduler.-- * Streamer mentions get a guaranteed answer via a fast path when the next pass is far.-- * Nothing runs when nothing changed. Spend degrades gracefully toward the streamer's cap.-- */--'use strict';--const db = require('../../../db/database');--const settingsMod = require('../settings');--const roster = require('../roster');--const budget = require('../budget');--const context = require('../../context');--const director = require('./director');--const poster = require('./poster');--const mention = require('./mention');--const fold = require('./fold');--const { Scheduler } = require('./scheduler');----const ACTIVE_WINDOW_MS = 45000;--const SKIP_LOG_EVERY_MS = 5 * 60 * 1000;--const STREAMER_QUIET_MS = 8000; // after answering the streamer, hold ambient chatter briefly----function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }--function norm(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); }----class AiViewersEngineV3 {-- constructor() { this.workers = new Map(); this.version = 'v3'; }---- hasWorker(streamId) { return this.workers.has(streamId); }-- workerForUser(userId) { for (const w of this.workers.values()) if (w.userId === userId) return w; return null; }---- _killSwitch() { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; }---- // ── Lifecycle ─────────────────────────────────────────────-- async startForStream(stream) {-- try {-- if (!stream || !stream.id || !stream.user_id) return;-- if (this.workers.has(stream.id)) return;-- if (this._killSwitch()) return;-- const cfg = db.getChannelAiConfig(stream.user_id);-- if (!cfg.enabled) return;-- const settings = settingsMod.getSettings(stream.user_id, cfg);-- let full = stream;-- try { full = db.getStreamById(stream.id) || stream; } catch { /* */ }-- if (full.managed_stream_id && settings.slots && settings.slots[String(full.managed_stream_id)] === false) {-- console.log(`[AI-Viewers] slot ${full.managed_stream_id} has AI viewers off — not starting for stream ${stream.id}`);-- return;-- }-- const bots = roster.ensureRoster(stream.user_id, settings.roster_size);-- if (!bots.length) return;---- const worker = {-- streamId: stream.id, userId: stream.user_id, stream: full, cfg, settings, bots,-- stable: null, // cached stable prefix {text,hash,at}-- lastChatId: db.getMaxChatMessageIdForChannel(stream.user_id),-- lastTickAt: 0, nextTickAt: 0, tickTimer: null, foldTimer: null, ticking: false,-- lastRealInputAt: 0, lastStreamerReplyAt: 0, lastSkipLogAt: 0, lastVolatileHash: null,-- intents: [], // [{ kind:'streamer'|'mention'|'sound'|'scene', text, bots:[], at }]-- botLines: new Map(), // botId → recent lines (memory fold)-- recentLines: [], // normalized last 30 bot lines (dedupe)-- lastSeenMemoryId: null, lastPeriodicVisionAt: 0,-- paused: !!(settings.runtime && settings.runtime.paused),-- mode: 'normal',-- stats: { ticks: 0, lines: 0, skips: 0, cost: 0 },-- startedAt: Date.now(), stopped: false,-- };-- worker.scheduler = new Scheduler(worker, {-- post: (line) => this._post(worker, line),-- dropped: (line, why) => poster.log(worker, { event: 'skip', bot_username: line.bot, text: line.text, reason: why }),-- });-- this.workers.set(stream.id, worker);-- poster.log(worker, { event: 'info', reason: `engine v3 started — ${bots.length} viewer(s), ${settings.activity} activity, director every ${settings.director_interval_sec}s` });-- console.log(`[AI-Viewers v3] started for stream ${stream.id} (user ${stream.user_id}, ${bots.length} bots)`);-- this._scheduleTick(worker, 4000);-- worker.foldTimer = setInterval(() => this._fold(worker).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000);-- } catch (e) { console.warn('[AI-Viewers v3] start failed:', e.message); }-- }---- stopForStream(streamId) {-- const w = this.workers.get(streamId);-- if (!w) return;-- w.stopped = true;-- if (w.tickTimer) clearTimeout(w.tickTimer);-- if (w.foldTimer) clearInterval(w.foldTimer);-- w.scheduler.stop();-- this.workers.delete(streamId);-- try { db.closeAllAiViewerThreads(w.userId); } catch { /* */ }-- this._fold(w).catch(() => {});-- poster.log(w, { event: 'info', reason: `stopped — ${w.stats.lines} line(s), ${w.stats.ticks} pass(es), $${w.stats.cost.toFixed(4)}` });-- console.log(`[AI-Viewers v3] stopped for stream ${streamId}`);-- }-- stopForUser(userId) { for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid); }---- /** Hot-apply config: reload settings/roster on live workers; start/stop as needed. */-- applyConfigForUser(userId) {-- const cfg = db.getChannelAiConfig(userId);-- const settings = settingsMod.getSettings(userId, cfg);-- const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();-- for (const [sid, w] of this.workers) {-- if (w.userId !== userId) continue;-- if (!cfg.enabled || this._killSwitch() || (w.stream.managed_stream_id && settings.slots && settings.slots[String(w.stream.managed_stream_id)] === false)) { this.stopForStream(sid); continue; }-- w.cfg = cfg; w.settings = settings; w.paused = !!(settings.runtime && settings.runtime.paused);-- try { w.bots = roster.ensureRoster(userId, settings.roster_size); } catch { /* */ }-- w.stable = null; // prefix inputs may have changed-- if (w.foldTimer) { clearInterval(w.foldTimer); w.foldTimer = setInterval(() => this._fold(w).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000); }-- poster.log(w, { event: 'info', reason: 'settings applied' });-- }-- if (cfg.enabled && !this._killSwitch()) for (const s of live) if (!this.workers.has(s.id)) this.startForStream(s).catch(() => {});-- }-- reloadForUser(userId) { return this.applyConfigForUser(userId); }---- // ── Chat events (native, relayed, anon — everything public) ─-- onRealChatMessage(streamId, ev) { return this.onChatEvent(streamId, ev); }-- onChatEvent(streamId, ev = {}) {-- const w = this.workers.get(streamId);-- if (!w || w.stopped) return;-- const uname = String(ev.username || '');-- if (ev.isBot || w.bots.some(b => b.username.toLowerCase() === uname.toLowerCase())) return; // never react to our own lines-- if (ev.platform && !w.settings.reply_to_relay_chat) return;-- const isStreamer = !!ev.isStreamer || (ev.userId && ev.userId === w.userId);-- const text = String(ev.message || '');-- // Streamer/mod shortcut: "!ai pause" etc. also works from relayed chat for the streamer.-- if (/^!ai\b/i.test(text) && (isStreamer || ev.isMod)) {-- try { this.onModCommand(w.userId, streamId, text.split(/\s+/).slice(1), { by: uname }); } catch { /* */ }-- return;-- }-- w.lastRealInputAt = Date.now();-- const det = mention.detect(text, w.bots, { isStreamer });-- if (isStreamer && (det.addressedChat || det.bots.length)) {-- this._pushIntent(w, { kind: 'streamer', text: `The streamer (${uname}) said: "${clip(text, 220)}"${det.bots.length ? ` — addressed to ${det.bots.join(', ')}` : ' — addressed to chat'}. A reply is REQUIRED.`, bots: det.bots, line: text, at: Date.now(), msgId: ev.msgId || null });-- this._fastPath(w, { line: text, bots: det.bots, replyTo: uname, msgId: ev.msgId || null }).catch(() => {});-- return;-- }-- if (det.bots.length) {-- this._pushIntent(w, { kind: 'mention', text: `${uname} addressed ${det.bots.join(', ')}: "${clip(text, 200)}" — that viewer should be answered.`, bots: det.bots, at: Date.now(), replyTo: uname });-- this._scheduleTick(w, 2500);-- return;-- }-- if (isStreamer) { this._scheduleTick(w, Math.min(6000, w.settings.director_interval_sec * 1000)); return; }-- // Regular viewer message: the director decides; reply probability trims how often-- // we even suggest it, and a burst of new chat pulls the next pass closer.-- if (Math.random() < (w.settings.reply_probability == null ? 0.6 : w.settings.reply_probability)) {-- this._pushIntent(w, { kind: 'viewer', text: `${uname} said: "${clip(text, 200)}" — consider replying to them.`, bots: [], at: Date.now(), replyTo: uname, soft: true });-- }-- if (Date.now() - w.lastTickAt > 8000) this._scheduleTick(w, 6000);-- }-- _pushIntent(w, intent) {-- w.intents.push(intent);-- // Hard intents first; keep the queue short so a burst can't stack ten replies.-- w.intents.sort((a, b) => (a.soft ? 1 : 0) - (b.soft ? 1 : 0) || a.at - b.at);-- if (w.intents.length > 6) w.intents = w.intents.slice(0, 6);-- }---- // ── Mod commands: /ai … or !ai … ─────────────────────────-- onModCommand(channelUserId, streamId, args = [], { by = null } = {}) {-- const [cmd, ...rest] = (args || []).map(a => String(a || '').trim()).filter(Boolean);-- const w = this.workerForUser(channelUserId);-- const settings = settingsMod.getSettings(channelUserId);-- const runtime = { paused: !!(settings.runtime && settings.runtime.paused), muted: (settings.runtime && settings.runtime.muted) || [] };-- const save = () => { settingsMod.updateSettings(channelUserId, { runtime }); if (w) { w.settings = settingsMod.getSettings(channelUserId); w.paused = runtime.paused; w.stable = null; } };-- switch ((cmd || 'status').toLowerCase()) {-- case 'pause': runtime.paused = true; save(); if (w) { w.scheduler.clear(); poster.log(w, { event: 'pause', reason: `paused by ${by || 'mod'}` }); } return 'AI viewers paused.';-- case 'resume': runtime.paused = false; save(); if (w) { poster.log(w, { event: 'pause', reason: `resumed by ${by || 'mod'}` }); this._scheduleTick(w, 3000); } return 'AI viewers resumed.';-- case 'mute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); if (!n) return 'Usage: /ai mute <viewer>'; if (!runtime.muted.includes(n)) runtime.muted.push(n); save(); return `Muted ${n}.`; }-- case 'unmute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); runtime.muted = runtime.muted.filter(x => x !== n); save(); return `Unmuted ${n}.`; }-- case 'nudge': if (w) { this._scheduleTick(w, 500); return 'Nudged the director.'; } return 'AI viewers are not running right now.';-- default: {-- if (!w) return `AI viewers: ${runtime.paused ? 'paused' : 'not running for a live stream'}${runtime.muted.length ? `, muted: ${runtime.muted.join(', ')}` : ''}.`;-- return `AI viewers: ${w.paused ? 'paused' : 'running'} · ${w.bots.length} viewers · mode ${w.mode} · ${w.stats.lines} lines this stream${runtime.muted.length ? ` · muted: ${runtime.muted.join(', ')}` : ''}. Commands: pause, resume, mute <name>, unmute <name>, nudge.`;-- }-- }-- }---- // ── Director loop ────────────────────────────────────────-- _scheduleTick(w, delayMs) {-- if (w.stopped) return;-- const at = Date.now() + Math.max(250, delayMs);-- if (w.tickTimer && w.nextTickAt <= at) return; // an earlier tick is already armed-- if (w.tickTimer) clearTimeout(w.tickTimer);-- w.nextTickAt = at;-- w.tickTimer = setTimeout(() => { w.tickTimer = null; this._tick(w).catch(e => console.warn('[AI-Viewers v3] tick error:', e.message)); }, at - Date.now());-- }-- _cadenceMs(w) {-- const s = w.settings;-- const active = (Date.now() - w.lastRealInputAt) < ACTIVE_WINDOW_MS;-- let ms = (active ? s.director_interval_sec : s.idle_interval_sec) * 1000;-- if (w.mode === 'economy') ms *= 1.5;-- if (w.mode === 'replies_only') ms *= 2;-- return ms;-- }-- _providerFor(w) { return w.cfg.use_shared_key ? null : budget.byoProvider(w.cfg); }---- async _tick(w) {-- if (w.stopped || w.ticking) return;-- w.ticking = true;-- const s = w.settings;-- try {-- if (this._killSwitch()) { this.stopForStream(w.streamId); return; }-- try { w.stream = db.getStreamById(w.streamId) || w.stream; } catch { /* */ }-- if (!w.stream || !w.stream.is_live) { this.stopForStream(w.streamId); return; }-- const hardIntents = w.intents.filter(i => !i.soft);-- const st = budget.status(w.userId);-- w.mode = st.mode;-- const quiet = settingsMod.isQuietNow(s);-- if (w.paused || (quiet && !(s.quiet_allow_replies && hardIntents.length)) || st.mode === 'silent' || !st.active) {-- w.intents = [];-- this._logSkip(w, w.paused ? 'paused' : quiet ? 'quiet hours' : (st.reason || st.mode));-- return;-- }-- if (st.mode === 'streamer_only' && !hardIntents.some(i => i.kind === 'streamer')) { w.intents = []; this._logSkip(w, 'budget: streamer replies only'); return; }-- if (st.mode === 'replies_only') w.intents = w.intents.filter(i => !i.soft ? true : i.kind === 'viewer');---- // Vision: look at the screen through the shared memory pipeline when it is stale.-- await this._maybeVision(w, hardIntents.length > 0);---- const bots = w.bots.filter(b => !((s.runtime && s.runtime.muted) || []).includes(b.username.toLowerCase()));-- if (!bots.length) { this._logSkip(w, 'all viewers muted'); return; }-- const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots, settings: s, cacheHolder: w });-- const linesAllowed = Math.max(1, Math.min(s.lines_per_tick, st.mode === 'economy' ? s.lines_per_tick - 1 : s.lines_per_tick));-- const tail = context.volatileTail({-- userId: w.userId, stream: w.stream, settings: s, sinceChatId: w.lastChatId,-- botNames: new Set(w.bots.map(b => b.username.toLowerCase())),-- intents: w.intents.slice(0, 5), mode: st.mode, linesAllowed, botShare: w.scheduler.botShare(),-- });-- const awaiting = tail.threads.some(t => t.awaiting);-- const sceneChanged = tail.delta.memoryId && tail.delta.memoryId !== w.lastSeenMemoryId && s.react_to_scene_changes;-- const sound = s.react_to_sounds && tail.delta.latestSound && (Date.now() - w.lastTickAt) < 120000 ? tail.delta.latestSound : null;-- const changed = tail.hash !== w.lastVolatileHash;-- if (!changed && !w.intents.length && !awaiting && !sceneChanged) { this._logSkip(w, 'nothing new'); return; }-- if (!tail.delta.newChat && !tail.delta.speechLines && !w.intents.length && !awaiting && !sceneChanged && w.scheduler.pending()) { this._logSkip(w, 'lines still queued'); return; }-- w.lastVolatileHash = tail.hash;-- if (sceneChanged) w.lastSeenMemoryId = tail.delta.memoryId;-- let volatileText = tail.text;-- if (sceneChanged && !w.intents.length) volatileText += '\n\nNote: what is on screen just changed — an ambient reaction is welcome.';-- if (sound) volatileText += `\n\nNote: a "${sound.label}" sound was just detected on stream.`;---- const consumed = w.intents.splice(0, w.intents.length);-- const res = await director.plan({-- stableText: stable.text, volatileText, maxLines: linesAllowed,-- provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`,-- });-- w.lastTickAt = Date.now();-- w.lastChatId = Math.max(w.lastChatId, tail.chatMaxId || 0);-- w.stats.ticks++;-- if (!res) { poster.log(w, { event: 'error', reason: 'director call failed or provider quiet' }); w.intents.unshift(...consumed.filter(i => !i.soft)); return; }-- w.stats.cost += res.cost || 0;-- const accepted = this._acceptPlan(w, res.plan, bots, consumed);-- poster.log(w, {-- event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),-- text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,-- tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,-- });-- for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { db.closeAiViewerThread(n); } catch { /* */ } } }-- try { db.closeStaleAiViewerThreads(w.userId, s.thread_idle_close_sec, s.max_thread_turns); } catch { /* */ }-- } finally {-- w.ticking = false;-- if (!w.stopped) this._scheduleTick(w, this._cadenceMs(w));-- }-- }---- _logSkip(w, reason) {-- w.stats.skips++;-- if (Date.now() - w.lastSkipLogAt > SKIP_LOG_EVERY_MS) { w.lastSkipLogAt = Date.now(); poster.log(w, { event: 'skip', reason }); }-- }---- /** Validate + enqueue a plan. Returns the number of accepted lines. */-- _acceptPlan(w, plan, bots, consumed) {-- if (!plan || plan.skip || !plan.lines.length) return 0;-- const s = w.settings;-- const byName = new Map(bots.map(b => [b.username.toLowerCase(), b]));-- const streamerIntent = consumed.find(i => i.kind === 'streamer');-- const out = [];-- let botTargets = 0;-- let lastBot = null;-- for (const raw of plan.lines) {-- const bot = byName.get(String(raw.bot || '').toLowerCase());-- if (!bot) continue;-- if (lastBot === bot.username) continue;-- let text = poster.clean(raw.text, s.max_words);-- if (!text) continue;-- const n = norm(text);-- if (w.recentLines.includes(n)) continue;-- const mod = poster.moderate(text);-- if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); continue; }-- const target = ['viewer', 'bot', 'streamer', 'ambient'].includes(raw.target) ? raw.target : 'ambient';-- if (target === 'bot') {-- const maxBot = Math.floor((s.bot_to_bot_ratio || 0) * Math.max(1, plan.lines.length) + 0.5);-- if (botTargets >= Math.max(0, maxBot) && (s.bot_to_bot_ratio || 0) < 0.79) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: 'bot-to-bot ratio' }); continue; }-- botTargets++;-- }-- // Threads-- let threadId = null;-- try {-- if (raw.thread === 'new') {-- const kind = target === 'bot' ? 'bot_bot' : target === 'streamer' ? 'bot_streamer' : 'bot_viewer';-- const open = db.getOpenAiViewerThreads(w.userId, 10);-- if (open.length < (s.max_open_threads || 3)) {-- const t = db.createAiViewerThread({ channel_user_id: w.userId, stream_id: w.streamId, kind, participants: [bot.username, raw.reply_to].filter(Boolean), topic: raw.topic ? clip(raw.topic, 80) : null, awaiting: target === 'bot' ? raw.reply_to : null });-- threadId = t && t.id;-- }-- } else if (raw.thread && /^\d+$/.test(String(raw.thread))) threadId = parseInt(raw.thread, 10);-- } catch { threadId = null; }-- lastBot = bot.username;-- w.recentLines.push(n); if (w.recentLines.length > 30) w.recentLines.shift();-- out.push({ bot: bot.username, botRow: bot, text, target, replyTo: raw.reply_to || null, threadId, delay_ms: Math.max(0, Math.min(45000, parseInt(raw.delay_ms, 10) || 0)), reason: clip(raw.reason, 120), replyToId: (target === 'streamer' && streamerIntent) ? streamerIntent.msgId : null });-- }-- if (out.length) w.scheduler.enqueue(out);-- return out.length;-- }---- _post(w, line) {-- if (w.stopped) return;-- const bot = line.botRow || w.bots.find(b => b.username === line.bot);-- if (!bot) return;-- const id = poster.post(w, bot, line.text, { threadId: line.threadId, replyToId: line.replyToId });-- w.stats.lines++;-- if (line.target === 'streamer') w.lastStreamerReplyAt = Date.now();-- poster.log(w, { event: 'line', bot_username: bot.username, target: line.target + (line.replyTo ? `:${line.replyTo}` : ''), thread_id: line.threadId || null, chat_message_id: id, text: line.text, reason: line.reason });-- const buf = w.botLines.get(bot.id) || []; buf.push(line.text); if (buf.length > 24) buf.shift(); w.botLines.set(bot.id, buf);-- if (line.threadId) {-- try { db.touchAiViewerThread(line.threadId, { line: line.text, by: bot.username, awaiting: line.target === 'bot' ? (line.replyTo || null) : null }); } catch { /* */ }-- // A bot↔bot thread waiting on the other bot pulls the next pass closer.-- if (line.target === 'bot') this._scheduleTick(w, Math.min(this._cadenceMs(w), 12000));-- }-- }---- // ── Streamer fast path ───────────────────────────────────-- async _fastPath(w, { line, bots: named, replyTo, msgId }) {-- const s = w.settings;-- const waitMs = Math.max(0, (s.mention_fast_path_sec || 0) * 1000);-- const nextIn = w.nextTickAt ? w.nextTickAt - Date.now() : Infinity;-- if (nextIn <= waitMs + 1500) { this._scheduleTick(w, Math.min(nextIn, 1500)); return; } // the director will handle it in time-- const st = budget.status(w.userId);-- if (!st.active || st.mode === 'silent' || w.paused) return;-- const muted = new Set(((s.runtime && s.runtime.muted) || []).map(x => String(x).toLowerCase()));-- const pool = w.bots.filter(b => !muted.has(b.username.toLowerCase()));-- if (!pool.length) return;-- const bot = (named.length && pool.find(b => b.username === named[0])) || pool[Math.floor(Math.random() * pool.length)];-- await this._maybeVision(w, true);-- const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: pool, settings: s, cacheHolder: w });-- const heard = context.heardBlock(w.stream, s);-- const seen = context.seenBlock(w.stream);-- const situation = [-- heard.text ? `Heard on stream recently:\n${heard.text.split('\n').slice(-6).join('\n')}` : '',-- seen.text ? `On screen (${context.ago(seen.ageMs)}): ${seen.text}` : '',-- ].filter(Boolean).join('\n\n');-- const r = await director.quickReply({ stableText: stable.text, situationText: situation, bot, streamerLine: clip(line, 220), maxWords: s.max_words, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}` });-- if (!r || !r.text) return;-- const text = poster.clean(r.text, s.max_words);-- const mod = text ? poster.moderate(text) : { ok: false, reason: 'empty' };-- if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); return; }-- w.stats.cost += r.cost || 0;-- w.intents = w.intents.filter(i => i.kind !== 'streamer'); // handled — the next pass must not double-reply-- w.recentLines.push(norm(text)); if (w.recentLines.length > 30) w.recentLines.shift();-- poster.log(w, { event: 'mention', bot_username: bot.username, target: `streamer:${replyTo}`, text, reason: 'fast path — streamer addressed chat', tokens_in: r.usage.input, tokens_cached: r.usage.cached, tokens_out: r.usage.output, cost_usd: r.cost, model: r.model });-- w.scheduler.enqueue([{ bot: bot.username, botRow: bot, text, target: 'streamer', replyTo, threadId: null, delay_ms: 0, reason: 'answering the streamer', replyToId: msgId || null }], { basePrio: 3 });-- }---- // ── Vision policy ────────────────────────────────────────-- async _maybeVision(w, addressed) {-- const s = w.settings;-- if (s.vision_policy === 'off') return;-- const seen = context.seenBlock(w.stream);-- const stale = seen.ageMs == null || seen.ageMs > (s.vision_max_age_sec || 180) * 1000;-- const periodicDue = s.vision_policy === 'periodic' && (Date.now() - w.lastPeriodicVisionAt) > (s.vision_periodic_sec || 300) * 1000 && (Date.now() - w.lastRealInputAt) < 5 * 60000;-- if (!stale && !periodicDue) return;-- const allowFfmpeg = s.vision_policy !== 'thumbnail';-- if (addressed || periodicDue) {-- if (periodicDue) w.lastPeriodicVisionAt = Date.now();-- const p = context.wantFreshFrame(w.stream, { maxAgeSec: s.vision_max_age_sec, allowFfmpeg });-- // Addressed: wait up to 12s so the answer can reference the screen; otherwise fire and forget.-- if (addressed) { const t = new Promise(res => setTimeout(() => res(false), 12000)); const started = await Promise.race([p, t]); if (started) poster.log(w, { event: 'vision', reason: 'fresh frame analyzed (shared memory pipeline)' }); }-- else p.then(ok => { if (ok) poster.log(w, { event: 'vision', reason: 'periodic frame analyzed' }); }).catch(() => {});-- }-- }---- // ── Memory fold ──────────────────────────────────────────-- async _fold(w) {-- try {-- const r = await fold.foldAll(w, { provider: this._providerFor(w) });-- if (r) { w.stats.cost += r.cost || 0; poster.log(w, { event: 'fold', reason: `memory updated for ${r.updated} viewer(s)`, tokens_in: r.usage && r.usage.input, tokens_cached: r.usage && r.usage.cached, tokens_out: r.usage && r.usage.output, cost_usd: r.cost, model: r.model });-- for (let i = 0; i < w.bots.length; i++) { const fresh = db.getChannelAiBot(w.bots[i].id); if (fresh) w.bots[i] = fresh; }-- w.stable = null;-- }-- } catch (e) { poster.log(w, { event: 'error', reason: `fold: ${e.message}` }); }-- }---- // ── Status / preview (control panel) ─────────────────────-- status(userId) {-- const w = this.workerForUser(userId);-- const st = budget.status(userId);-- const timelineOn = (() => { try { return require('../../timeline-job').timelineEnabled(); } catch { return false; } })();-- const base = { engine: 'v3', running: !!w, mode: st.mode, budget_active: st.active, budget_reason: st.reason, spent_today_usd: st.spentToday, cap_usd: st.capUsd, timeline_enabled: timelineOn, kill_switch: this._killSwitch() };-- if (!w) return base;-- const seen = context.seenBlock(w.stream);-- const heard = context.heardBlock(w.stream, w.settings);-- return { ...base, stream_id: w.streamId, paused: w.paused, bots: w.bots.length, stats: w.stats, last_tick_at: w.lastTickAt || null, next_tick_in_ms: w.nextTickAt ? Math.max(0, w.nextTickAt - Date.now()) : null,-- queued: w.scheduler.pending(), bot_share: w.scheduler.botShare(), lines_last_minute: w.scheduler.linesLastMinute(),-- senses: { heard_age_ms: heard.ageMs, heard_lines: heard.count, seen_age_ms: seen.ageMs, seen_text: seen.text, stable_prefix_chars: w.stable ? w.stable.text.length : null, stable_cached_since: w.stable ? w.stable.at : null } };-- }---- /** Dry run: assemble the real context and ask the director what it would do (nothing posted). */-- async preview(userId) {-- let w = this.workerForUser(userId);-- let temp = false;-- if (!w) {-- const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();-- if (!live.length) return { error: 'Go live first — the preview runs on your real stream context.' };-- const cfg = db.getChannelAiConfig(userId); const settings = settingsMod.getSettings(userId, cfg);-- const bots = roster.ensureRoster(userId, settings.roster_size || 3);-- w = { streamId: live[0].id, userId, stream: db.getStreamById(live[0].id) || live[0], cfg, settings, bots, lastChatId: Math.max(0, db.getMaxChatMessageIdForChannel(userId) - 40), intents: [], scheduler: { botShare: () => 0 } };-- temp = true;-- }-- const s = w.settings;-- const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: w.bots, settings: s, cacheHolder: temp ? null : w });-- const tail = context.volatileTail({ userId: w.userId, stream: w.stream, settings: s, sinceChatId: Math.max(0, (w.lastChatId || 0) - 25), botNames: new Set(w.bots.map(b => b.username.toLowerCase())), intents: w.intents, mode: 'normal', linesAllowed: s.lines_per_tick, botShare: w.scheduler.botShare() });-- const res = await director.plan({ stableText: stable.text, volatileText: tail.text, maxLines: s.lines_per_tick, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`, temperature: 0.9 });-- return { plan: res ? res.plan : null, usage: res ? res.usage : null, cost: res ? res.cost : null, model: res ? res.model : null, context: { stable_chars: stable.text.length, volatile_chars: tail.text.length, sources: tail.sources }, stable_preview: stable.text.slice(0, 1200), volatile_preview: tail.text.slice(0, 1500) };-- }---- nudge(userId) { const w = this.workerForUser(userId); if (!w) return false; this._scheduleTick(w, 500); return true; }--}----module.exports = new AiViewersEngineV3();ddeleted file mode 100644@@@ -1,73 +0,0 @@--/**-- * AI viewers v3 — memory fold. One summary-role call per channel 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');----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 }-- * @returns {Promise<{updated:number, cost:number}|null>}-- */--async function foldAll(worker, { provider = null } = {}) {-- const entries = [];-- for (const bot of worker.bots) {-- const lines = worker.botLines.get(bot.id) || [];-- if (lines.length < 3) continue;-- let brain = {}; try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }-- entries.push({ bot, lines: lines.slice(-20), memory: brain.memory || '' });-- }-- if (!entries.length) return 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,-- });-- if (!r) return null;-- const out = r.json || llm.parseJsonLoose(r.text);-- if (!out || !Array.isArray(out.memories)) return null;-- let updated = 0;-- for (const m of out.memories) {-- const e = entries.find(x => x.bot.username.toLowerCase() === String(m.bot || '').toLowerCase());-- if (!e || !m.memory || !String(m.memory).trim()) continue;-- let brain = {}; try { brain = JSON.parse(e.bot.brain_json || '{}'); } catch { /* */ }-- brain.memory = clip(m.memory, 900);-- brain.timeline = (brain.timeline || []).slice(-8);-- brain.timeline.push({ n: e.bot.msg_count || 0, note: clip(e.lines[e.lines.length - 1], 80) });-- try { db.updateChannelAiBot(e.bot.id, { brain_json: brain }); worker.botLines.set(e.bot.id, []); updated++; } catch { /* */ }-- }-- if (out.channel_memory && String(out.channel_memory).trim()) {-- try {-- const cfg = db.getChannelAiConfig(worker.userId);-- let sj = {}; try { sj = JSON.parse(cfg.settings_json || '{}') || {}; } catch { /* */ }-- sj.channel_memory = clip(out.channel_memory, 600);-- db.upsertChannelAiConfig(worker.userId, { settings_json: JSON.stringify(sj) });-- } catch { /* */ }-- }-- return { updated, cost: r.cost || 0, usage: r.usage, model: r.model };--}----module.exports = { foldAll };ddeleted file mode 100644@@@ -1,29 +0,0 @@--/**-- * AI viewers v3 — who is being addressed?-- * detect(message, bots, { isStreamer }) → { bots: [usernames named], addressedChat, question }-- * A streamer talking to "chat"/"guys"/"everyone" or asking a question addresses everyone;-- * "@name" or a bare bot username/display name addresses that bot (anyone can do that).-- */--'use strict';----const CHAT_WORDS = /\b(chat|guys|gang|everyone|everybody|yall|y'all|you all|anyone|anybody|viewers|lads|folks|people)\b/i;----function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }----function detect(message, bots, { isStreamer = false } = {}) {-- const text = String(message || '');-- const lower = text.toLowerCase();-- const named = [];-- for (const b of bots || []) {-- const names = [b.username, b.display_name].filter(Boolean).map(n => String(n).toLowerCase());-- for (const n of names) {-- if (n.length < 3) continue;-- if (lower.includes('@' + n) || new RegExp(`(^|[^a-z0-9_])${escapeRe(n)}([^a-z0-9_]|$)`, 'i').test(text)) { named.push(b.username); break; }-- }-- }-- const question = /\?\s*$/.test(text.trim()) || /\b(what do you|whatd?ya|do you (guys|all)|should i|thoughts\??|opinions?\??|who here|anyone (here|know|want))\b/i.test(text);-- const addressedChat = isStreamer && (CHAT_WORDS.test(text) || question || /^(so|ok|okay|alright|hey|yo)\b/i.test(text.trim()) && text.length < 120);-- return { bots: [...new Set(named)], addressedChat, question };--}----module.exports = { detect };ddeleted file mode 100644@@@ -1,106 +0,0 @@--/**-- * AI viewers v3 — posting a bot line into chat (+ moderation + activity log).-- * Ported from the v2 engine's _inject, with the bits it skipped: word filter, per-bot-- * TTS toggle, PowerChat forward toggle, and a log row for the control panel.-- */--'use strict';--const db = require('../../../db/database');----function botColor(bot) {-- try { return (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || '#8a8aff'; } catch { return bot.avatar_color || '#8a8aff'; }--}--function botPersona(bot) { try { return JSON.parse(bot.persona_json || '{}') || {}; } catch { return {}; } }----/** Strip quotes/disclaimers; enforce a word cap. Returns '' when the line must be dropped. */--function clean(text, maxWords = 18) {-- let t = String(text || '').trim();-- t = t.replace(/^["'`“”]+|["'`“”]+$/g, '').replace(/\s+/g, ' ').trim();-- if (!t) return '';-- if (/\b(as an ai|language model|i am a bot|i'm a bot|as a bot|openai|anthropic)\b/i.test(t)) return '';-- if (/^\w+:\s/.test(t) && t.length < 80) t = t.replace(/^\w+:\s/, ''); // "botname: text" echo-- const words = t.split(' ');-- if (words.length > maxWords) t = words.slice(0, maxWords).join(' ').replace(/[,;:]$/, '');-- if (t.length > 240) t = t.slice(0, 240);-- return t.trim();--}----/** Word-filter gate — returns { ok, text, reason }. */--function moderate(text) {-- try {-- const wf = require('../../../chat/word-filter');-- const r = wf.check(text);-- if (!r.safe) return { ok: false, text, reason: `word filter: ${r.matches.slice(0, 3).join(', ')}` };-- if (typeof wf.isSpam === 'function' && wf.isSpam(text)) return { ok: false, text, reason: 'spam pattern' };-- } catch { /* filter unavailable → allow */ }-- return { ok: true, text, reason: null };--}----/**-- * Post a line. Returns the chat message id (or null).-- * @param {object} worker { streamId, userId, settings }-- */--function post(worker, bot, message, { threadId = null, replyToId = null } = {}) {-- const streamId = worker.streamId;-- const persona = botPersona(bot);-- const color = botColor(bot);-- let id = null;-- try {-- const res = db.saveChatMessage({-- stream_id: streamId,-- channel_user_id: worker.userId,-- user_id: null,-- username: bot.username,-- message,-- message_type: 'chat',-- is_global: false,-- reply_to_id: replyToId || null,-- source_platform: 'ai', // marker → excluded from chat-AI feeds-- metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },-- });-- id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;-- } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }---- const chatMsg = {-- type: 'chat', id: id || undefined,-- username: bot.username, core_username: null,-- user_id: null, anon_id: null, role: 'user',-- message, stream_id: streamId, is_global: false,-- reply_to_id: replyToId || null,-- avatar_url: null, profile_color: color,-- is_ai: true, source_platform: 'ai',-- filtered: false, timestamp: new Date().toISOString(),-- };-- try {-- const chatServer = require('../../../chat/chat-server');-- chatServer.broadcastToStream(streamId, chatMsg);-- chatServer.forwardToGlobal(streamId, chatMsg);-- const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;-- if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);-- } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }---- if (worker.settings.powerchat_forward !== false) {-- try {-- const pc = require('../../../integrations/powerchat-platform');-- if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {-- pc.forwardChat(worker.userId, {-- chatterName: bot.username,-- externalChatterId: `ai:${bot.username.toLowerCase()}`,-- message,-- messageId: id ? `ov-${id}` : undefined,-- avatarFallback: '🤖',-- });-- }-- } catch { /* non-critical */ }-- }-- try { db.touchChannelAiBot(bot.id); } catch { /* */ }-- return id;--}----/** Append a row to ai_viewer_log (best-effort). */--function log(worker, row) {-- try {-- db.addAiViewerLog({ channel_user_id: worker.userId, stream_id: worker.streamId || null, ...row });-- } catch { /* */ }--}----module.exports = { post, clean, moderate, log, botPersona, botColor };ddeleted file mode 100644@@@ -1,97 +0,0 @@--/**-- * AI viewers v3 — pacing. Planned lines go into a per-stream queue and are released-- * humanly: a minimum gap between any two bot lines, a per-minute ceiling, slow-mode-- * awareness, no bot twice in a row, and a rolling bot-to-bot share cap.-- */--'use strict';----function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }----class Scheduler {-- constructor(worker, deps) {-- this.worker = worker; // { streamId, settings, ... }-- this.deps = deps; // { post(line), now() }-- this.queue = []; // [{ bot, text, target, replyTo, threadId, postAt, prio, reason }]-- this.recent = []; // [{ at, target, bot }] last 40 posted-- this.lastPostAt = 0;-- this.lastBot = null;-- this.timer = null;-- this.stopped = false;-- }---- settings() { return this.worker.settings || {}; }---- /** Queue planned lines (from a director pass). */-- enqueue(lines, { basePrio = 1 } = {}) {-- const now = Date.now();-- for (const l of lines) {-- const jitter = rint(0, 2500);-- const prio = l.target === 'streamer' ? 3 : basePrio;-- this.queue.push({ ...l, prio, postAt: now + (prio === 3 ? Math.min(l.delay_ms || 0, 4000) : (l.delay_ms || 0)) + jitter });-- }-- // Keep the queue bounded: newest plans win over stale unposted ambient chatter.-- if (this.queue.length > 12) this.queue = this.queue.sort((a, b) => b.prio - a.prio || a.postAt - b.postAt).slice(0, 12);-- this._arm();-- }---- /** Drop everything not yet posted (config change / pause / stop). */-- clear() { this.queue = []; if (this.timer) { clearTimeout(this.timer); this.timer = null; } }---- stop() { this.stopped = true; this.clear(); }---- pending() { return this.queue.length; }---- /** Share of bot→bot lines over the last 20 posts (0..1). */-- botShare() {-- const last = this.recent.slice(-20);-- if (!last.length) return 0;-- return last.filter(r => r.target === 'bot').length / last.length;-- }-- linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }---- _slowModeMs() {-- try { const cs = require('../../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }-- }---- _arm(delay = null) {-- if (this.stopped || this.timer || !this.queue.length) return;-- const next = Math.min(...this.queue.map(q => q.postAt));-- const wait = delay != null ? delay : Math.max(250, next - Date.now());-- this.timer = setTimeout(() => { this.timer = null; this._drain(); }, wait);-- }---- _drain() {-- if (this.stopped || !this.queue.length) return;-- const s = this.settings();-- const now = Date.now();-- const minGap = Math.max((s.min_gap_sec || 4) * 1000, this._slowModeMs());-- if (now - this.lastPostAt < minGap) return this._arm(minGap - (now - this.lastPostAt));-- const perMin = Math.max(0.5, s.lines_per_min || 3);-- if (this.linesLastMinute() >= perMin) return this._arm(5000);---- // Pick the highest-priority due line that doesn't violate the alternation/ratio rules.-- const due = this.queue.filter(q => q.postAt <= now).sort((a, b) => b.prio - a.prio || a.postAt - b.postAt);-- if (!due.length) return this._arm();-- let pick = null;-- for (const q of due) {-- if (q.bot === this.lastBot && due.length > 1) continue;-- if (q.target === 'bot' && this.botShare() >= (s.bot_to_bot_ratio || 0) + 0.05 && q.prio < 3) continue;-- pick = q; break;-- }-- if (!pick) {-- // Everything due is blocked by the ratio/alternation rules — drop stale bot↔bot chatter.-- const stale = due.find(q => q.target === 'bot' && q.prio < 3);-- if (stale) { this.queue = this.queue.filter(q => q !== stale); this.deps.dropped && this.deps.dropped(stale, 'bot-to-bot share cap'); }-- return this._arm(3000);-- }-- this.queue = this.queue.filter(q => q !== pick);-- try { this.deps.post(pick); } catch (e) { console.warn('[AI-Viewers] post failed:', e.message); }-- this.lastPostAt = Date.now();-- this.lastBot = pick.bot;-- this.recent.push({ at: this.lastPostAt, target: pick.target, bot: pick.bot });-- if (this.recent.length > 40) this.recent.shift();-- this._arm();-- }--}----module.exports = { Scheduler };@@@ -1079,7 +1079,6 @@ function initDb() {['ai_model_summary', '', 'Model for summaries/overviews/memory folds. Blank = ai_model', 'string'],['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"gpt-5-mini":{"in":0.25,"out":2,"cached":0.025},"gpt-5":{"in":1.25,"out":10,"cached":0.125},"gpt-4o-mini":{"in":0.15,"out":0.6,"cached":0.075},"gpt-4.1-mini":{"in":0.4,"out":1.6,"cached":0.1},"claude-haiku":{"in":0.8,"out":4,"cached":0.08},"claude-sonnet":{"in":3,"out":15,"cached":0.3}}', 'USD per 1M tokens by model-id prefix: {"<model>":{"in","out","cached"}}. Longest prefix wins; missing → ai_input/output_cost_per_mtok', 'json'],['ai_viewers_enabled', 'true', 'Kill switch for the AI chat viewers feature (all channels)', 'boolean'],-- ['ai_viewers_engine', 'v2', 'AI viewers engine: v2 (legacy per-line) | v3 (director, unified context)', 'string'],['ai_viewers_max_roster', '12', 'Max AI viewers per channel', 'number'],['ai_viewers_max_lines_per_min', '12', 'Hard ceiling on bot lines per minute per channel', 'number'],['ai_viewers_global_cap_usd_per_day', '0', 'Daily USD cap for ALL AI-viewer spend on the shared key (0 = none)', 'number'],@@@ -1087,6 +1086,7 @@ function initDb() {];const seedAi = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");for (const [k, v, d, t] of aiSeeds) seedAi.run(k, v, d, t);++ try { database.exec("DELETE FROM site_settings WHERE key = 'ai_viewers_engine'"); } catch { /* */ }// PowerChat monetization (donations/tips). App-level OAuth client + webhook secret// are configured here by the owner; each streamer then connects their own PowerChat
@@ -463,24 +463,19 @@ router.get('/ai/usage', (req, res) => {
@@ -463,24 +463,19 @@ router.get('/ai/usage', (req, res) => {
router.get('/ai/viewers/status', (req, res) => {
router.get('/ai/viewers/status', (req, res) => {
try {
try {
const viewers = require('../ai/viewers');
const viewers = require('../ai/viewers');
- const v3 = viewers.v3(); const v2 = viewers.v2();
const running = [];
const running = [];
- for (const w of v3.workers.values()) {
+ for (const w of viewers.engine.workers.values()) {
const u = db.getUserById(w.userId);
const u = db.getUserById(w.userId);
running.push({ engine: 'v3', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length, mode: w.mode, paused: w.paused, stats: w.stats, started_at: w.startedAt });
running.push({ engine: 'v3', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length, mode: w.mode, paused: w.paused, stats: w.stats, started_at: w.startedAt });
}
}
- for (const w of v2.workers.values()) {
- const u = db.getUserById(w.userId);
- running.push({ engine: 'v2', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length });
- }
const channels = db.all(`SELECT c.user_id, u.username, c.enabled, c.use_shared_key, c.daily_budget_cents, c.settings_json,
const channels = db.all(`SELECT c.user_id, u.username, c.enabled, c.use_shared_key, c.daily_budget_cents, c.settings_json,
(SELECT COUNT(*) FROM channel_ai_bots b WHERE b.channel_user_id = c.user_id AND b.is_active = 1) AS bots
(SELECT COUNT(*) FROM channel_ai_bots b WHERE b.channel_user_id = c.user_id AND b.is_active = 1) AS bots
FROM channel_ai_config c JOIN users u ON u.id = c.user_id ORDER BY c.enabled DESC, u.username`).map(r => {
FROM channel_ai_config c JOIN users u ON u.id = c.user_id ORDER BY c.enabled DESC, u.username`).map(r => {
let sj = {}; try { sj = JSON.parse(r.settings_json || '{}') || {}; } catch { /* */ }
let sj = {}; try { sj = JSON.parse(r.settings_json || '{}') || {}; } catch { /* */ }
let spent = 0; try { spent = db.getAiCostTodayForUser(r.user_id, 'ai_viewers'); } catch { /* */ }
let spent = 0; try { spent = db.getAiCostTodayForUser(r.user_id, 'ai_viewers'); } catch { /* */ }
- return { user_id: r.user_id, username: r.username, enabled: !!r.enabled, shared_key: !!r.use_shared_key, cap_usd: (r.daily_budget_cents || 0) / 100, spent_today_usd: spent, bots: r.bots, engine: sj.engine || null, activity: sj.activity || null };
+ return { user_id: r.user_id, username: r.username, enabled: !!r.enabled, shared_key: !!r.use_shared_key, cap_usd: (r.daily_budget_cents || 0) / 100, spent_today_usd: spent, bots: r.bots, activity: sj.activity || null };
});
});
- res.json({ engine_default: viewers.version, kill_switch: db.getSetting('ai_viewers_enabled') === false, global_spend_today_usd: require('../ai/viewers/budget').globalViewerSpendToday(), running, channels });
+ res.json({ kill_switch: db.getSetting('ai_viewers_enabled') === false, global_spend_today_usd: require('../ai/viewers/budget').globalViewerSpendToday(), running, channels });
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) { res.status(500).json({ error: err.message }); }
});
});
router.post('/ai/viewers/:userId/:action', (req, res) => {
router.post('/ai/viewers/:userId/:action', (req, res) => {
deleted file mode 100644
deleted file mode 100644
@@ -1,59 +0,0 @@
@@ -1,59 +0,0 @@
-/**
- * AI Chat Viewers — persistent brains + rolling memory.
- *
- * A bot's brain_json holds a condensed identity + rolling memory + a short timeline
- * that persists across streams (stored on channel_ai_bots). Periodically we fold the
- * bot's recent activity into that memory with a cheap metered LLM pass, so a bot that
- * chatted last week remembers running jokes and who it talked to.
- */
-const db = require('../../db/database');
-const budget = require('./budget');
-
-function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }
-
-/**
- * Fold a bot's recent lines + notable events into its rolling memory.
- * @param {number} streamerId
- * @param {object} bot channel_ai_bots row
- * @param {string[]} recentLines what the bot said / reacted to since the last fold
- * @returns {Promise<boolean>} whether the brain was updated
- */
-async function foldBrain(streamerId, bot, recentLines) {
- if (!bot || !recentLines || recentLines.length < 3) return false;
- let brain = {};
- try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }
- const prevMemory = brain.memory || '';
-
- const prompt = [
- `You maintain the memory of a chat persona named "${bot.display_name || bot.username}".`,
- prevMemory ? `Current memory:\n${clip(prevMemory, 800)}` : `No memory yet.`,
- `Recent things they said/saw in chat:\n- ${recentLines.map(l => clip(l, 160)).slice(-20).join('\n- ')}`,
- `Update their memory: a tight paragraph (max 90 words) of durable facts — running jokes, who they talk to, opinions they've formed, recurring topics. Keep the useful old stuff, drop the trivial. Return ONLY the updated memory paragraph.`,
- ].join('\n\n');
-
- let updated;
- try {
- updated = await budget.generate(streamerId, { system: '', user: prompt, maxTokens: 160, temperature: 0.4 });
- } catch { return false; }
- if (!updated || !updated.trim()) return false;
-
- brain.memory = clip(updated, 900);
- brain.timeline = (brain.timeline || []).slice(-8);
- brain.timeline.push({ n: bot.msg_count || 0, note: clip(recentLines[recentLines.length - 1], 80) });
- try {
- db.updateChannelAiBot(bot.id, { brain_json: brain });
- return true;
- } catch { return false; }
-}
-
-/** Clear a bot's rolling memory (keeps its identity/persona). */
-function clearBrain(botId) {
- const bot = db.getChannelAiBot(botId);
- if (!bot) return false;
- let persona = {};
- try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }
- db.updateChannelAiBot(botId, { brain_json: { memory: '', timeline: [], identity: persona.identity || '' } });
- return true;
-}
-
-module.exports = { foldBrain, clearBrain };
deleted file mode 100644
deleted file mode 100644
@@ -1,117 +0,0 @@
@@ -1,117 +0,0 @@
-/**
- * AI Chat Viewers — context assembly.
- *
- * Builds a compact, layered prompt for one bot line from data we already produce:
- * - the streamer's profile (streamer_overviews)
- * - what's happening now (live "situation": audio transcript + on-screen vision)
- * - room mood (global chat insight)
- * - who they're replying to (that viewer's insight/brain)
- * - the bot's own persistent persona + brain
- *
- * The shared per-tick context (streamer/global/situation/recent chat) is computed
- * once by refreshShared() and reused by every bot in the tick; only the per-target
- * viewer insight varies per line.
- */
-const db = require('../../db/database');
-const chatAi = require('../chat-ai');
-
-function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }
-
-/** Compute the per-tick shared context for a stream (cached on the worker). */
-function refreshShared(worker) {
- const s = worker.stream || {};
- const streamerId = worker.userId;
- let streamerOverview = '';
- try {
- const ov = db.getStreamerOverview(streamerId);
- streamerOverview = clip(ov && (ov.overview_short || ov.overview), 700);
- } catch { /* */ }
- let globalMood = '';
- try {
- const g = chatAi.getGlobalInsight();
- if (g) globalMood = clip(g.overview, 300);
- } catch { /* */ }
- worker.shared = {
- streamerOverview,
- globalMood,
- streamMeta: {
- title: clip(s.title, 140),
- category: clip(s.category, 60),
- streamer: clip(s.display_name || s.username, 60),
- },
- at: Date.now(),
- };
- return worker.shared;
-}
-
-// Recent chat lines the bot can "see" (worker keeps a rolling buffer).
-function recentChatText(worker, max = 14) {
- const lines = (worker.recentChat || []).slice(-max);
- return lines.map(l => `${l.username}: ${clip(l.message, 160)}`).join('\n');
-}
-
-// Insight text for the viewer a bot is replying to (real or relay).
-function targetInsightText(target) {
- if (!target) return '';
- try {
- let ins = null;
- if (target.kind === 'user' && target.userId) ins = chatAi.getUserInsight(target.userId);
- else if (target.kind === 'relay' && target.relayId) ins = chatAi.getRelayUserInsight(target.relayId);
- if (!ins) return '';
- const ov = ins.overview_alltime || ins.overview_24h || '';
- const mem = ins.memory || '';
- return clip([ov, mem].filter(Boolean).join(' — '), 400);
- } catch { return ''; }
-}
-
-/**
- * Build {system, user} for one bot line.
- * @param {object} worker
- * @param {object} bot channel_ai_bots row
- * @param {{targetUser?:object, addressed?:boolean, situationLine?:string}} [opts]
- */
-function buildBotContext(worker, bot, opts = {}) {
- const shared = worker.shared || refreshShared(worker);
- let persona = {}; let brain = {};
- try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }
- try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }
-
- const charBlurb = (persona.character && persona.character.blurb) || 'a chat viewer';
- const styleRules = (persona.style && persona.style.rules) || 'casual, short';
- const identity = persona.identity || brain.identity || '';
- const memory = brain.memory || '';
-
- const sys = [
- `You are "${bot.display_name || bot.username}", a viewer in a live stream's chat. You are ${charBlurb}.`,
- identity ? `Your identity: ${clip(identity, 500)}` : '',
- `Typing style: ${styleRules}.`,
- memory ? `Things you remember from this channel: ${clip(memory, 500)}` : '',
- shared.streamerOverview ? `About the streamer (${shared.streamMeta.streamer}): ${shared.streamerOverview}` : '',
- shared.globalMood ? `Site chat mood right now: ${shared.globalMood}` : '',
- `Rules: Reply with ONE short chat message only (usually under 15 words). Stay fully in character. Never say you are an AI, a bot, or a language model. No quotation marks, no emojis unless your style uses them, no meta commentary. Just the message text.`,
- ].filter(Boolean).join('\n');
-
- const situation = opts.situationLine || worker.situationLine || '';
- const recent = recentChatText(worker);
- const targetTxt = opts.targetUser ? targetInsightText(opts.targetUser) : '';
-
- const userParts = [
- `Stream: "${shared.streamMeta.title}"${shared.streamMeta.category ? ` [${shared.streamMeta.category}]` : ''}.`,
- situation ? `What's happening on stream right now: ${situation}` : '',
- recent ? `Recent chat:\n${recent}` : 'Chat is quiet right now.',
- ];
- if (opts.targetUser) {
- const who = opts.targetUser.username || 'someone';
- userParts.push(targetTxt
- ? `You're replying to ${who}. What you know about ${who}: ${targetTxt}`
- : `You're replying to ${who}.`);
- if (opts.targetUser.message) userParts.push(`${who} just said: "${clip(opts.targetUser.message, 200)}"`);
- } else if (opts.addressed) {
- userParts.push(`The streamer just addressed chat. React naturally.`);
- }
- userParts.push(`Write your next chat message as ${bot.username}:`);
-
- return { system: sys, user: userParts.filter(Boolean).join('\n\n') };
-}
-
-module.exports = { refreshShared, buildBotContext, recentChatText, targetInsightText };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+/**
+ * AI viewers v3 — the director.
+ *
+ * ONE structured LLM call 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.
+ */
+'use strict';
+const llm = require('../llm');
+
+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 },
+ ];
+}
+
+/**
+ * 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 }
+ */
+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,
+ });
+ if (!r) return null;
+ let p = r.json;
+ if (!p || typeof p !== 'object') p = llm.parseJsonLoose(r.text) || { skip: true, lines: [] };
+ 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).
+ */
+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;
+}
+
+module.exports = { plan, quickReply, PLAN_SCHEMA, DIRECTOR_RULES, buildSystem };
@@ -1,364 +1,428 @@
@@ -1,364 +1,428 @@
/**
/**
- * AI Chat Viewers 2.0 — engine + lifecycle.
+ * AI Chat Viewers v3 — engine.
*
*
- * Persistent per-channel bot roster reacts to the live situation (audio + vision),
- * the room mood, and the specific viewers they reply to. One bot speaks at a time,
- * paced to feel human; every LLM call is metered + budget-capped per streamer.
- *
- * Wired at the same lifecycle hooks the old ai-chatbot-service used (startForStream /
- * stopForStream / onRealChatMessage / applyConfigForUser / hasWorker / stopForUser),
- * so ../integrations/ai-chatbot-service.js re-exports this singleton.
+ * Per live stream: a worker holds the roster, settings, a cached stable prompt prefix,
+ * a scheduler, and pending intents. A DIRECTOR pass runs on a cadence (or immediately
+ * on a priority intent), assembles the unified context (only deltas), makes ONE
+ * structured LLM call that plans several lines, and hands them to the scheduler.
+ * Streamer mentions get a guaranteed answer via a fast path when the next pass is far.
+ * Nothing runs when nothing changed. Spend degrades gracefully toward the streamer's cap.
*/
*/
+'use strict';
const db = require('../../db/database');
const db = require('../../db/database');
-const budget = require('./budget');
+const settingsMod = require('./settings');
const roster = require('./roster');
const roster = require('./roster');
-const context = require('./context');
-const brain = require('./brain');
+const budget = require('./budget');
+const context = require('../context');
+const director = require('./director');
+const poster = require('./poster');
+const mention = require('./mention');
+const fold = require('./fold');
+const { Scheduler } = require('./scheduler');
-// ── Pacing constants ──────────────────────────────────────────
-const GLOBAL_MIN_GAP_MS = 3500; // hard floor between any two bot lines
-const ACTIVE_GAP_MS = [5000, 11000]; // target gap when chat/stream is active
-const IDLE_GAP_MS = [14000, 30000]; // target gap when quiet
-const TICK_MS = 2500; // pacer resolution
-const ACTIVE_WINDOW_MS = 25000; // "active" = real input within this window
-const SITUATION_AUDIO_MS = 30000; // how often to sample mic (free local STT)
-const SITUATION_VISION_MS = 75000; // how often to sample the screen (metered)
-const BRAIN_FOLD_MS = 180000; // how often to fold bots' rolling memory
-const RECENT_CHAT_MAX = 30;
-const REPLY_PROB = 0.6; // chance a real viewer msg gets a bot reply
+const ACTIVE_WINDOW_MS = 45000;
+const SKIP_LOG_EVERY_MS = 5 * 60 * 1000;
+const STREAMER_QUIET_MS = 8000; // after answering the streamer, hold ambient chatter briefly
-function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
+function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }
+function norm(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); }
-class AiViewersEngine {
- constructor() {
- /** @type {Map<number, object>} streamId → worker */
- this.workers = new Map();
- }
+class AiViewersEngineV3 {
+ constructor() { this.workers = new Map(); this.version = 'v3'; }
hasWorker(streamId) { return this.workers.has(streamId); }
hasWorker(streamId) { return this.workers.has(streamId); }
+ workerForUser(userId) { for (const w of this.workers.values()) if (w.userId === userId) return w; return null; }
+
+ _killSwitch() { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; }
// ── Lifecycle ─────────────────────────────────────────────
// ── Lifecycle ─────────────────────────────────────────────
async startForStream(stream) {
async startForStream(stream) {
try {
try {
if (!stream || !stream.id || !stream.user_id) return;
if (!stream || !stream.id || !stream.user_id) return;
if (this.workers.has(stream.id)) return;
if (this.workers.has(stream.id)) return;
+ if (this._killSwitch()) return;
const cfg = db.getChannelAiConfig(stream.user_id);
const cfg = db.getChannelAiConfig(stream.user_id);
if (!cfg.enabled) return;
if (!cfg.enabled) return;
-
- // Full row for capture (needs protocol/managed_stream_key).
+ const settings = settingsMod.getSettings(stream.user_id, cfg);
let full = stream;
let full = stream;
try { full = db.getStreamById(stream.id) || stream; } catch { /* */ }
try { full = db.getStreamById(stream.id) || stream; } catch { /* */ }
-
- const target = Math.max(0, Math.min(12, cfg.num_ambient_bots || 0));
- const bots = roster.ensureRoster(stream.user_id, target);
- if (!bots.length) return; // nothing to say with
+ if (full.managed_stream_id && settings.slots && settings.slots[String(full.managed_stream_id)] === false) {
+ console.log(`[AI-Viewers] slot ${full.managed_stream_id} has AI viewers off — not starting for stream ${stream.id}`);
+ return;
+ }
+ const bots = roster.ensureRoster(stream.user_id, settings.roster_size);
+ if (!bots.length) return;
const worker = {
const worker = {
- streamId: stream.id,
- userId: stream.user_id,
- stream: full,
- config: cfg,
- bots,
- recentChat: [],
- situationLine: '',
- lastPostAt: 0,
- lastPoster: null,
- nextGapMs: rint(IDLE_GAP_MS[0], IDLE_GAP_MS[1]),
- lastRealInputAt: 0,
- intents: [], // [{prio, botId, target, addressed}]
- botLines: new Map(), // botId → recent lines (for brain fold)
- generating: false,
- stopped: false,
- paceTimer: null, audioTimer: null, visionTimer: null, brainTimer: null,
+ streamId: stream.id, userId: stream.user_id, stream: full, cfg, settings, bots,
+ stable: null, // cached stable prefix {text,hash,at}
+ lastChatId: db.getMaxChatMessageIdForChannel(stream.user_id),
+ lastTickAt: 0, nextTickAt: 0, tickTimer: null, foldTimer: null, ticking: false,
+ lastRealInputAt: 0, lastStreamerReplyAt: 0, lastSkipLogAt: 0, lastVolatileHash: null,
+ intents: [], // [{ kind:'streamer'|'mention'|'sound'|'scene', text, bots:[], at }]
+ botLines: new Map(), // botId → recent lines (memory fold)
+ recentLines: [], // normalized last 30 bot lines (dedupe)
+ lastSeenMemoryId: null, lastPeriodicVisionAt: 0,
+ paused: !!(settings.runtime && settings.runtime.paused),
+ mode: 'normal',
+ stats: { ticks: 0, lines: 0, skips: 0, cost: 0 },
+ startedAt: Date.now(), stopped: false,
};
};
- context.refreshShared(worker);
+ worker.scheduler = new Scheduler(worker, {
+ post: (line) => this._post(worker, line),
+ dropped: (line, why) => poster.log(worker, { event: 'skip', bot_username: line.bot, text: line.text, reason: why }),
+ });
this.workers.set(stream.id, worker);
this.workers.set(stream.id, worker);
-
- worker.paceTimer = setTimeout(() => this._tick(worker), TICK_MS);
- if (cfg.transcribe_enabled) worker.audioTimer = setInterval(() => this._captureAudio(worker), SITUATION_AUDIO_MS);
- if (cfg.vision_enabled) worker.visionTimer = setInterval(() => this._captureVision(worker), SITUATION_VISION_MS);
- worker.brainTimer = setInterval(() => this._foldBrains(worker), BRAIN_FOLD_MS);
-
- console.log(`[AI-Viewers] Started for stream ${stream.id} (streamer ${stream.user_id}, ${bots.length} bots)`);
- } catch (e) {
- console.warn('[AI-Viewers] startForStream failed:', e.message);
- }
+ poster.log(worker, { event: 'info', reason: `engine v3 started — ${bots.length} viewer(s), ${settings.activity} activity, director every ${settings.director_interval_sec}s` });
+ console.log(`[AI-Viewers v3] started for stream ${stream.id} (user ${stream.user_id}, ${bots.length} bots)`);
+ this._scheduleTick(worker, 4000);
+ worker.foldTimer = setInterval(() => this._fold(worker).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000);
+ } catch (e) { console.warn('[AI-Viewers v3] start failed:', e.message); }
}
}
stopForStream(streamId) {
stopForStream(streamId) {
- const worker = this.workers.get(streamId);
- if (!worker) return;
- worker.stopped = true;
- clearTimeout(worker.paceTimer);
- clearInterval(worker.audioTimer);
- clearInterval(worker.visionTimer);
- clearInterval(worker.brainTimer);
+ const w = this.workers.get(streamId);
+ if (!w) return;
+ w.stopped = true;
+ if (w.tickTimer) clearTimeout(w.tickTimer);
+ if (w.foldTimer) clearInterval(w.foldTimer);
+ w.scheduler.stop();
this.workers.delete(streamId);
this.workers.delete(streamId);
- console.log(`[AI-Viewers] Stopped for stream ${streamId}`);
- }
-
- stopForUser(userId) {
- for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid);
+ try { db.closeAllAiViewerThreads(w.userId); } catch { /* */ }
+ this._fold(w).catch(() => {});
+ poster.log(w, { event: 'info', reason: `stopped — ${w.stats.lines} line(s), ${w.stats.ticks} pass(es), $${w.stats.cost.toFixed(4)}` });
+ console.log(`[AI-Viewers v3] stopped for stream ${streamId}`);
}
}
+ stopForUser(userId) { for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid); }
- // Hot-apply config changes to any live streams for this user.
+ /** Hot-apply config: reload settings/roster on live workers; start/stop as needed. */
applyConfigForUser(userId) {
applyConfigForUser(userId) {
- for (const [, w] of this.workers) {
+ const cfg = db.getChannelAiConfig(userId);
+ const settings = settingsMod.getSettings(userId, cfg);
+ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();
+ for (const [sid, w] of this.workers) {
if (w.userId !== userId) continue;
if (w.userId !== userId) continue;
- const cfg = db.getChannelAiConfig(userId);
- w.config = cfg;
- if (!cfg.enabled) { this.stopForStream(w.streamId); continue; }
- const target = Math.max(0, Math.min(12, cfg.num_ambient_bots || 0));
- w.bots = roster.ensureRoster(userId, target);
- // Re-arm situation timers to match toggles.
- clearInterval(w.audioTimer); clearInterval(w.visionTimer);
- w.audioTimer = cfg.transcribe_enabled ? setInterval(() => this._captureAudio(w), SITUATION_AUDIO_MS) : null;
- w.visionTimer = cfg.vision_enabled ? setInterval(() => this._captureVision(w), SITUATION_VISION_MS) : null;
+ if (!cfg.enabled || this._killSwitch() || (w.stream.managed_stream_id && settings.slots && settings.slots[String(w.stream.managed_stream_id)] === false)) { this.stopForStream(sid); continue; }
+ w.cfg = cfg; w.settings = settings; w.paused = !!(settings.runtime && settings.runtime.paused);
+ try { w.bots = roster.ensureRoster(userId, settings.roster_size); } catch { /* */ }
+ w.stable = null; // prefix inputs may have changed
+ if (w.foldTimer) { clearInterval(w.foldTimer); w.foldTimer = setInterval(() => this._fold(w).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000); }
+ poster.log(w, { event: 'info', reason: 'settings applied' });
}
}
- // If enabled but not yet running for a live stream, the next go-live picks it up.
+ if (cfg.enabled && !this._killSwitch()) for (const s of live) if (!this.workers.has(s.id)) this.startForStream(s).catch(() => {});
}
}
reloadForUser(userId) { return this.applyConfigForUser(userId); }
reloadForUser(userId) { return this.applyConfigForUser(userId); }
- // ── Reactive: real chat / streamer input ──────────────────
- onRealChatMessage(streamId, { username, message, userId }) {
- const worker = this.workers.get(streamId);
- if (!worker || worker.stopped) return;
- // Ignore our own bots (defensive; bots don't route through here).
- if (worker.bots.some(b => (b.username || '').toLowerCase() === (username || '').toLowerCase())) return;
-
- worker.recentChat.push({ username, message, userId, ts: Date.now() });
- if (worker.recentChat.length > RECENT_CHAT_MAX) worker.recentChat.shift();
- worker.lastRealInputAt = Date.now();
-
- const isStreamer = userId && userId === worker.userId;
- const addressedChat = isStreamer || /\b(chat|guys|everyone|yall|y'all)\b/i.test(message || '');
-
- // Decide whether a bot replies to this specific viewer.
- if (!isStreamer && Math.random() < REPLY_PROB) {
- const bot = this._pickBot(worker);
- if (bot) {
- worker.intents.push({
- prio: 2, botId: bot.id,
- target: { kind: userId ? 'user' : 'relay', userId: userId || null, relayId: null, username, message },
- addressed: false,
- });
- }
- } else if (addressedChat) {
- const bot = this._pickBot(worker);
- if (bot) worker.intents.push({ prio: isStreamer ? 3 : 1, botId: bot.id, target: null, addressed: true });
+ // ── Chat events (native, relayed, anon — everything public) ─
+ onRealChatMessage(streamId, ev) { return this.onChatEvent(streamId, ev); }
+ onChatEvent(streamId, ev = {}) {
+ const w = this.workers.get(streamId);
+ if (!w || w.stopped) return;
+ const uname = String(ev.username || '');
+ if (ev.isBot || w.bots.some(b => b.username.toLowerCase() === uname.toLowerCase())) return; // never react to our own lines
+ if (ev.platform && !w.settings.reply_to_relay_chat) return;
+ const isStreamer = !!ev.isStreamer || (ev.userId && ev.userId === w.userId);
+ const text = String(ev.message || '');
+ // Streamer/mod shortcut: "!ai pause" etc. also works from relayed chat for the streamer.
+ if (/^!ai\b/i.test(text) && (isStreamer || ev.isMod)) {
+ try { this.onModCommand(w.userId, streamId, text.split(/\s+/).slice(1), { by: uname }); } catch { /* */ }
+ return;
}
}
- // Keep the intent queue small.
- if (worker.intents.length > 6) worker.intents = worker.intents.slice(-6);
- }
-
- // ── Pacer ─────────────────────────────────────────────────
- _tick(worker) {
- if (worker.stopped) return;
- worker.paceTimer = setTimeout(() => this._tick(worker), TICK_MS);
- if (worker.generating) return;
-
- const now = Date.now();
- const gap = now - worker.lastPostAt;
- if (gap < GLOBAL_MIN_GAP_MS) return;
-
- const active = (now - worker.lastRealInputAt) < ACTIVE_WINDOW_MS;
- if (gap < worker.nextGapMs) {
- // Not yet due for ambient — but a queued reactive intent can jump the gap.
- if (!worker.intents.length) return;
+ w.lastRealInputAt = Date.now();
+ const det = mention.detect(text, w.bots, { isStreamer });
+ if (isStreamer && (det.addressedChat || det.bots.length)) {
+ this._pushIntent(w, { kind: 'streamer', text: `The streamer (${uname}) said: "${clip(text, 220)}"${det.bots.length ? ` — addressed to ${det.bots.join(', ')}` : ' — addressed to chat'}. A reply is REQUIRED.`, bots: det.bots, line: text, at: Date.now(), msgId: ev.msgId || null });
+ this._fastPath(w, { line: text, bots: det.bots, replyTo: uname, msgId: ev.msgId || null }).catch(() => {});
+ return;
}
}
-
- // Pick the work: highest-priority queued intent, else maybe ambient filler.
- let intent = null;
- if (worker.intents.length) {
- worker.intents.sort((a, b) => b.prio - a.prio);
- intent = worker.intents.shift();
- } else {
- // Ambient filler only when due and (probabilistically) when active/idle.
- if (gap < worker.nextGapMs) return;
- const prob = active ? 0.7 : 0.4;
- if (Math.random() > prob) { worker.nextGapMs = this._nextGap(active); worker.lastPostAt = now; return; }
- const bot = this._pickBot(worker);
- if (!bot) return;
- intent = { botId: bot.id, target: null, addressed: false };
+ if (det.bots.length) {
+ this._pushIntent(w, { kind: 'mention', text: `${uname} addressed ${det.bots.join(', ')}: "${clip(text, 200)}" — that viewer should be answered.`, bots: det.bots, at: Date.now(), replyTo: uname });
+ this._scheduleTick(w, 2500);
+ return;
}
}
-
- this._emit(worker, intent).catch(e => console.warn('[AI-Viewers] emit failed:', e.message));
+ if (isStreamer) { this._scheduleTick(w, Math.min(6000, w.settings.director_interval_sec * 1000)); return; }
+ // Regular viewer message: the director decides; reply probability trims how often
+ // we even suggest it, and a burst of new chat pulls the next pass closer.
+ if (Math.random() < (w.settings.reply_probability == null ? 0.6 : w.settings.reply_probability)) {
+ this._pushIntent(w, { kind: 'viewer', text: `${uname} said: "${clip(text, 200)}" — consider replying to them.`, bots: [], at: Date.now(), replyTo: uname, soft: true });
+ }
+ if (Date.now() - w.lastTickAt > 8000) this._scheduleTick(w, 6000);
+ }
+ _pushIntent(w, intent) {
+ w.intents.push(intent);
+ // Hard intents first; keep the queue short so a burst can't stack ten replies.
+ w.intents.sort((a, b) => (a.soft ? 1 : 0) - (b.soft ? 1 : 0) || a.at - b.at);
+ if (w.intents.length > 6) w.intents = w.intents.slice(0, 6);
}
}
- _nextGap(active) {
- const r = active ? ACTIVE_GAP_MS : IDLE_GAP_MS;
- return rint(r[0], r[1]);
+ // ── Mod commands: /ai … or !ai … ─────────────────────────
+ onModCommand(channelUserId, streamId, args = [], { by = null } = {}) {
+ const [cmd, ...rest] = (args || []).map(a => String(a || '').trim()).filter(Boolean);
+ const w = this.workerForUser(channelUserId);
+ const settings = settingsMod.getSettings(channelUserId);
+ const runtime = { paused: !!(settings.runtime && settings.runtime.paused), muted: (settings.runtime && settings.runtime.muted) || [] };
+ const save = () => { settingsMod.updateSettings(channelUserId, { runtime }); if (w) { w.settings = settingsMod.getSettings(channelUserId); w.paused = runtime.paused; w.stable = null; } };
+ switch ((cmd || 'status').toLowerCase()) {
+ case 'pause': runtime.paused = true; save(); if (w) { w.scheduler.clear(); poster.log(w, { event: 'pause', reason: `paused by ${by || 'mod'}` }); } return 'AI viewers paused.';
+ case 'resume': runtime.paused = false; save(); if (w) { poster.log(w, { event: 'pause', reason: `resumed by ${by || 'mod'}` }); this._scheduleTick(w, 3000); } return 'AI viewers resumed.';
+ case 'mute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); if (!n) return 'Usage: /ai mute <viewer>'; if (!runtime.muted.includes(n)) runtime.muted.push(n); save(); return `Muted ${n}.`; }
+ case 'unmute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); runtime.muted = runtime.muted.filter(x => x !== n); save(); return `Unmuted ${n}.`; }
+ case 'nudge': if (w) { this._scheduleTick(w, 500); return 'Nudged the director.'; } return 'AI viewers are not running right now.';
+ default: {
+ if (!w) return `AI viewers: ${runtime.paused ? 'paused' : 'not running for a live stream'}${runtime.muted.length ? `, muted: ${runtime.muted.join(', ')}` : ''}.`;
+ return `AI viewers: ${w.paused ? 'paused' : 'running'} · ${w.bots.length} viewers · mode ${w.mode} · ${w.stats.lines} lines this stream${runtime.muted.length ? ` · muted: ${runtime.muted.join(', ')}` : ''}. Commands: pause, resume, mute <name>, unmute <name>, nudge.`;
+ }
+ }
}
}
- // Pick a bot, avoiding the last one that posted (anti-domination).
- _pickBot(worker) {
- const pool = worker.bots.filter(b => b.id !== worker.lastPoster);
- const list = pool.length ? pool : worker.bots;
- if (!list.length) return null;
- return list[rint(0, list.length - 1)];
+ // ── Director loop ────────────────────────────────────────
+ _scheduleTick(w, delayMs) {
+ if (w.stopped) return;
+ const at = Date.now() + Math.max(250, delayMs);
+ if (w.tickTimer && w.nextTickAt <= at) return; // an earlier tick is already armed
+ if (w.tickTimer) clearTimeout(w.tickTimer);
+ w.nextTickAt = at;
+ w.tickTimer = setTimeout(() => { w.tickTimer = null; this._tick(w).catch(e => console.warn('[AI-Viewers v3] tick error:', e.message)); }, at - Date.now());
}
}
+ _cadenceMs(w) {
+ const s = w.settings;
+ const active = (Date.now() - w.lastRealInputAt) < ACTIVE_WINDOW_MS;
+ let ms = (active ? s.director_interval_sec : s.idle_interval_sec) * 1000;
+ if (w.mode === 'economy') ms *= 1.5;
+ if (w.mode === 'replies_only') ms *= 2;
+ return ms;
+ }
+ _providerFor(w) { return w.cfg.use_shared_key ? null : budget.byoProvider(w.cfg); }
- async _emit(worker, intent) {
- const bot = worker.bots.find(b => b.id === intent.botId) || this._pickBot(worker);
- if (!bot) return;
- worker.generating = true;
+ async _tick(w) {
+ if (w.stopped || w.ticking) return;
+ w.ticking = true;
+ const s = w.settings;
try {
try {
- const { system, user } = context.buildBotContext(worker, bot, {
- targetUser: intent.target || null,
- addressed: !!intent.addressed,
+ if (this._killSwitch()) { this.stopForStream(w.streamId); return; }
+ try { w.stream = db.getStreamById(w.streamId) || w.stream; } catch { /* */ }
+ if (!w.stream || !w.stream.is_live) { this.stopForStream(w.streamId); return; }
+ const hardIntents = w.intents.filter(i => !i.soft);
+ const st = budget.status(w.userId);
+ w.mode = st.mode;
+ const quiet = settingsMod.isQuietNow(s);
+ if (w.paused || (quiet && !(s.quiet_allow_replies && hardIntents.length)) || st.mode === 'silent' || !st.active) {
+ w.intents = [];
+ this._logSkip(w, w.paused ? 'paused' : quiet ? 'quiet hours' : (st.reason || st.mode));
+ return;
+ }
+ if (st.mode === 'streamer_only' && !hardIntents.some(i => i.kind === 'streamer')) { w.intents = []; this._logSkip(w, 'budget: streamer replies only'); return; }
+ if (st.mode === 'replies_only') w.intents = w.intents.filter(i => !i.soft ? true : i.kind === 'viewer');
+
+ // Vision: look at the screen through the shared memory pipeline when it is stale.
+ await this._maybeVision(w, hardIntents.length > 0);
+
+ const bots = w.bots.filter(b => !((s.runtime && s.runtime.muted) || []).includes(b.username.toLowerCase()));
+ if (!bots.length) { this._logSkip(w, 'all viewers muted'); return; }
+ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots, settings: s, cacheHolder: w });
+ const linesAllowed = Math.max(1, Math.min(s.lines_per_tick, st.mode === 'economy' ? s.lines_per_tick - 1 : s.lines_per_tick));
+ const tail = context.volatileTail({
+ userId: w.userId, stream: w.stream, settings: s, sinceChatId: w.lastChatId,
+ botNames: new Set(w.bots.map(b => b.username.toLowerCase())),
+ intents: w.intents.slice(0, 5), mode: st.mode, linesAllowed, botShare: w.scheduler.botShare(),
+ });
+ const awaiting = tail.threads.some(t => t.awaiting);
+ const sceneChanged = tail.delta.memoryId && tail.delta.memoryId !== w.lastSeenMemoryId && s.react_to_scene_changes;
+ const sound = s.react_to_sounds && tail.delta.latestSound && (Date.now() - w.lastTickAt) < 120000 ? tail.delta.latestSound : null;
+ const changed = tail.hash !== w.lastVolatileHash;
+ if (!changed && !w.intents.length && !awaiting && !sceneChanged) { this._logSkip(w, 'nothing new'); return; }
+ if (!tail.delta.newChat && !tail.delta.speechLines && !w.intents.length && !awaiting && !sceneChanged && w.scheduler.pending()) { this._logSkip(w, 'lines still queued'); return; }
+ w.lastVolatileHash = tail.hash;
+ if (sceneChanged) w.lastSeenMemoryId = tail.delta.memoryId;
+ let volatileText = tail.text;
+ if (sceneChanged && !w.intents.length) volatileText += '\n\nNote: what is on screen just changed — an ambient reaction is welcome.';
+ if (sound) volatileText += `\n\nNote: a "${sound.label}" sound was just detected on stream.`;
+
+ const consumed = w.intents.splice(0, w.intents.length);
+ const res = await director.plan({
+ stableText: stable.text, volatileText, maxLines: linesAllowed,
+ provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`,
});
});
- const text = await budget.generate(worker.userId, {
- system, user,
- maxTokens: 60,
- temperature: intent.target ? 0.9 : 1.05,
+ w.lastTickAt = Date.now();
+ w.lastChatId = Math.max(w.lastChatId, tail.chatMaxId || 0);
+ w.stats.ticks++;
+ if (!res) { poster.log(w, { event: 'error', reason: 'director call failed or provider quiet' }); w.intents.unshift(...consumed.filter(i => !i.soft)); return; }
+ w.stats.cost += res.cost || 0;
+ const accepted = this._acceptPlan(w, res.plan, bots, consumed);
+ poster.log(w, {
+ event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),
+ text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,
+ tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,
});
});
- if (!text) return; // quiet: over budget / disabled / error
- const clean = this._clean(text);
- if (!clean) return;
- this._inject(worker, bot, clean);
+ for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { db.closeAiViewerThread(n); } catch { /* */ } } }
+ try { db.closeStaleAiViewerThreads(w.userId, s.thread_idle_close_sec, s.max_thread_turns); } catch { /* */ }
} finally {
} finally {
- worker.generating = false;
+ w.ticking = false;
+ if (!w.stopped) this._scheduleTick(w, this._cadenceMs(w));
}
}
}
}
- _clean(text) {
- let t = (text || '').trim();
- t = t.replace(/^["'`]+|["'`]+$/g, ''); // strip wrapping quotes
- t = t.replace(/\s+/g, ' ');
- if (t.length > 240) t = t.slice(0, 240);
- // Drop obvious AI disclaimers if the model slipped.
- if (/\b(as an ai|language model|i am a bot|i'm a bot)\b/i.test(t)) return '';
- return t.trim();
+ _logSkip(w, reason) {
+ w.stats.skips++;
+ if (Date.now() - w.lastSkipLogAt > SKIP_LOG_EVERY_MS) { w.lastSkipLogAt = Date.now(); poster.log(w, { event: 'skip', reason }); }
}
}
- // ── Posting ───────────────────────────────────────────────
- _inject(worker, bot, message) {
- const streamId = worker.streamId;
- let color = '#8a8aff';
- try { color = (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || color; } catch { color = bot.avatar_color || color; }
-
- let id = null;
- try {
- const res = db.saveChatMessage({
- stream_id: streamId,
- channel_user_id: worker.userId,
- user_id: null,
- username: bot.username,
- message,
- message_type: 'chat',
- is_global: false,
- source_platform: 'ai', // marker → excluded from chat-AI feeds
- metadata: { bot: 1, bot_id: bot.id, source: bot.source },
- });
- id = res && res.lastInsertRowid;
- } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }
-
- const chatMsg = {
- type: 'chat', id: id || undefined,
- username: bot.username, core_username: null,
- user_id: null, anon_id: null, role: 'user',
- message, stream_id: streamId, is_global: false,
- avatar_url: null, profile_color: color,
- is_ai: true, source_platform: 'ai',
- filtered: false, timestamp: new Date().toISOString(),
- };
- try {
- const chatServer = require('../../chat/chat-server');
- chatServer.broadcastToStream(streamId, chatMsg);
- chatServer.forwardToGlobal(streamId, chatMsg);
- chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);
- } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }
-
- // ...and into the streamer's PowerChat unified overlay. AI viewers inject here
- // directly (never through handleChatMessage where the normal relay lives), so
- // they were invisible on PowerChat while every human chatter showed up.
- try {
- const pc = require('../../integrations/powerchat-platform');
- if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {
- pc.forwardChat(worker.userId, {
- chatterName: bot.username,
- externalChatterId: `ai:${bot.username.toLowerCase()}`,
- message,
- messageId: id ? `ov-${id}` : undefined,
- avatarFallback: '🤖',
- });
+ /** Validate + enqueue a plan. Returns the number of accepted lines. */
+ _acceptPlan(w, plan, bots, consumed) {
+ if (!plan || plan.skip || !plan.lines.length) return 0;
+ const s = w.settings;
+ const byName = new Map(bots.map(b => [b.username.toLowerCase(), b]));
+ const streamerIntent = consumed.find(i => i.kind === 'streamer');
+ const out = [];
+ let botTargets = 0;
+ let lastBot = null;
+ for (const raw of plan.lines) {
+ const bot = byName.get(String(raw.bot || '').toLowerCase());
+ if (!bot) continue;
+ if (lastBot === bot.username) continue;
+ let text = poster.clean(raw.text, s.max_words);
+ if (!text) continue;
+ const n = norm(text);
+ if (w.recentLines.includes(n)) continue;
+ const mod = poster.moderate(text);
+ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); continue; }
+ const target = ['viewer', 'bot', 'streamer', 'ambient'].includes(raw.target) ? raw.target : 'ambient';
+ if (target === 'bot') {
+ const maxBot = Math.floor((s.bot_to_bot_ratio || 0) * Math.max(1, plan.lines.length) + 0.5);
+ if (botTargets >= Math.max(0, maxBot) && (s.bot_to_bot_ratio || 0) < 0.79) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: 'bot-to-bot ratio' }); continue; }
+ botTargets++;
}
}
- } catch { /* non-critical */ }
+ // Threads
+ let threadId = null;
+ try {
+ if (raw.thread === 'new') {
+ const kind = target === 'bot' ? 'bot_bot' : target === 'streamer' ? 'bot_streamer' : 'bot_viewer';
+ const open = db.getOpenAiViewerThreads(w.userId, 10);
+ if (open.length < (s.max_open_threads || 3)) {
+ const t = db.createAiViewerThread({ channel_user_id: w.userId, stream_id: w.streamId, kind, participants: [bot.username, raw.reply_to].filter(Boolean), topic: raw.topic ? clip(raw.topic, 80) : null, awaiting: target === 'bot' ? raw.reply_to : null });
+ threadId = t && t.id;
+ }
+ } else if (raw.thread && /^\d+$/.test(String(raw.thread))) threadId = parseInt(raw.thread, 10);
+ } catch { threadId = null; }
+ lastBot = bot.username;
+ w.recentLines.push(n); if (w.recentLines.length > 30) w.recentLines.shift();
+ out.push({ bot: bot.username, botRow: bot, text, target, replyTo: raw.reply_to || null, threadId, delay_ms: Math.max(0, Math.min(45000, parseInt(raw.delay_ms, 10) || 0)), reason: clip(raw.reason, 120), replyToId: (target === 'streamer' && streamerIntent) ? streamerIntent.msgId : null });
+ }
+ if (out.length) w.scheduler.enqueue(out);
+ return out.length;
+ }
- worker.lastPostAt = Date.now();
- worker.lastPoster = bot.id;
- worker.nextGapMs = this._nextGap((Date.now() - worker.lastRealInputAt) < ACTIVE_WINDOW_MS);
- try { db.touchChannelAiBot(bot.id); } catch { /* */ }
- // Buffer this line for the bot's periodic brain fold.
- const buf = worker.botLines.get(bot.id) || [];
- buf.push(message);
- if (buf.length > 24) buf.shift();
- worker.botLines.set(bot.id, buf);
- // Also let bots see each other in chat.
- worker.recentChat.push({ username: bot.username, message, ts: Date.now() });
- if (worker.recentChat.length > RECENT_CHAT_MAX) worker.recentChat.shift();
+ _post(w, line) {
+ if (w.stopped) return;
+ const bot = line.botRow || w.bots.find(b => b.username === line.bot);
+ if (!bot) return;
+ const id = poster.post(w, bot, line.text, { threadId: line.threadId, replyToId: line.replyToId });
+ w.stats.lines++;
+ if (line.target === 'streamer') w.lastStreamerReplyAt = Date.now();
+ poster.log(w, { event: 'line', bot_username: bot.username, target: line.target + (line.replyTo ? `:${line.replyTo}` : ''), thread_id: line.threadId || null, chat_message_id: id, text: line.text, reason: line.reason });
+ const buf = w.botLines.get(bot.id) || []; buf.push(line.text); if (buf.length > 24) buf.shift(); w.botLines.set(bot.id, buf);
+ if (line.threadId) {
+ try { db.touchAiViewerThread(line.threadId, { line: line.text, by: bot.username, awaiting: line.target === 'bot' ? (line.replyTo || null) : null }); } catch { /* */ }
+ // A bot↔bot thread waiting on the other bot pulls the next pass closer.
+ if (line.target === 'bot') this._scheduleTick(w, Math.min(this._cadenceMs(w), 12000));
+ }
}
}
- // ── Situation: what's happening on stream ─────────────────
- async _captureAudio(worker) {
- if (worker.stopped) return;
- let ai, transcribe;
- try { ai = require('../ai-analysis'); transcribe = require('../transcribe'); } catch { return; }
- if (!ai.transcriptionEnabled || !ai.transcriptionEnabled()) return;
- let wav = null;
- try {
- const streamAudio = require('../stream-audio');
- wav = await streamAudio.captureAudioChunk(worker.stream, 12);
- if (!wav) return;
- const res = await transcribe.transcribeWavDetailed(wav);
- const text = res && res.ok ? (res.text || '').trim() : '';
- if (text) this._mergeSituation(worker, `heard: "${text.slice(0, 240)}"`);
- } catch (e) { /* capture is best-effort */ }
- finally { if (wav) { try { require('fs').unlink(wav, () => {}); } catch { /* */ } } }
+ // ── Streamer fast path ───────────────────────────────────
+ async _fastPath(w, { line, bots: named, replyTo, msgId }) {
+ const s = w.settings;
+ const waitMs = Math.max(0, (s.mention_fast_path_sec || 0) * 1000);
+ const nextIn = w.nextTickAt ? w.nextTickAt - Date.now() : Infinity;
+ if (nextIn <= waitMs + 1500) { this._scheduleTick(w, Math.min(nextIn, 1500)); return; } // the director will handle it in time
+ const st = budget.status(w.userId);
+ if (!st.active || st.mode === 'silent' || w.paused) return;
+ const muted = new Set(((s.runtime && s.runtime.muted) || []).map(x => String(x).toLowerCase()));
+ const pool = w.bots.filter(b => !muted.has(b.username.toLowerCase()));
+ if (!pool.length) return;
+ const bot = (named.length && pool.find(b => b.username === named[0])) || pool[Math.floor(Math.random() * pool.length)];
+ await this._maybeVision(w, true);
+ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: pool, settings: s, cacheHolder: w });
+ const heard = context.heardBlock(w.stream, s);
+ const seen = context.seenBlock(w.stream);
+ const situation = [
+ heard.text ? `Heard on stream recently:\n${heard.text.split('\n').slice(-6).join('\n')}` : '',
+ seen.text ? `On screen (${context.ago(seen.ageMs)}): ${seen.text}` : '',
+ ].filter(Boolean).join('\n\n');
+ const r = await director.quickReply({ stableText: stable.text, situationText: situation, bot, streamerLine: clip(line, 220), maxWords: s.max_words, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}` });
+ if (!r || !r.text) return;
+ const text = poster.clean(r.text, s.max_words);
+ const mod = text ? poster.moderate(text) : { ok: false, reason: 'empty' };
+ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); return; }
+ w.stats.cost += r.cost || 0;
+ w.intents = w.intents.filter(i => i.kind !== 'streamer'); // handled — the next pass must not double-reply
+ w.recentLines.push(norm(text)); if (w.recentLines.length > 30) w.recentLines.shift();
+ poster.log(w, { event: 'mention', bot_username: bot.username, target: `streamer:${replyTo}`, text, reason: 'fast path — streamer addressed chat', tokens_in: r.usage.input, tokens_cached: r.usage.cached, tokens_out: r.usage.output, cost_usd: r.cost, model: r.model });
+ w.scheduler.enqueue([{ bot: bot.username, botRow: bot, text, target: 'streamer', replyTo, threadId: null, delay_ms: 0, reason: 'answering the streamer', replyToId: msgId || null }], { basePrio: 3 });
}
}
- async _captureVision(worker) {
- if (worker.stopped) return;
+ // ── Vision policy ────────────────────────────────────────
+ async _maybeVision(w, addressed) {
+ const s = w.settings;
+ if (s.vision_policy === 'off') return;
+ const seen = context.seenBlock(w.stream);
+ const stale = seen.ageMs == null || seen.ageMs > (s.vision_max_age_sec || 180) * 1000;
+ const periodicDue = s.vision_policy === 'periodic' && (Date.now() - w.lastPeriodicVisionAt) > (s.vision_periodic_sec || 300) * 1000 && (Date.now() - w.lastRealInputAt) < 5 * 60000;
+ if (!stale && !periodicDue) return;
+ const allowFfmpeg = s.vision_policy !== 'thumbnail';
+ if (addressed || periodicDue) {
+ if (periodicDue) w.lastPeriodicVisionAt = Date.now();
+ const p = context.wantFreshFrame(w.stream, { maxAgeSec: s.vision_max_age_sec, allowFfmpeg });
+ // Addressed: wait up to 12s so the answer can reference the screen; otherwise fire and forget.
+ if (addressed) { const t = new Promise(res => setTimeout(() => res(false), 12000)); const started = await Promise.race([p, t]); if (started) poster.log(w, { event: 'vision', reason: 'fresh frame analyzed (shared memory pipeline)' }); }
+ else p.then(ok => { if (ok) poster.log(w, { event: 'vision', reason: 'periodic frame analyzed' }); }).catch(() => {});
+ }
+ }
+
+ // ── Memory fold ──────────────────────────────────────────
+ async _fold(w) {
try {
try {
- const streamVision = require('../stream-vision');
- const frame = await streamVision.captureFrame(worker.stream);
- if (!frame) return;
- const desc = await budget.generate(worker.userId, {
- system: 'You describe a single video frame from a live stream in one short vivid sentence.',
- user: 'What is on screen right now?',
- image: frame, maxTokens: 60, temperature: 0.4,
- });
- if (desc && desc.trim()) this._mergeSituation(worker, `seen: ${desc.trim().slice(0, 200)}`);
- } catch (e) { /* best-effort */ }
+ const r = await fold.foldAll(w, { provider: this._providerFor(w) });
+ if (r) { w.stats.cost += r.cost || 0; poster.log(w, { event: 'fold', reason: `memory updated for ${r.updated} viewer(s)`, tokens_in: r.usage && r.usage.input, tokens_cached: r.usage && r.usage.cached, tokens_out: r.usage && r.usage.output, cost_usd: r.cost, model: r.model });
+ for (let i = 0; i < w.bots.length; i++) { const fresh = db.getChannelAiBot(w.bots[i].id); if (fresh) w.bots[i] = fresh; }
+ w.stable = null;
+ }
+ } catch (e) { poster.log(w, { event: 'error', reason: `fold: ${e.message}` }); }
}
}
- _mergeSituation(worker, line) {
- // Keep the most recent heard/seen fragments as the current situation.
- const parts = (worker.situationLine || '').split(' | ').filter(Boolean);
- const kind = line.split(':')[0];
- const kept = parts.filter(p => !p.startsWith(kind));
- kept.push(line);
- worker.situationLine = kept.slice(-2).join(' | ');
- context.refreshShared(worker); // refresh streamer/global insight alongside
+ // ── Status / preview (control panel) ─────────────────────
+ status(userId) {
+ const w = this.workerForUser(userId);
+ const st = budget.status(userId);
+ const timelineOn = (() => { try { return require('../timeline-job').timelineEnabled(); } catch { return false; } })();
+ const base = { engine: 'v3', running: !!w, mode: st.mode, budget_active: st.active, budget_reason: st.reason, spent_today_usd: st.spentToday, cap_usd: st.capUsd, timeline_enabled: timelineOn, kill_switch: this._killSwitch() };
+ if (!w) return base;
+ const seen = context.seenBlock(w.stream);
+ const heard = context.heardBlock(w.stream, w.settings);
+ return { ...base, stream_id: w.streamId, paused: w.paused, bots: w.bots.length, stats: w.stats, last_tick_at: w.lastTickAt || null, next_tick_in_ms: w.nextTickAt ? Math.max(0, w.nextTickAt - Date.now()) : null,
+ queued: w.scheduler.pending(), bot_share: w.scheduler.botShare(), lines_last_minute: w.scheduler.linesLastMinute(),
+ senses: { heard_age_ms: heard.ageMs, heard_lines: heard.count, seen_age_ms: seen.ageMs, seen_text: seen.text, stable_prefix_chars: w.stable ? w.stable.text.length : null, stable_cached_since: w.stable ? w.stable.at : null } };
}
}
- async _foldBrains(worker) {
- if (worker.stopped) return;
- for (const bot of worker.bots) {
- const lines = worker.botLines.get(bot.id);
- if (!lines || lines.length < 3) continue;
- try {
- const fresh = db.getChannelAiBot(bot.id) || bot;
- const ok = await brain.foldBrain(worker.userId, fresh, lines);
- if (ok) { worker.botLines.set(bot.id, []); /* refresh cached row */
- const updated = db.getChannelAiBot(bot.id);
- const idx = worker.bots.findIndex(b => b.id === bot.id);
- if (updated && idx >= 0) worker.bots[idx] = updated;
- }
- } catch { /* */ }
- if (worker.stopped) return;
+ /** Dry run: assemble the real context and ask the director what it would do (nothing posted). */
+ async preview(userId) {
+ let w = this.workerForUser(userId);
+ let temp = false;
+ if (!w) {
+ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();
+ if (!live.length) return { error: 'Go live first — the preview runs on your real stream context.' };
+ const cfg = db.getChannelAiConfig(userId); const settings = settingsMod.getSettings(userId, cfg);
+ const bots = roster.ensureRoster(userId, settings.roster_size || 3);
+ w = { streamId: live[0].id, userId, stream: db.getStreamById(live[0].id) || live[0], cfg, settings, bots, lastChatId: Math.max(0, db.getMaxChatMessageIdForChannel(userId) - 40), intents: [], scheduler: { botShare: () => 0 } };
+ temp = true;
}
}
+ const s = w.settings;
+ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: w.bots, settings: s, cacheHolder: temp ? null : w });
+ const tail = context.volatileTail({ userId: w.userId, stream: w.stream, settings: s, sinceChatId: Math.max(0, (w.lastChatId || 0) - 25), botNames: new Set(w.bots.map(b => b.username.toLowerCase())), intents: w.intents, mode: 'normal', linesAllowed: s.lines_per_tick, botShare: w.scheduler.botShare() });
+ const res = await director.plan({ stableText: stable.text, volatileText: tail.text, maxLines: s.lines_per_tick, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`, temperature: 0.9 });
+ return { plan: res ? res.plan : null, usage: res ? res.usage : null, cost: res ? res.cost : null, model: res ? res.model : null, context: { stable_chars: stable.text.length, volatile_chars: tail.text.length, sources: tail.sources }, stable_preview: stable.text.slice(0, 1200), volatile_preview: tail.text.slice(0, 1500) };
}
}
+
+ nudge(userId) { const w = this.workerForUser(userId); if (!w) return false; this._scheduleTick(w, 500); return true; }
}
}
-module.exports = new AiViewersEngine();
+module.exports = new AiViewersEngineV3();
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+/**
+ * AI viewers v3 — memory fold. One summary-role call per channel 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');
+
+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 }
+ * @returns {Promise<{updated:number, cost:number}|null>}
+ */
+async function foldAll(worker, { provider = null } = {}) {
+ const entries = [];
+ for (const bot of worker.bots) {
+ const lines = worker.botLines.get(bot.id) || [];
+ if (lines.length < 3) continue;
+ let brain = {}; try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }
+ entries.push({ bot, lines: lines.slice(-20), memory: brain.memory || '' });
+ }
+ if (!entries.length) return 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,
+ });
+ if (!r) return null;
+ const out = r.json || llm.parseJsonLoose(r.text);
+ if (!out || !Array.isArray(out.memories)) return null;
+ let updated = 0;
+ for (const m of out.memories) {
+ const e = entries.find(x => x.bot.username.toLowerCase() === String(m.bot || '').toLowerCase());
+ if (!e || !m.memory || !String(m.memory).trim()) continue;
+ let brain = {}; try { brain = JSON.parse(e.bot.brain_json || '{}'); } catch { /* */ }
+ brain.memory = clip(m.memory, 900);
+ brain.timeline = (brain.timeline || []).slice(-8);
+ brain.timeline.push({ n: e.bot.msg_count || 0, note: clip(e.lines[e.lines.length - 1], 80) });
+ try { db.updateChannelAiBot(e.bot.id, { brain_json: brain }); worker.botLines.set(e.bot.id, []); updated++; } catch { /* */ }
+ }
+ if (out.channel_memory && String(out.channel_memory).trim()) {
+ try {
+ const cfg = db.getChannelAiConfig(worker.userId);
+ let sj = {}; try { sj = JSON.parse(cfg.settings_json || '{}') || {}; } catch { /* */ }
+ sj.channel_memory = clip(out.channel_memory, 600);
+ db.upsertChannelAiConfig(worker.userId, { settings_json: JSON.stringify(sj) });
+ } catch { /* */ }
+ }
+ return { updated, cost: r.cost || 0, usage: r.usage, model: r.model };
+}
+
+/** Clear a bot's rolling memory (keeps its identity/persona). */
+function clearBrain(botId) {
+ const bot = db.getChannelAiBot(botId);
+ if (!bot) return false;
+ let persona = {}; try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }
+ db.updateChannelAiBot(botId, { brain_json: { memory: '', timeline: [], identity: persona.identity || '' } });
+ return true;
+}
+
+module.exports = { foldAll, clearBrain };
@@ -1,47 +1,24 @@
@@ -1,47 +1,24 @@
/**
/**
- * AI Chat Viewers — engine selector.
- * site_settings.ai_viewers_engine ('v2' | 'v3') picks the default; a channel can override
- * with settings_json.engine. Both engines expose the same lifecycle surface, so the rest
- * of the server (go-live hooks, chat hook, routes) never cares which one is running.
+ * AI Chat Viewers — public surface. The director engine (./engine) is the only engine;
+ * every lifecycle/chat hook in the server and the routes go through this module.
*/
*/
'use strict';
'use strict';
-const db = require('../../db/database');
-
-function _v2() { return require('./engine'); }
-function _v3() { return require('./v3/engine'); }
-
-function engineForUser(userId) {
- let override = '';
- try { override = (JSON.parse(db.getChannelAiConfig(userId).settings_json || '{}') || {}).engine || ''; } catch { /* */ }
- const site = String(db.getSetting('ai_viewers_engine') || 'v2');
- return (override || site) === 'v3' ? _v3() : _v2();
-}
-function engineForStream(streamId) {
- if (_v3().hasWorker(streamId)) return _v3();
- if (_v2().hasWorker(streamId)) return _v2();
- return null;
-}
+const engine = require('./engine');
module.exports = {
module.exports = {
- get version() { return String(db.getSetting('ai_viewers_engine') || 'v2'); },
- engineForUser,
- hasWorker(streamId) { return !!engineForStream(streamId); },
- startForStream(stream) { if (!stream || !stream.user_id) return; return engineForUser(stream.user_id).startForStream(stream); },
- stopForStream(streamId) { const e = engineForStream(streamId); if (e) return e.stopForStream(streamId); },
- stopForUser(userId) { _v2().stopForUser(userId); _v3().stopForUser(userId); },
- applyConfigForUser(userId) {
- // If the channel switched engines, stop the other one first.
- const want = engineForUser(userId);
- const other = want === _v3() ? _v2() : _v3();
- try { other.stopForUser(userId); } catch { /* */ }
- return want.applyConfigForUser(userId);
- },
- reloadForUser(userId) { return this.applyConfigForUser(userId); },
- onRealChatMessage(streamId, ev) { const e = engineForStream(streamId); if (e) return e.onRealChatMessage(streamId, ev); },
- onChatEvent(streamId, ev) { const e = engineForStream(streamId); if (e && e.onChatEvent) return e.onChatEvent(streamId, ev); if (e) return e.onRealChatMessage(streamId, ev); },
- onModCommand(channelUserId, streamId, args, opts) { const e = engineForUser(channelUserId); return e.onModCommand ? e.onModCommand(channelUserId, streamId, args, opts) : 'This channel runs the legacy AI viewers engine — switch to v3 for chat commands.'; },
- status(userId) { const e = engineForUser(userId); return e.status ? e.status(userId) : { engine: 'v2', running: _v2().hasWorker ? [..._v2().workers.values()].some(w => w.userId === userId) : false }; },
- preview(userId) { const e = engineForUser(userId); return e.preview ? e.preview(userId) : Promise.resolve({ error: 'Preview needs the v3 engine' }); },
- nudge(userId) { const e = engineForUser(userId); return e.nudge ? e.nudge(userId) : false; },
- v2: _v2, v3: _v3,
+ version: 'v3',
+ engineForUser() { return engine; },
+ hasWorker: (streamId) => engine.hasWorker(streamId),
+ startForStream: (stream) => engine.startForStream(stream),
+ stopForStream: (streamId) => engine.stopForStream(streamId),
+ stopForUser: (userId) => engine.stopForUser(userId),
+ applyConfigForUser: (userId) => engine.applyConfigForUser(userId),
+ reloadForUser: (userId) => engine.applyConfigForUser(userId),
+ onRealChatMessage: (streamId, ev) => engine.onChatEvent(streamId, ev),
+ onChatEvent: (streamId, ev) => engine.onChatEvent(streamId, ev),
+ onModCommand: (channelUserId, streamId, args, opts) => engine.onModCommand(channelUserId, streamId, args, opts),
+ status: (userId) => engine.status(userId),
+ preview: (userId) => engine.preview(userId),
+ nudge: (userId) => engine.nudge(userId),
+ engine,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+/**
+ * AI viewers v3 — who is being addressed?
+ * detect(message, bots, { isStreamer }) → { bots: [usernames named], addressedChat, question }
+ * A streamer talking to "chat"/"guys"/"everyone" or asking a question addresses everyone;
+ * "@name" or a bare bot username/display name addresses that bot (anyone can do that).
+ */
+'use strict';
+
+const CHAT_WORDS = /\b(chat|guys|gang|everyone|everybody|yall|y'all|you all|anyone|anybody|viewers|lads|folks|people)\b/i;
+
+function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
+
+function detect(message, bots, { isStreamer = false } = {}) {
+ const text = String(message || '');
+ const lower = text.toLowerCase();
+ const named = [];
+ for (const b of bots || []) {
+ const names = [b.username, b.display_name].filter(Boolean).map(n => String(n).toLowerCase());
+ for (const n of names) {
+ if (n.length < 3) continue;
+ if (lower.includes('@' + n) || new RegExp(`(^|[^a-z0-9_])${escapeRe(n)}([^a-z0-9_]|$)`, 'i').test(text)) { named.push(b.username); break; }
+ }
+ }
+ const question = /\?\s*$/.test(text.trim()) || /\b(what do you|whatd?ya|do you (guys|all)|should i|thoughts\??|opinions?\??|who here|anyone (here|know|want))\b/i.test(text);
+ const addressedChat = isStreamer && (CHAT_WORDS.test(text) || question || /^(so|ok|okay|alright|hey|yo)\b/i.test(text.trim()) && text.length < 120);
+ return { bots: [...new Set(named)], addressedChat, question };
+}
+
+module.exports = { detect };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+/**
+ * AI viewers v3 — posting a bot line into chat (+ moderation + activity log).
+ * Ported from the v2 engine's _inject, with the bits it skipped: word filter, per-bot
+ * TTS toggle, PowerChat forward toggle, and a log row for the control panel.
+ */
+'use strict';
+const db = require('../../db/database');
+
+function botColor(bot) {
+ try { return (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || '#8a8aff'; } catch { return bot.avatar_color || '#8a8aff'; }
+}
+function botPersona(bot) { try { return JSON.parse(bot.persona_json || '{}') || {}; } catch { return {}; } }
+
+/** Strip quotes/disclaimers; enforce a word cap. Returns '' when the line must be dropped. */
+function clean(text, maxWords = 18) {
+ let t = String(text || '').trim();
+ t = t.replace(/^["'`“”]+|["'`“”]+$/g, '').replace(/\s+/g, ' ').trim();
+ if (!t) return '';
+ if (/\b(as an ai|language model|i am a bot|i'm a bot|as a bot|openai|anthropic)\b/i.test(t)) return '';
+ if (/^\w+:\s/.test(t) && t.length < 80) t = t.replace(/^\w+:\s/, ''); // "botname: text" echo
+ const words = t.split(' ');
+ if (words.length > maxWords) t = words.slice(0, maxWords).join(' ').replace(/[,;:]$/, '');
+ if (t.length > 240) t = t.slice(0, 240);
+ return t.trim();
+}
+
+/** Word-filter gate — returns { ok, text, reason }. */
+function moderate(text) {
+ try {
+ const wf = require('../../chat/word-filter');
+ const r = wf.check(text);
+ if (!r.safe) return { ok: false, text, reason: `word filter: ${r.matches.slice(0, 3).join(', ')}` };
+ if (typeof wf.isSpam === 'function' && wf.isSpam(text)) return { ok: false, text, reason: 'spam pattern' };
+ } catch { /* filter unavailable → allow */ }
+ return { ok: true, text, reason: null };
+}
+
+/**
+ * Post a line. Returns the chat message id (or null).
+ * @param {object} worker { streamId, userId, settings }
+ */
+function post(worker, bot, message, { threadId = null, replyToId = null } = {}) {
+ const streamId = worker.streamId;
+ const persona = botPersona(bot);
+ const color = botColor(bot);
+ let id = null;
+ try {
+ const res = db.saveChatMessage({
+ stream_id: streamId,
+ channel_user_id: worker.userId,
+ user_id: null,
+ username: bot.username,
+ message,
+ message_type: 'chat',
+ is_global: false,
+ reply_to_id: replyToId || null,
+ source_platform: 'ai', // marker → excluded from chat-AI feeds
+ metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },
+ });
+ id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;
+ } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }
+
+ const chatMsg = {
+ type: 'chat', id: id || undefined,
+ username: bot.username, core_username: null,
+ user_id: null, anon_id: null, role: 'user',
+ message, stream_id: streamId, is_global: false,
+ reply_to_id: replyToId || null,
+ avatar_url: null, profile_color: color,
+ is_ai: true, source_platform: 'ai',
+ filtered: false, timestamp: new Date().toISOString(),
+ };
+ try {
+ const chatServer = require('../../chat/chat-server');
+ chatServer.broadcastToStream(streamId, chatMsg);
+ chatServer.forwardToGlobal(streamId, chatMsg);
+ const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;
+ if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);
+ } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }
+
+ if (worker.settings.powerchat_forward !== false) {
+ try {
+ const pc = require('../../integrations/powerchat-platform');
+ if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {
+ pc.forwardChat(worker.userId, {
+ chatterName: bot.username,
+ externalChatterId: `ai:${bot.username.toLowerCase()}`,
+ message,
+ messageId: id ? `ov-${id}` : undefined,
+ avatarFallback: '🤖',
+ });
+ }
+ } catch { /* non-critical */ }
+ }
+ try { db.touchChannelAiBot(bot.id); } catch { /* */ }
+ return id;
+}
+
+/** Append a row to ai_viewer_log (best-effort). */
+function log(worker, row) {
+ try {
+ db.addAiViewerLog({ channel_user_id: worker.userId, stream_id: worker.streamId || null, ...row });
+ } catch { /* */ }
+}
+
+module.exports = { post, clean, moderate, log, botPersona, botColor };
@@ -17,7 +17,7 @@ const { requireAuth } = require('../../auth/auth');
@@ -17,7 +17,7 @@ const { requireAuth } = require('../../auth/auth');
const chatAi = require('../chat-ai');
const chatAi = require('../chat-ai');
const budget = require('./budget');
const budget = require('./budget');
const roster = require('./roster');
const roster = require('./roster');
-const brain = require('./brain');
+const { clearBrain } = require('./fold');
const engine = require('./index');
const engine = require('./index');
const settingsMod = require('./settings');
const settingsMod = require('./settings');
const ai = require('../ai-analysis');
const ai = require('../ai-analysis');
@@ -209,7 +209,7 @@ router.post('/bots/:id/clear-memory', requireAuth, (req, res) => {
@@ -209,7 +209,7 @@ router.post('/bots/:id/clear-memory', requireAuth, (req, res) => {
try {
try {
const bot = ownBotOr404(req, res);
const bot = ownBotOr404(req, res);
if (!bot) return;
if (!bot) return;
- brain.clearBrain(bot.id);
+ clearBrain(bot.id);
res.json({ ok: true, bot: botSummary(db.getChannelAiBot(bot.id)) });
res.json({ ok: true, bot: botSummary(db.getChannelAiBot(bot.id)) });
} catch (e) {
} catch (e) {
res.status(500).json({ error: 'Failed to clear memory' });
res.status(500).json({ error: 'Failed to clear memory' });
@@ -271,33 +271,9 @@ router.post('/clone', requireAuth, async (req, res) => {
@@ -271,33 +271,9 @@ router.post('/clone', requireAuth, async (req, res) => {
// ── Preview one line (no posting) ─────────────────────────────
// ── Preview one line (no posting) ─────────────────────────────
router.post('/preview', requireAuth, async (req, res) => {
router.post('/preview', requireAuth, async (req, res) => {
try {
try {
- if (engine.engineForUser(req.user.id).version === 'v3') {
- const out = await engine.preview(req.user.id);
- if (out && out.error) return res.status(400).json({ error: out.error });
- return res.json(out);
- }
- const st = budget.budgetStatus(req.user.id);
- if (!st.active) {
- const why = st.reason === 'shared_ai_disabled' ? 'Shared AI is currently disabled by the admin.'
- : st.reason === 'over_daily_cap' ? "You've hit today's budget cap."
- : st.reason === 'no_byo_key' ? 'Add your API key (or a public self-hosted URL) first.'
- : 'AI viewers are not active.';
- return res.status(400).json({ error: why });
- }
- const cfg = db.getChannelAiConfig(req.user.id);
- const persona = roster.makeAmbientPersona((req.user.id * 7 + 3) & 0x7fffffff);
- const system = [
- `You are a viewer in a live stream's chat. You are ${persona.character.blurb}.`,
- cfg.persona ? `Channel vibe: ${cfg.persona}` : '',
- `Typing style: ${persona.style.rules}.`,
- `Reply with ONE short chat message only. Never say you are an AI or a bot. Just the message.`,
- ].filter(Boolean).join('\n');
- const text = await budget.generate(req.user.id, {
- system, user: 'Chat is a bit quiet. Say something to get things going.',
- maxTokens: 50, temperature: 1.05,
- });
- if (!text) return res.status(502).json({ error: 'No response from the AI provider.' });
- res.json({ line: text });
+ const out = await engine.preview(req.user.id);
+ if (out && out.error) return res.status(400).json({ error: out.error });
+ res.json(out);
} catch (e) {
} catch (e) {
res.status(500).json({ error: 'Preview failed' });
res.status(500).json({ error: 'Preview failed' });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+/**
+ * AI viewers v3 — pacing. Planned lines go into a per-stream queue and are released
+ * humanly: a minimum gap between any two bot lines, a per-minute ceiling, slow-mode
+ * awareness, no bot twice in a row, and a rolling bot-to-bot share cap.
+ */
+'use strict';
+
+function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
+
+class Scheduler {
+ constructor(worker, deps) {
+ this.worker = worker; // { streamId, settings, ... }
+ this.deps = deps; // { post(line), now() }
+ this.queue = []; // [{ bot, text, target, replyTo, threadId, postAt, prio, reason }]
+ this.recent = []; // [{ at, target, bot }] last 40 posted
+ this.lastPostAt = 0;
+ this.lastBot = null;
+ this.timer = null;
+ this.stopped = false;
+ }
+
+ settings() { return this.worker.settings || {}; }
+
+ /** Queue planned lines (from a director pass). */
+ enqueue(lines, { basePrio = 1 } = {}) {
+ const now = Date.now();
+ for (const l of lines) {
+ const jitter = rint(0, 2500);
+ const prio = l.target === 'streamer' ? 3 : basePrio;
+ this.queue.push({ ...l, prio, postAt: now + (prio === 3 ? Math.min(l.delay_ms || 0, 4000) : (l.delay_ms || 0)) + jitter });
+ }
+ // Keep the queue bounded: newest plans win over stale unposted ambient chatter.
+ if (this.queue.length > 12) this.queue = this.queue.sort((a, b) => b.prio - a.prio || a.postAt - b.postAt).slice(0, 12);
+ this._arm();
+ }
+
+ /** Drop everything not yet posted (config change / pause / stop). */
+ clear() { this.queue = []; if (this.timer) { clearTimeout(this.timer); this.timer = null; } }
+
+ stop() { this.stopped = true; this.clear(); }
+
+ pending() { return this.queue.length; }
+
+ /** Share of bot→bot lines over the last 20 posts (0..1). */
+ botShare() {
+ const last = this.recent.slice(-20);
+ if (!last.length) return 0;
+ return last.filter(r => r.target === 'bot').length / last.length;
+ }
+ linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }
+
+ _slowModeMs() {
+ try { const cs = require('../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }
+ }
+
+ _arm(delay = null) {
+ if (this.stopped || this.timer || !this.queue.length) return;
+ const next = Math.min(...this.queue.map(q => q.postAt));
+ const wait = delay != null ? delay : Math.max(250, next - Date.now());
+ this.timer = setTimeout(() => { this.timer = null; this._drain(); }, wait);
+ }
+
+ _drain() {
+ if (this.stopped || !this.queue.length) return;
+ const s = this.settings();
+ const now = Date.now();
+ const minGap = Math.max((s.min_gap_sec || 4) * 1000, this._slowModeMs());
+ if (now - this.lastPostAt < minGap) return this._arm(minGap - (now - this.lastPostAt));
+ const perMin = Math.max(0.5, s.lines_per_min || 3);
+ if (this.linesLastMinute() >= perMin) return this._arm(5000);
+
+ // Pick the highest-priority due line that doesn't violate the alternation/ratio rules.
+ const due = this.queue.filter(q => q.postAt <= now).sort((a, b) => b.prio - a.prio || a.postAt - b.postAt);
+ if (!due.length) return this._arm();
+ let pick = null;
+ for (const q of due) {
+ if (q.bot === this.lastBot && due.length > 1) continue;
+ if (q.target === 'bot' && this.botShare() >= (s.bot_to_bot_ratio || 0) + 0.05 && q.prio < 3) continue;
+ pick = q; break;
+ }
+ if (!pick) {
+ // Everything due is blocked by the ratio/alternation rules — drop stale bot↔bot chatter.
+ const stale = due.find(q => q.target === 'bot' && q.prio < 3);
+ if (stale) { this.queue = this.queue.filter(q => q !== stale); this.deps.dropped && this.deps.dropped(stale, 'bot-to-bot share cap'); }
+ return this._arm(3000);
+ }
+ this.queue = this.queue.filter(q => q !== pick);
+ try { this.deps.post(pick); } catch (e) { console.warn('[AI-Viewers] post failed:', e.message); }
+ this.lastPostAt = Date.now();
+ this.lastBot = pick.bot;
+ this.recent.push({ at: this.lastPostAt, target: pick.target, bot: pick.bot });
+ if (this.recent.length > 40) this.recent.shift();
+ this._arm();
+ }
+}
+
+module.exports = { Scheduler };
@@ -12,7 +12,6 @@ const db = require('../../db/database');
@@ -12,7 +12,6 @@ const db = require('../../db/database');
// key → { def, type, min, max, values, max_len, help }
// key → { def, type, min, max, values, max_len, help }
const SCHEMA = {
const SCHEMA = {
- engine: { def: '', type: 'enum', values: ['', 'v2', 'v3'], help: 'Engine override (blank = site default)' },
// Roster
// Roster
roster_size: { def: 3, type: 'int', min: 0, max: 12, help: 'How many ambient AI viewers hang out in chat' },
roster_size: { def: 3, type: 'int', min: 0, max: 12, help: 'How many ambient AI viewers hang out in chat' },
// Activity
// Activity
deleted file mode 100644
deleted file mode 100644
@@ -1,103 +0,0 @@
@@ -1,103 +0,0 @@
-/**
- * AI viewers v3 — the director.
- *
- * ONE structured LLM call 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.
- */
-'use strict';
-const llm = require('../../llm');
-
-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 },
- ];
-}
-
-/**
- * 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 }
- */
-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,
- });
- if (!r) return null;
- let p = r.json;
- if (!p || typeof p !== 'object') p = llm.parseJsonLoose(r.text) || { skip: true, lines: [] };
- 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).
- */
-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;
-}
-
-module.exports = { plan, quickReply, PLAN_SCHEMA, DIRECTOR_RULES, buildSystem };
deleted file mode 100644
deleted file mode 100644
@@ -1,428 +0,0 @@
@@ -1,428 +0,0 @@
-/**
- * AI Chat Viewers v3 — engine.
- *
- * Per live stream: a worker holds the roster, settings, a cached stable prompt prefix,
- * a scheduler, and pending intents. A DIRECTOR pass runs on a cadence (or immediately
- * on a priority intent), assembles the unified context (only deltas), makes ONE
- * structured LLM call that plans several lines, and hands them to the scheduler.
- * Streamer mentions get a guaranteed answer via a fast path when the next pass is far.
- * Nothing runs when nothing changed. Spend degrades gracefully toward the streamer's cap.
- */
-'use strict';
-const db = require('../../../db/database');
-const settingsMod = require('../settings');
-const roster = require('../roster');
-const budget = require('../budget');
-const context = require('../../context');
-const director = require('./director');
-const poster = require('./poster');
-const mention = require('./mention');
-const fold = require('./fold');
-const { Scheduler } = require('./scheduler');
-
-const ACTIVE_WINDOW_MS = 45000;
-const SKIP_LOG_EVERY_MS = 5 * 60 * 1000;
-const STREAMER_QUIET_MS = 8000; // after answering the streamer, hold ambient chatter briefly
-
-function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }
-function norm(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); }
-
-class AiViewersEngineV3 {
- constructor() { this.workers = new Map(); this.version = 'v3'; }
-
- hasWorker(streamId) { return this.workers.has(streamId); }
- workerForUser(userId) { for (const w of this.workers.values()) if (w.userId === userId) return w; return null; }
-
- _killSwitch() { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; }
-
- // ── Lifecycle ─────────────────────────────────────────────
- async startForStream(stream) {
- try {
- if (!stream || !stream.id || !stream.user_id) return;
- if (this.workers.has(stream.id)) return;
- if (this._killSwitch()) return;
- const cfg = db.getChannelAiConfig(stream.user_id);
- if (!cfg.enabled) return;
- const settings = settingsMod.getSettings(stream.user_id, cfg);
- let full = stream;
- try { full = db.getStreamById(stream.id) || stream; } catch { /* */ }
- if (full.managed_stream_id && settings.slots && settings.slots[String(full.managed_stream_id)] === false) {
- console.log(`[AI-Viewers] slot ${full.managed_stream_id} has AI viewers off — not starting for stream ${stream.id}`);
- return;
- }
- const bots = roster.ensureRoster(stream.user_id, settings.roster_size);
- if (!bots.length) return;
-
- const worker = {
- streamId: stream.id, userId: stream.user_id, stream: full, cfg, settings, bots,
- stable: null, // cached stable prefix {text,hash,at}
- lastChatId: db.getMaxChatMessageIdForChannel(stream.user_id),
- lastTickAt: 0, nextTickAt: 0, tickTimer: null, foldTimer: null, ticking: false,
- lastRealInputAt: 0, lastStreamerReplyAt: 0, lastSkipLogAt: 0, lastVolatileHash: null,
- intents: [], // [{ kind:'streamer'|'mention'|'sound'|'scene', text, bots:[], at }]
- botLines: new Map(), // botId → recent lines (memory fold)
- recentLines: [], // normalized last 30 bot lines (dedupe)
- lastSeenMemoryId: null, lastPeriodicVisionAt: 0,
- paused: !!(settings.runtime && settings.runtime.paused),
- mode: 'normal',
- stats: { ticks: 0, lines: 0, skips: 0, cost: 0 },
- startedAt: Date.now(), stopped: false,
- };
- worker.scheduler = new Scheduler(worker, {
- post: (line) => this._post(worker, line),
- dropped: (line, why) => poster.log(worker, { event: 'skip', bot_username: line.bot, text: line.text, reason: why }),
- });
- this.workers.set(stream.id, worker);
- poster.log(worker, { event: 'info', reason: `engine v3 started — ${bots.length} viewer(s), ${settings.activity} activity, director every ${settings.director_interval_sec}s` });
- console.log(`[AI-Viewers v3] started for stream ${stream.id} (user ${stream.user_id}, ${bots.length} bots)`);
- this._scheduleTick(worker, 4000);
- worker.foldTimer = setInterval(() => this._fold(worker).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000);
- } catch (e) { console.warn('[AI-Viewers v3] start failed:', e.message); }
- }
-
- stopForStream(streamId) {
- const w = this.workers.get(streamId);
- if (!w) return;
- w.stopped = true;
- if (w.tickTimer) clearTimeout(w.tickTimer);
- if (w.foldTimer) clearInterval(w.foldTimer);
- w.scheduler.stop();
- this.workers.delete(streamId);
- try { db.closeAllAiViewerThreads(w.userId); } catch { /* */ }
- this._fold(w).catch(() => {});
- poster.log(w, { event: 'info', reason: `stopped — ${w.stats.lines} line(s), ${w.stats.ticks} pass(es), $${w.stats.cost.toFixed(4)}` });
- console.log(`[AI-Viewers v3] stopped for stream ${streamId}`);
- }
- stopForUser(userId) { for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid); }
-
- /** Hot-apply config: reload settings/roster on live workers; start/stop as needed. */
- applyConfigForUser(userId) {
- const cfg = db.getChannelAiConfig(userId);
- const settings = settingsMod.getSettings(userId, cfg);
- const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();
- for (const [sid, w] of this.workers) {
- if (w.userId !== userId) continue;
- if (!cfg.enabled || this._killSwitch() || (w.stream.managed_stream_id && settings.slots && settings.slots[String(w.stream.managed_stream_id)] === false)) { this.stopForStream(sid); continue; }
- w.cfg = cfg; w.settings = settings; w.paused = !!(settings.runtime && settings.runtime.paused);
- try { w.bots = roster.ensureRoster(userId, settings.roster_size); } catch { /* */ }
- w.stable = null; // prefix inputs may have changed
- if (w.foldTimer) { clearInterval(w.foldTimer); w.foldTimer = setInterval(() => this._fold(w).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000); }
- poster.log(w, { event: 'info', reason: 'settings applied' });
- }
- if (cfg.enabled && !this._killSwitch()) for (const s of live) if (!this.workers.has(s.id)) this.startForStream(s).catch(() => {});
- }
- reloadForUser(userId) { return this.applyConfigForUser(userId); }
-
- // ── Chat events (native, relayed, anon — everything public) ─
- onRealChatMessage(streamId, ev) { return this.onChatEvent(streamId, ev); }
- onChatEvent(streamId, ev = {}) {
- const w = this.workers.get(streamId);
- if (!w || w.stopped) return;
- const uname = String(ev.username || '');
- if (ev.isBot || w.bots.some(b => b.username.toLowerCase() === uname.toLowerCase())) return; // never react to our own lines
- if (ev.platform && !w.settings.reply_to_relay_chat) return;
- const isStreamer = !!ev.isStreamer || (ev.userId && ev.userId === w.userId);
- const text = String(ev.message || '');
- // Streamer/mod shortcut: "!ai pause" etc. also works from relayed chat for the streamer.
- if (/^!ai\b/i.test(text) && (isStreamer || ev.isMod)) {
- try { this.onModCommand(w.userId, streamId, text.split(/\s+/).slice(1), { by: uname }); } catch { /* */ }
- return;
- }
- w.lastRealInputAt = Date.now();
- const det = mention.detect(text, w.bots, { isStreamer });
- if (isStreamer && (det.addressedChat || det.bots.length)) {
- this._pushIntent(w, { kind: 'streamer', text: `The streamer (${uname}) said: "${clip(text, 220)}"${det.bots.length ? ` — addressed to ${det.bots.join(', ')}` : ' — addressed to chat'}. A reply is REQUIRED.`, bots: det.bots, line: text, at: Date.now(), msgId: ev.msgId || null });
- this._fastPath(w, { line: text, bots: det.bots, replyTo: uname, msgId: ev.msgId || null }).catch(() => {});
- return;
- }
- if (det.bots.length) {
- this._pushIntent(w, { kind: 'mention', text: `${uname} addressed ${det.bots.join(', ')}: "${clip(text, 200)}" — that viewer should be answered.`, bots: det.bots, at: Date.now(), replyTo: uname });
- this._scheduleTick(w, 2500);
- return;
- }
- if (isStreamer) { this._scheduleTick(w, Math.min(6000, w.settings.director_interval_sec * 1000)); return; }
- // Regular viewer message: the director decides; reply probability trims how often
- // we even suggest it, and a burst of new chat pulls the next pass closer.
- if (Math.random() < (w.settings.reply_probability == null ? 0.6 : w.settings.reply_probability)) {
- this._pushIntent(w, { kind: 'viewer', text: `${uname} said: "${clip(text, 200)}" — consider replying to them.`, bots: [], at: Date.now(), replyTo: uname, soft: true });
- }
- if (Date.now() - w.lastTickAt > 8000) this._scheduleTick(w, 6000);
- }
- _pushIntent(w, intent) {
- w.intents.push(intent);
- // Hard intents first; keep the queue short so a burst can't stack ten replies.
- w.intents.sort((a, b) => (a.soft ? 1 : 0) - (b.soft ? 1 : 0) || a.at - b.at);
- if (w.intents.length > 6) w.intents = w.intents.slice(0, 6);
- }
-
- // ── Mod commands: /ai … or !ai … ─────────────────────────
- onModCommand(channelUserId, streamId, args = [], { by = null } = {}) {
- const [cmd, ...rest] = (args || []).map(a => String(a || '').trim()).filter(Boolean);
- const w = this.workerForUser(channelUserId);
- const settings = settingsMod.getSettings(channelUserId);
- const runtime = { paused: !!(settings.runtime && settings.runtime.paused), muted: (settings.runtime && settings.runtime.muted) || [] };
- const save = () => { settingsMod.updateSettings(channelUserId, { runtime }); if (w) { w.settings = settingsMod.getSettings(channelUserId); w.paused = runtime.paused; w.stable = null; } };
- switch ((cmd || 'status').toLowerCase()) {
- case 'pause': runtime.paused = true; save(); if (w) { w.scheduler.clear(); poster.log(w, { event: 'pause', reason: `paused by ${by || 'mod'}` }); } return 'AI viewers paused.';
- case 'resume': runtime.paused = false; save(); if (w) { poster.log(w, { event: 'pause', reason: `resumed by ${by || 'mod'}` }); this._scheduleTick(w, 3000); } return 'AI viewers resumed.';
- case 'mute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); if (!n) return 'Usage: /ai mute <viewer>'; if (!runtime.muted.includes(n)) runtime.muted.push(n); save(); return `Muted ${n}.`; }
- case 'unmute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); runtime.muted = runtime.muted.filter(x => x !== n); save(); return `Unmuted ${n}.`; }
- case 'nudge': if (w) { this._scheduleTick(w, 500); return 'Nudged the director.'; } return 'AI viewers are not running right now.';
- default: {
- if (!w) return `AI viewers: ${runtime.paused ? 'paused' : 'not running for a live stream'}${runtime.muted.length ? `, muted: ${runtime.muted.join(', ')}` : ''}.`;
- return `AI viewers: ${w.paused ? 'paused' : 'running'} · ${w.bots.length} viewers · mode ${w.mode} · ${w.stats.lines} lines this stream${runtime.muted.length ? ` · muted: ${runtime.muted.join(', ')}` : ''}. Commands: pause, resume, mute <name>, unmute <name>, nudge.`;
- }
- }
- }
-
- // ── Director loop ────────────────────────────────────────
- _scheduleTick(w, delayMs) {
- if (w.stopped) return;
- const at = Date.now() + Math.max(250, delayMs);
- if (w.tickTimer && w.nextTickAt <= at) return; // an earlier tick is already armed
- if (w.tickTimer) clearTimeout(w.tickTimer);
- w.nextTickAt = at;
- w.tickTimer = setTimeout(() => { w.tickTimer = null; this._tick(w).catch(e => console.warn('[AI-Viewers v3] tick error:', e.message)); }, at - Date.now());
- }
- _cadenceMs(w) {
- const s = w.settings;
- const active = (Date.now() - w.lastRealInputAt) < ACTIVE_WINDOW_MS;
- let ms = (active ? s.director_interval_sec : s.idle_interval_sec) * 1000;
- if (w.mode === 'economy') ms *= 1.5;
- if (w.mode === 'replies_only') ms *= 2;
- return ms;
- }
- _providerFor(w) { return w.cfg.use_shared_key ? null : budget.byoProvider(w.cfg); }
-
- async _tick(w) {
- if (w.stopped || w.ticking) return;
- w.ticking = true;
- const s = w.settings;
- try {
- if (this._killSwitch()) { this.stopForStream(w.streamId); return; }
- try { w.stream = db.getStreamById(w.streamId) || w.stream; } catch { /* */ }
- if (!w.stream || !w.stream.is_live) { this.stopForStream(w.streamId); return; }
- const hardIntents = w.intents.filter(i => !i.soft);
- const st = budget.status(w.userId);
- w.mode = st.mode;
- const quiet = settingsMod.isQuietNow(s);
- if (w.paused || (quiet && !(s.quiet_allow_replies && hardIntents.length)) || st.mode === 'silent' || !st.active) {
- w.intents = [];
- this._logSkip(w, w.paused ? 'paused' : quiet ? 'quiet hours' : (st.reason || st.mode));
- return;
- }
- if (st.mode === 'streamer_only' && !hardIntents.some(i => i.kind === 'streamer')) { w.intents = []; this._logSkip(w, 'budget: streamer replies only'); return; }
- if (st.mode === 'replies_only') w.intents = w.intents.filter(i => !i.soft ? true : i.kind === 'viewer');
-
- // Vision: look at the screen through the shared memory pipeline when it is stale.
- await this._maybeVision(w, hardIntents.length > 0);
-
- const bots = w.bots.filter(b => !((s.runtime && s.runtime.muted) || []).includes(b.username.toLowerCase()));
- if (!bots.length) { this._logSkip(w, 'all viewers muted'); return; }
- const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots, settings: s, cacheHolder: w });
- const linesAllowed = Math.max(1, Math.min(s.lines_per_tick, st.mode === 'economy' ? s.lines_per_tick - 1 : s.lines_per_tick));
- const tail = context.volatileTail({
- userId: w.userId, stream: w.stream, settings: s, sinceChatId: w.lastChatId,
- botNames: new Set(w.bots.map(b => b.username.toLowerCase())),
- intents: w.intents.slice(0, 5), mode: st.mode, linesAllowed, botShare: w.scheduler.botShare(),
- });
- const awaiting = tail.threads.some(t => t.awaiting);
- const sceneChanged = tail.delta.memoryId && tail.delta.memoryId !== w.lastSeenMemoryId && s.react_to_scene_changes;
- const sound = s.react_to_sounds && tail.delta.latestSound && (Date.now() - w.lastTickAt) < 120000 ? tail.delta.latestSound : null;
- const changed = tail.hash !== w.lastVolatileHash;
- if (!changed && !w.intents.length && !awaiting && !sceneChanged) { this._logSkip(w, 'nothing new'); return; }
- if (!tail.delta.newChat && !tail.delta.speechLines && !w.intents.length && !awaiting && !sceneChanged && w.scheduler.pending()) { this._logSkip(w, 'lines still queued'); return; }
- w.lastVolatileHash = tail.hash;
- if (sceneChanged) w.lastSeenMemoryId = tail.delta.memoryId;
- let volatileText = tail.text;
- if (sceneChanged && !w.intents.length) volatileText += '\n\nNote: what is on screen just changed — an ambient reaction is welcome.';
- if (sound) volatileText += `\n\nNote: a "${sound.label}" sound was just detected on stream.`;
-
- const consumed = w.intents.splice(0, w.intents.length);
- const res = await director.plan({
- stableText: stable.text, volatileText, maxLines: linesAllowed,
- provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`,
- });
- w.lastTickAt = Date.now();
- w.lastChatId = Math.max(w.lastChatId, tail.chatMaxId || 0);
- w.stats.ticks++;
- if (!res) { poster.log(w, { event: 'error', reason: 'director call failed or provider quiet' }); w.intents.unshift(...consumed.filter(i => !i.soft)); return; }
- w.stats.cost += res.cost || 0;
- const accepted = this._acceptPlan(w, res.plan, bots, consumed);
- poster.log(w, {
- event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),
- text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,
- tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,
- });
- for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { db.closeAiViewerThread(n); } catch { /* */ } } }
- try { db.closeStaleAiViewerThreads(w.userId, s.thread_idle_close_sec, s.max_thread_turns); } catch { /* */ }
- } finally {
- w.ticking = false;
- if (!w.stopped) this._scheduleTick(w, this._cadenceMs(w));
- }
- }
-
- _logSkip(w, reason) {
- w.stats.skips++;
- if (Date.now() - w.lastSkipLogAt > SKIP_LOG_EVERY_MS) { w.lastSkipLogAt = Date.now(); poster.log(w, { event: 'skip', reason }); }
- }
-
- /** Validate + enqueue a plan. Returns the number of accepted lines. */
- _acceptPlan(w, plan, bots, consumed) {
- if (!plan || plan.skip || !plan.lines.length) return 0;
- const s = w.settings;
- const byName = new Map(bots.map(b => [b.username.toLowerCase(), b]));
- const streamerIntent = consumed.find(i => i.kind === 'streamer');
- const out = [];
- let botTargets = 0;
- let lastBot = null;
- for (const raw of plan.lines) {
- const bot = byName.get(String(raw.bot || '').toLowerCase());
- if (!bot) continue;
- if (lastBot === bot.username) continue;
- let text = poster.clean(raw.text, s.max_words);
- if (!text) continue;
- const n = norm(text);
- if (w.recentLines.includes(n)) continue;
- const mod = poster.moderate(text);
- if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); continue; }
- const target = ['viewer', 'bot', 'streamer', 'ambient'].includes(raw.target) ? raw.target : 'ambient';
- if (target === 'bot') {
- const maxBot = Math.floor((s.bot_to_bot_ratio || 0) * Math.max(1, plan.lines.length) + 0.5);
- if (botTargets >= Math.max(0, maxBot) && (s.bot_to_bot_ratio || 0) < 0.79) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: 'bot-to-bot ratio' }); continue; }
- botTargets++;
- }
- // Threads
- let threadId = null;
- try {
- if (raw.thread === 'new') {
- const kind = target === 'bot' ? 'bot_bot' : target === 'streamer' ? 'bot_streamer' : 'bot_viewer';
- const open = db.getOpenAiViewerThreads(w.userId, 10);
- if (open.length < (s.max_open_threads || 3)) {
- const t = db.createAiViewerThread({ channel_user_id: w.userId, stream_id: w.streamId, kind, participants: [bot.username, raw.reply_to].filter(Boolean), topic: raw.topic ? clip(raw.topic, 80) : null, awaiting: target === 'bot' ? raw.reply_to : null });
- threadId = t && t.id;
- }
- } else if (raw.thread && /^\d+$/.test(String(raw.thread))) threadId = parseInt(raw.thread, 10);
- } catch { threadId = null; }
- lastBot = bot.username;
- w.recentLines.push(n); if (w.recentLines.length > 30) w.recentLines.shift();
- out.push({ bot: bot.username, botRow: bot, text, target, replyTo: raw.reply_to || null, threadId, delay_ms: Math.max(0, Math.min(45000, parseInt(raw.delay_ms, 10) || 0)), reason: clip(raw.reason, 120), replyToId: (target === 'streamer' && streamerIntent) ? streamerIntent.msgId : null });
- }
- if (out.length) w.scheduler.enqueue(out);
- return out.length;
- }
-
- _post(w, line) {
- if (w.stopped) return;
- const bot = line.botRow || w.bots.find(b => b.username === line.bot);
- if (!bot) return;
- const id = poster.post(w, bot, line.text, { threadId: line.threadId, replyToId: line.replyToId });
- w.stats.lines++;
- if (line.target === 'streamer') w.lastStreamerReplyAt = Date.now();
- poster.log(w, { event: 'line', bot_username: bot.username, target: line.target + (line.replyTo ? `:${line.replyTo}` : ''), thread_id: line.threadId || null, chat_message_id: id, text: line.text, reason: line.reason });
- const buf = w.botLines.get(bot.id) || []; buf.push(line.text); if (buf.length > 24) buf.shift(); w.botLines.set(bot.id, buf);
- if (line.threadId) {
- try { db.touchAiViewerThread(line.threadId, { line: line.text, by: bot.username, awaiting: line.target === 'bot' ? (line.replyTo || null) : null }); } catch { /* */ }
- // A bot↔bot thread waiting on the other bot pulls the next pass closer.
- if (line.target === 'bot') this._scheduleTick(w, Math.min(this._cadenceMs(w), 12000));
- }
- }
-
- // ── Streamer fast path ───────────────────────────────────
- async _fastPath(w, { line, bots: named, replyTo, msgId }) {
- const s = w.settings;
- const waitMs = Math.max(0, (s.mention_fast_path_sec || 0) * 1000);
- const nextIn = w.nextTickAt ? w.nextTickAt - Date.now() : Infinity;
- if (nextIn <= waitMs + 1500) { this._scheduleTick(w, Math.min(nextIn, 1500)); return; } // the director will handle it in time
- const st = budget.status(w.userId);
- if (!st.active || st.mode === 'silent' || w.paused) return;
- const muted = new Set(((s.runtime && s.runtime.muted) || []).map(x => String(x).toLowerCase()));
- const pool = w.bots.filter(b => !muted.has(b.username.toLowerCase()));
- if (!pool.length) return;
- const bot = (named.length && pool.find(b => b.username === named[0])) || pool[Math.floor(Math.random() * pool.length)];
- await this._maybeVision(w, true);
- const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: pool, settings: s, cacheHolder: w });
- const heard = context.heardBlock(w.stream, s);
- const seen = context.seenBlock(w.stream);
- const situation = [
- heard.text ? `Heard on stream recently:\n${heard.text.split('\n').slice(-6).join('\n')}` : '',
- seen.text ? `On screen (${context.ago(seen.ageMs)}): ${seen.text}` : '',
- ].filter(Boolean).join('\n\n');
- const r = await director.quickReply({ stableText: stable.text, situationText: situation, bot, streamerLine: clip(line, 220), maxWords: s.max_words, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}` });
- if (!r || !r.text) return;
- const text = poster.clean(r.text, s.max_words);
- const mod = text ? poster.moderate(text) : { ok: false, reason: 'empty' };
- if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); return; }
- w.stats.cost += r.cost || 0;
- w.intents = w.intents.filter(i => i.kind !== 'streamer'); // handled — the next pass must not double-reply
- w.recentLines.push(norm(text)); if (w.recentLines.length > 30) w.recentLines.shift();
- poster.log(w, { event: 'mention', bot_username: bot.username, target: `streamer:${replyTo}`, text, reason: 'fast path — streamer addressed chat', tokens_in: r.usage.input, tokens_cached: r.usage.cached, tokens_out: r.usage.output, cost_usd: r.cost, model: r.model });
- w.scheduler.enqueue([{ bot: bot.username, botRow: bot, text, target: 'streamer', replyTo, threadId: null, delay_ms: 0, reason: 'answering the streamer', replyToId: msgId || null }], { basePrio: 3 });
- }
-
- // ── Vision policy ────────────────────────────────────────
- async _maybeVision(w, addressed) {
- const s = w.settings;
- if (s.vision_policy === 'off') return;
- const seen = context.seenBlock(w.stream);
- const stale = seen.ageMs == null || seen.ageMs > (s.vision_max_age_sec || 180) * 1000;
- const periodicDue = s.vision_policy === 'periodic' && (Date.now() - w.lastPeriodicVisionAt) > (s.vision_periodic_sec || 300) * 1000 && (Date.now() - w.lastRealInputAt) < 5 * 60000;
- if (!stale && !periodicDue) return;
- const allowFfmpeg = s.vision_policy !== 'thumbnail';
- if (addressed || periodicDue) {
- if (periodicDue) w.lastPeriodicVisionAt = Date.now();
- const p = context.wantFreshFrame(w.stream, { maxAgeSec: s.vision_max_age_sec, allowFfmpeg });
- // Addressed: wait up to 12s so the answer can reference the screen; otherwise fire and forget.
- if (addressed) { const t = new Promise(res => setTimeout(() => res(false), 12000)); const started = await Promise.race([p, t]); if (started) poster.log(w, { event: 'vision', reason: 'fresh frame analyzed (shared memory pipeline)' }); }
- else p.then(ok => { if (ok) poster.log(w, { event: 'vision', reason: 'periodic frame analyzed' }); }).catch(() => {});
- }
- }
-
- // ── Memory fold ──────────────────────────────────────────
- async _fold(w) {
- try {
- const r = await fold.foldAll(w, { provider: this._providerFor(w) });
- if (r) { w.stats.cost += r.cost || 0; poster.log(w, { event: 'fold', reason: `memory updated for ${r.updated} viewer(s)`, tokens_in: r.usage && r.usage.input, tokens_cached: r.usage && r.usage.cached, tokens_out: r.usage && r.usage.output, cost_usd: r.cost, model: r.model });
- for (let i = 0; i < w.bots.length; i++) { const fresh = db.getChannelAiBot(w.bots[i].id); if (fresh) w.bots[i] = fresh; }
- w.stable = null;
- }
- } catch (e) { poster.log(w, { event: 'error', reason: `fold: ${e.message}` }); }
- }
-
- // ── Status / preview (control panel) ─────────────────────
- status(userId) {
- const w = this.workerForUser(userId);
- const st = budget.status(userId);
- const timelineOn = (() => { try { return require('../../timeline-job').timelineEnabled(); } catch { return false; } })();
- const base = { engine: 'v3', running: !!w, mode: st.mode, budget_active: st.active, budget_reason: st.reason, spent_today_usd: st.spentToday, cap_usd: st.capUsd, timeline_enabled: timelineOn, kill_switch: this._killSwitch() };
- if (!w) return base;
- const seen = context.seenBlock(w.stream);
- const heard = context.heardBlock(w.stream, w.settings);
- return { ...base, stream_id: w.streamId, paused: w.paused, bots: w.bots.length, stats: w.stats, last_tick_at: w.lastTickAt || null, next_tick_in_ms: w.nextTickAt ? Math.max(0, w.nextTickAt - Date.now()) : null,
- queued: w.scheduler.pending(), bot_share: w.scheduler.botShare(), lines_last_minute: w.scheduler.linesLastMinute(),
- senses: { heard_age_ms: heard.ageMs, heard_lines: heard.count, seen_age_ms: seen.ageMs, seen_text: seen.text, stable_prefix_chars: w.stable ? w.stable.text.length : null, stable_cached_since: w.stable ? w.stable.at : null } };
- }
-
- /** Dry run: assemble the real context and ask the director what it would do (nothing posted). */
- async preview(userId) {
- let w = this.workerForUser(userId);
- let temp = false;
- if (!w) {
- const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();
- if (!live.length) return { error: 'Go live first — the preview runs on your real stream context.' };
- const cfg = db.getChannelAiConfig(userId); const settings = settingsMod.getSettings(userId, cfg);
- const bots = roster.ensureRoster(userId, settings.roster_size || 3);
- w = { streamId: live[0].id, userId, stream: db.getStreamById(live[0].id) || live[0], cfg, settings, bots, lastChatId: Math.max(0, db.getMaxChatMessageIdForChannel(userId) - 40), intents: [], scheduler: { botShare: () => 0 } };
- temp = true;
- }
- const s = w.settings;
- const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: w.bots, settings: s, cacheHolder: temp ? null : w });
- const tail = context.volatileTail({ userId: w.userId, stream: w.stream, settings: s, sinceChatId: Math.max(0, (w.lastChatId || 0) - 25), botNames: new Set(w.bots.map(b => b.username.toLowerCase())), intents: w.intents, mode: 'normal', linesAllowed: s.lines_per_tick, botShare: w.scheduler.botShare() });
- const res = await director.plan({ stableText: stable.text, volatileText: tail.text, maxLines: s.lines_per_tick, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`, temperature: 0.9 });
- return { plan: res ? res.plan : null, usage: res ? res.usage : null, cost: res ? res.cost : null, model: res ? res.model : null, context: { stable_chars: stable.text.length, volatile_chars: tail.text.length, sources: tail.sources }, stable_preview: stable.text.slice(0, 1200), volatile_preview: tail.text.slice(0, 1500) };
- }
-
- nudge(userId) { const w = this.workerForUser(userId); if (!w) return false; this._scheduleTick(w, 500); return true; }
-}
-
-module.exports = new AiViewersEngineV3();
deleted file mode 100644
deleted file mode 100644
@@ -1,73 +0,0 @@
@@ -1,73 +0,0 @@
-/**
- * AI viewers v3 — memory fold. One summary-role call per channel 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');
-
-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 }
- * @returns {Promise<{updated:number, cost:number}|null>}
- */
-async function foldAll(worker, { provider = null } = {}) {
- const entries = [];
- for (const bot of worker.bots) {
- const lines = worker.botLines.get(bot.id) || [];
- if (lines.length < 3) continue;
- let brain = {}; try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }
- entries.push({ bot, lines: lines.slice(-20), memory: brain.memory || '' });
- }
- if (!entries.length) return 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,
- });
- if (!r) return null;
- const out = r.json || llm.parseJsonLoose(r.text);
- if (!out || !Array.isArray(out.memories)) return null;
- let updated = 0;
- for (const m of out.memories) {
- const e = entries.find(x => x.bot.username.toLowerCase() === String(m.bot || '').toLowerCase());
- if (!e || !m.memory || !String(m.memory).trim()) continue;
- let brain = {}; try { brain = JSON.parse(e.bot.brain_json || '{}'); } catch { /* */ }
- brain.memory = clip(m.memory, 900);
- brain.timeline = (brain.timeline || []).slice(-8);
- brain.timeline.push({ n: e.bot.msg_count || 0, note: clip(e.lines[e.lines.length - 1], 80) });
- try { db.updateChannelAiBot(e.bot.id, { brain_json: brain }); worker.botLines.set(e.bot.id, []); updated++; } catch { /* */ }
- }
- if (out.channel_memory && String(out.channel_memory).trim()) {
- try {
- const cfg = db.getChannelAiConfig(worker.userId);
- let sj = {}; try { sj = JSON.parse(cfg.settings_json || '{}') || {}; } catch { /* */ }
- sj.channel_memory = clip(out.channel_memory, 600);
- db.upsertChannelAiConfig(worker.userId, { settings_json: JSON.stringify(sj) });
- } catch { /* */ }
- }
- return { updated, cost: r.cost || 0, usage: r.usage, model: r.model };
-}
-
-module.exports = { foldAll };
deleted file mode 100644
deleted file mode 100644
@@ -1,29 +0,0 @@
@@ -1,29 +0,0 @@
-/**
- * AI viewers v3 — who is being addressed?
- * detect(message, bots, { isStreamer }) → { bots: [usernames named], addressedChat, question }
- * A streamer talking to "chat"/"guys"/"everyone" or asking a question addresses everyone;
- * "@name" or a bare bot username/display name addresses that bot (anyone can do that).
- */
-'use strict';
-
-const CHAT_WORDS = /\b(chat|guys|gang|everyone|everybody|yall|y'all|you all|anyone|anybody|viewers|lads|folks|people)\b/i;
-
-function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
-
-function detect(message, bots, { isStreamer = false } = {}) {
- const text = String(message || '');
- const lower = text.toLowerCase();
- const named = [];
- for (const b of bots || []) {
- const names = [b.username, b.display_name].filter(Boolean).map(n => String(n).toLowerCase());
- for (const n of names) {
- if (n.length < 3) continue;
- if (lower.includes('@' + n) || new RegExp(`(^|[^a-z0-9_])${escapeRe(n)}([^a-z0-9_]|$)`, 'i').test(text)) { named.push(b.username); break; }
- }
- }
- const question = /\?\s*$/.test(text.trim()) || /\b(what do you|whatd?ya|do you (guys|all)|should i|thoughts\??|opinions?\??|who here|anyone (here|know|want))\b/i.test(text);
- const addressedChat = isStreamer && (CHAT_WORDS.test(text) || question || /^(so|ok|okay|alright|hey|yo)\b/i.test(text.trim()) && text.length < 120);
- return { bots: [...new Set(named)], addressedChat, question };
-}
-
-module.exports = { detect };
deleted file mode 100644
deleted file mode 100644
@@ -1,106 +0,0 @@
@@ -1,106 +0,0 @@
-/**
- * AI viewers v3 — posting a bot line into chat (+ moderation + activity log).
- * Ported from the v2 engine's _inject, with the bits it skipped: word filter, per-bot
- * TTS toggle, PowerChat forward toggle, and a log row for the control panel.
- */
-'use strict';
-const db = require('../../../db/database');
-
-function botColor(bot) {
- try { return (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || '#8a8aff'; } catch { return bot.avatar_color || '#8a8aff'; }
-}
-function botPersona(bot) { try { return JSON.parse(bot.persona_json || '{}') || {}; } catch { return {}; } }
-
-/** Strip quotes/disclaimers; enforce a word cap. Returns '' when the line must be dropped. */
-function clean(text, maxWords = 18) {
- let t = String(text || '').trim();
- t = t.replace(/^["'`“”]+|["'`“”]+$/g, '').replace(/\s+/g, ' ').trim();
- if (!t) return '';
- if (/\b(as an ai|language model|i am a bot|i'm a bot|as a bot|openai|anthropic)\b/i.test(t)) return '';
- if (/^\w+:\s/.test(t) && t.length < 80) t = t.replace(/^\w+:\s/, ''); // "botname: text" echo
- const words = t.split(' ');
- if (words.length > maxWords) t = words.slice(0, maxWords).join(' ').replace(/[,;:]$/, '');
- if (t.length > 240) t = t.slice(0, 240);
- return t.trim();
-}
-
-/** Word-filter gate — returns { ok, text, reason }. */
-function moderate(text) {
- try {
- const wf = require('../../../chat/word-filter');
- const r = wf.check(text);
- if (!r.safe) return { ok: false, text, reason: `word filter: ${r.matches.slice(0, 3).join(', ')}` };
- if (typeof wf.isSpam === 'function' && wf.isSpam(text)) return { ok: false, text, reason: 'spam pattern' };
- } catch { /* filter unavailable → allow */ }
- return { ok: true, text, reason: null };
-}
-
-/**
- * Post a line. Returns the chat message id (or null).
- * @param {object} worker { streamId, userId, settings }
- */
-function post(worker, bot, message, { threadId = null, replyToId = null } = {}) {
- const streamId = worker.streamId;
- const persona = botPersona(bot);
- const color = botColor(bot);
- let id = null;
- try {
- const res = db.saveChatMessage({
- stream_id: streamId,
- channel_user_id: worker.userId,
- user_id: null,
- username: bot.username,
- message,
- message_type: 'chat',
- is_global: false,
- reply_to_id: replyToId || null,
- source_platform: 'ai', // marker → excluded from chat-AI feeds
- metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },
- });
- id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;
- } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }
-
- const chatMsg = {
- type: 'chat', id: id || undefined,
- username: bot.username, core_username: null,
- user_id: null, anon_id: null, role: 'user',
- message, stream_id: streamId, is_global: false,
- reply_to_id: replyToId || null,
- avatar_url: null, profile_color: color,
- is_ai: true, source_platform: 'ai',
- filtered: false, timestamp: new Date().toISOString(),
- };
- try {
- const chatServer = require('../../../chat/chat-server');
- chatServer.broadcastToStream(streamId, chatMsg);
- chatServer.forwardToGlobal(streamId, chatMsg);
- const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;
- if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);
- } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }
-
- if (worker.settings.powerchat_forward !== false) {
- try {
- const pc = require('../../../integrations/powerchat-platform');
- if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {
- pc.forwardChat(worker.userId, {
- chatterName: bot.username,
- externalChatterId: `ai:${bot.username.toLowerCase()}`,
- message,
- messageId: id ? `ov-${id}` : undefined,
- avatarFallback: '🤖',
- });
- }
- } catch { /* non-critical */ }
- }
- try { db.touchChannelAiBot(bot.id); } catch { /* */ }
- return id;
-}
-
-/** Append a row to ai_viewer_log (best-effort). */
-function log(worker, row) {
- try {
- db.addAiViewerLog({ channel_user_id: worker.userId, stream_id: worker.streamId || null, ...row });
- } catch { /* */ }
-}
-
-module.exports = { post, clean, moderate, log, botPersona, botColor };
deleted file mode 100644
deleted file mode 100644
@@ -1,97 +0,0 @@
@@ -1,97 +0,0 @@
-/**
- * AI viewers v3 — pacing. Planned lines go into a per-stream queue and are released
- * humanly: a minimum gap between any two bot lines, a per-minute ceiling, slow-mode
- * awareness, no bot twice in a row, and a rolling bot-to-bot share cap.
- */
-'use strict';
-
-function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
-
-class Scheduler {
- constructor(worker, deps) {
- this.worker = worker; // { streamId, settings, ... }
- this.deps = deps; // { post(line), now() }
- this.queue = []; // [{ bot, text, target, replyTo, threadId, postAt, prio, reason }]
- this.recent = []; // [{ at, target, bot }] last 40 posted
- this.lastPostAt = 0;
- this.lastBot = null;
- this.timer = null;
- this.stopped = false;
- }
-
- settings() { return this.worker.settings || {}; }
-
- /** Queue planned lines (from a director pass). */
- enqueue(lines, { basePrio = 1 } = {}) {
- const now = Date.now();
- for (const l of lines) {
- const jitter = rint(0, 2500);
- const prio = l.target === 'streamer' ? 3 : basePrio;
- this.queue.push({ ...l, prio, postAt: now + (prio === 3 ? Math.min(l.delay_ms || 0, 4000) : (l.delay_ms || 0)) + jitter });
- }
- // Keep the queue bounded: newest plans win over stale unposted ambient chatter.
- if (this.queue.length > 12) this.queue = this.queue.sort((a, b) => b.prio - a.prio || a.postAt - b.postAt).slice(0, 12);
- this._arm();
- }
-
- /** Drop everything not yet posted (config change / pause / stop). */
- clear() { this.queue = []; if (this.timer) { clearTimeout(this.timer); this.timer = null; } }
-
- stop() { this.stopped = true; this.clear(); }
-
- pending() { return this.queue.length; }
-
- /** Share of bot→bot lines over the last 20 posts (0..1). */
- botShare() {
- const last = this.recent.slice(-20);
- if (!last.length) return 0;
- return last.filter(r => r.target === 'bot').length / last.length;
- }
- linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }
-
- _slowModeMs() {
- try { const cs = require('../../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }
- }
-
- _arm(delay = null) {
- if (this.stopped || this.timer || !this.queue.length) return;
- const next = Math.min(...this.queue.map(q => q.postAt));
- const wait = delay != null ? delay : Math.max(250, next - Date.now());
- this.timer = setTimeout(() => { this.timer = null; this._drain(); }, wait);
- }
-
- _drain() {
- if (this.stopped || !this.queue.length) return;
- const s = this.settings();
- const now = Date.now();
- const minGap = Math.max((s.min_gap_sec || 4) * 1000, this._slowModeMs());
- if (now - this.lastPostAt < minGap) return this._arm(minGap - (now - this.lastPostAt));
- const perMin = Math.max(0.5, s.lines_per_min || 3);
- if (this.linesLastMinute() >= perMin) return this._arm(5000);
-
- // Pick the highest-priority due line that doesn't violate the alternation/ratio rules.
- const due = this.queue.filter(q => q.postAt <= now).sort((a, b) => b.prio - a.prio || a.postAt - b.postAt);
- if (!due.length) return this._arm();
- let pick = null;
- for (const q of due) {
- if (q.bot === this.lastBot && due.length > 1) continue;
- if (q.target === 'bot' && this.botShare() >= (s.bot_to_bot_ratio || 0) + 0.05 && q.prio < 3) continue;
- pick = q; break;
- }
- if (!pick) {
- // Everything due is blocked by the ratio/alternation rules — drop stale bot↔bot chatter.
- const stale = due.find(q => q.target === 'bot' && q.prio < 3);
- if (stale) { this.queue = this.queue.filter(q => q !== stale); this.deps.dropped && this.deps.dropped(stale, 'bot-to-bot share cap'); }
- return this._arm(3000);
- }
- this.queue = this.queue.filter(q => q !== pick);
- try { this.deps.post(pick); } catch (e) { console.warn('[AI-Viewers] post failed:', e.message); }
- this.lastPostAt = Date.now();
- this.lastBot = pick.bot;
- this.recent.push({ at: this.lastPostAt, target: pick.target, bot: pick.bot });
- if (this.recent.length > 40) this.recent.shift();
- this._arm();
- }
-}
-
-module.exports = { Scheduler };
@@ -1079,7 +1079,6 @@ function initDb() {
@@ -1079,7 +1079,6 @@ function initDb() {
['ai_model_summary', '', 'Model for summaries/overviews/memory folds. Blank = ai_model', 'string'],
['ai_model_summary', '', 'Model for summaries/overviews/memory folds. Blank = ai_model', 'string'],
['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"gpt-5-mini":{"in":0.25,"out":2,"cached":0.025},"gpt-5":{"in":1.25,"out":10,"cached":0.125},"gpt-4o-mini":{"in":0.15,"out":0.6,"cached":0.075},"gpt-4.1-mini":{"in":0.4,"out":1.6,"cached":0.1},"claude-haiku":{"in":0.8,"out":4,"cached":0.08},"claude-sonnet":{"in":3,"out":15,"cached":0.3}}', 'USD per 1M tokens by model-id prefix: {"<model>":{"in","out","cached"}}. Longest prefix wins; missing → ai_input/output_cost_per_mtok', 'json'],
['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"gpt-5-mini":{"in":0.25,"out":2,"cached":0.025},"gpt-5":{"in":1.25,"out":10,"cached":0.125},"gpt-4o-mini":{"in":0.15,"out":0.6,"cached":0.075},"gpt-4.1-mini":{"in":0.4,"out":1.6,"cached":0.1},"claude-haiku":{"in":0.8,"out":4,"cached":0.08},"claude-sonnet":{"in":3,"out":15,"cached":0.3}}', 'USD per 1M tokens by model-id prefix: {"<model>":{"in","out","cached"}}. Longest prefix wins; missing → ai_input/output_cost_per_mtok', 'json'],
['ai_viewers_enabled', 'true', 'Kill switch for the AI chat viewers feature (all channels)', 'boolean'],
['ai_viewers_enabled', 'true', 'Kill switch for the AI chat viewers feature (all channels)', 'boolean'],
- ['ai_viewers_engine', 'v2', 'AI viewers engine: v2 (legacy per-line) | v3 (director, unified context)', 'string'],
['ai_viewers_max_roster', '12', 'Max AI viewers per channel', 'number'],
['ai_viewers_max_roster', '12', 'Max AI viewers per channel', 'number'],
['ai_viewers_max_lines_per_min', '12', 'Hard ceiling on bot lines per minute per channel', 'number'],
['ai_viewers_max_lines_per_min', '12', 'Hard ceiling on bot lines per minute per channel', 'number'],
['ai_viewers_global_cap_usd_per_day', '0', 'Daily USD cap for ALL AI-viewer spend on the shared key (0 = none)', 'number'],
['ai_viewers_global_cap_usd_per_day', '0', 'Daily USD cap for ALL AI-viewer spend on the shared key (0 = none)', 'number'],
@@ -1087,6 +1086,7 @@ function initDb() {
@@ -1087,6 +1086,7 @@ function initDb() {
];
];
const seedAi = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
const seedAi = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
for (const [k, v, d, t] of aiSeeds) seedAi.run(k, v, d, t);
for (const [k, v, d, t] of aiSeeds) seedAi.run(k, v, d, t);
+ try { database.exec("DELETE FROM site_settings WHERE key = 'ai_viewers_engine'"); } catch { /* */ }
// PowerChat monetization (donations/tips). App-level OAuth client + webhook secret
// PowerChat monetization (donations/tips). App-level OAuth client + webhook secret
// are configured here by the owner; each streamer then connects their own PowerChat
// are configured here by the owner; each streamer then connects their own PowerChat