openvibers/openvibe.ai Commits
Move Live's AI here: import script for its AI records, migration plan, and the Live patch that adds AI_SERVICE=remote
Changes
nnew file mode 100644@@@ -0,0 +1,596 @@++diff --git a/.env.example b/.env.example++index 31f53b7..6911d85 100644++--- a/.env.example++@@ -16,6 +16,15 @@ ADMIN_PASSWORD=changeme123++ # channels keep using the English model and live captions stay hidden.++ # WHISPER_MODEL_MULTI=/home/openvibe/whisper.cpp/models/ggml-small.bin+++++# OpenVibe.AI (roadmap Wave 13). AI_SERVICE=remote sends Live's shared-key AI calls (chat AI, moments,+++# paste/frame analysis, overviews, translation, recaps, slogans, arena, footer copy) to the AI service as+++# workflow runs, with a service token for audience openvibe.ai (grant ai.run.create for client `live`).+++# The admin ai_enabled switch and the local daily cap still apply; the provider key lives in the AI+++# service. Streamer BYO keys and local whisper.cpp transcription stay here. Unset = Live calls+++# providers itself, exactly as before.+++#AI_SERVICE=remote+++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700+++++ # Server++ PORT=3000++ HOST=0.0.0.0++diff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js++index 559a58a..70297da 100644++--- a/server/ai/ai-analysis.js++@@ -16,7 +16,10 @@ function b(k) { const v = db.getSetting(k); return v === true || v === 'true' ||++ function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }++++ const llm = require('./llm');+++const aiService = require('./ai-service');++ function isEnabled() { return llm.isEnabled(); }+++// AI_SERVICE=remote: the analyses below whose prompt moved to OpenVibe.AI run as its workflows.+++const remote = () => aiService.enabled() && isEnabled() && withinBudget();++ function pasteAnalysisEnabled() { return isEnabled() && b('ai_paste_analysis_enabled'); }++ function streamMemoryEnabled() { return isEnabled() && b('ai_stream_memory_enabled'); }++ // Local whisper.cpp transcription (default on when installed). Independent of the++@@ -121,6 +124,12 @@ async function _toVisionJpeg(image, opts = {}) {++++ /** Describe an image paste → { description, tags }. */++ async function analyzeImagePaste(image, title, kind = 'paste_image') {+++ if (aiService.enabled()) {+++ if (!remote()) return null;+++ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 1280 });+++ const o = img ? await aiService.structured('live.paste.describe_image', { title: String(title || '').slice(0, 500), image: img }, { meter: { kind, role: 'vision' } }) : null;+++ return o && o.description ? { description: o.description, tags: o.tags || [] } : null;+++ }++ const prompt = `You are describing an uploaded image/screenshot for a paste titled "${(title || '').slice(0, 120)}".++ Reply ONLY with compact JSON: {"description":"1-2 sentence description of what the image shows","tags":["3-6","short","lowercase","tags"]}.`;++ const text = await _complete({ prompt, image, maxTokens: 300, kind, role: 'vision', imageMaxWidth: 1280 });++@@ -131,6 +140,11 @@ Reply ONLY with compact JSON: {"description":"1-2 sentence description of what t++++ /** Summarize a text paste → { description }. */++ async function analyzeTextPaste(content, title) {+++ if (aiService.enabled()) {+++ if (!remote() || !String(content || '').trim()) return null;+++ const o = await aiService.structured('live.paste.summarize_text', { title: String(title || '').slice(0, 500), content: String(content || '').slice(0, 200000) }, { meter: { kind: 'paste_text', role: 'legacy' } });+++ return o && o.description ? { description: o.description, tags: [] } : null;+++ }++ const snippet = String(content || '').slice(0, 6000);++ const prompt = `Summarize what this pasted text is about in one short sentence (max 200 chars), plainly. Title: "${(title || '').slice(0, 120)}".\n\n---\n${snippet}`;++ const text = await _complete({ prompt, maxTokens: 120, kind: 'paste_text' });++@@ -139,6 +153,12 @@ async function analyzeTextPaste(content, title) {++++ /** Analyze a live-stream frame → { description, tags }. */++ async function analyzeStreamFrame(image) {+++ if (aiService.enabled()) {+++ if (!remote()) return null;+++ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 768 });+++ const o = img ? await aiService.structured('live.stream.describe_frame', { image: img }, { meter: { kind: 'stream_memory', role: 'vision' } }) : null;+++ return o && o.description ? { description: o.description, tags: o.tags || [], worthy: o.worthy === true, title: o.worthy === true ? String(o.title || '').slice(0, 80) : '' } : null;+++ }++ // One vision call does three jobs: the memory description, the tags, and a screenshot-worthiness++ // verdict + caption (so live pastes need no extra call).++ const prompt = `This is a frame from a live stream. Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a face/reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption for it, else empty>"}.`;++@@ -160,6 +180,18 @@ async function summarizeStreamMemories(memories, streamId = null) {++ // overview reflects the entire stream since it started, not just the latest frame.++ const lines = (memories || []).slice(-80).map(m => `- ${m.description}`).join('\n');++ if (!lines) return null;+++ if (aiService.enabled()) {+++ if (!remote()) return null;+++ const input = { observations: (memories || []).slice(-80).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean) };+++ if (streamId) {+++ try {+++ input.speech = (db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || []).slice(-200).map(r => ({ start_sec: Number(r.start_sec) || 0, text: String(r.text || '').slice(0, 2000) }));+++ input.sounds = (db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || []).slice(-60).map(r => ({ start_sec: Number(r.start_sec) || 0, label: String(r.label || '').slice(0, 120), confidence: Number(r.confidence || 0) }));+++ } catch { /* timeline optional */ }+++ }+++ const o = await aiService.structured('live.stream.summarize', input, { target: streamId ? { service: 'live', type: 'stream', id: String(streamId) } : undefined, meter: { kind: 'stream_memory', role: 'legacy' } });+++ return o && o.overview ? { overview: o.overview, category: normalizeCategory(o.category), tags: Array.isArray(o.tags) ? o.tags.map(String).slice(0, 6) : [] } : null;+++ }++++ // Fold in the audio timeline when one exists. Previously the transcript reached this++ // prompt only as a 500-char slice embedded inside a memory's description string, with++@@ -230,7 +262,14 @@ async function generateStreamerOverview(userId) {++++ const prompt = `You are building an internal profile of a livestreamer for site staff, using aggregated signals across their streams, VODs, and pastes. Write a concise overview (4-8 sentences) covering: what they stream / their content niche, recurring themes or activities, tone/vibe, and anything notable for moderation. Be factual and neutral; do NOT invent specifics that aren't supported by the signals below.\n\n${ctx}`;++++- const text = await _complete({ prompt, maxTokens: 550, kind: 'streamer_overview' });+++ const text = aiService.enabled()+++ ? ((await aiService.structured('live.streamer.overview', {+++ streamer: { username: String(user.username), display_name: String(user.display_name || user.username), bio: String(channel?.bio || user.bio || '').slice(0, 2000), category: (channel?.ai_category || channel?.category) ? String(channel.ai_category || channel.category) : undefined, category_inferred: Boolean(channel?.ai_category) },+++ memories: memories.slice(0, 40).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean),+++ vods: vods.map(v => ({ title: String(v.title || 'Untitled VOD').slice(0, 300), category: (v.ai_category || v.category) ? String(v.ai_category || v.category) : undefined })),+++ pastes: pastes.filter(p => p.ai_summary).map(p => ({ title: String(p.title || 'paste').slice(0, 300), summary: String(p.ai_summary).slice(0, 600) })),+++ }, { target: { service: 'live', type: 'user', id: String(userId) }, meter: { kind: 'streamer_overview', role: 'legacy' } })) || {}).overview || null+++ : await _complete({ prompt, maxTokens: 550, kind: 'streamer_overview' });++ if (!text) return null;++ const overview = text.slice(0, 4000);++ try {++diff --git a/server/ai/ai-service.js b/server/ai/ai-service.js++new file mode 100644++index 0000000..9b0ef43++--- /dev/null++@@ -0,0 +1,181 @@+++/**+++ * ai-service.js — Live as a CONSUMER of OpenVibe.AI (roadmap Wave 13).+++ *+++ * With AI_SERVICE=remote, Live stops calling model providers itself: every shared-key AI call+++ * becomes a workflow run on OpenVibe.AI (ai.openvibe.network, internal OV_AI_INTERNAL_URL),+++ * authenticated with Live's Network service token for audience openvibe.ai (network-principal).+++ * Unset (the default), nothing here is used and Live behaves exactly as before.+++ *+++ * structured(workflow, input, opts) -> the run's output object, or null+++ * for workflows whose prompt now lives in OpenVibe.AI (live.translate, live.paste.*,+++ * live.stream.*, live.streamer.overview, live.media.overview, live.stream.recap,+++ * network.site_copy)+++ * complete(o, { toVisionJpeg }) -> the llm.complete() result shape, or null+++ * for every other llm.complete() call: the prompt Live rendered is sent as a passthrough run of+++ * the workflow that owns that feature (KIND_TO_WORKFLOW), so each run is still attributable+++ * to one workflow + model + run+++ *+++ * null means "no AI answer" exactly like llm.complete() returning null today (AI off, over+++ * budget, provider down): quota refusals (429), failed runs, unreachable service, and SYNTHETIC+++ * answers (the AI service's stub provider) all come back as null — synthetic text never reaches+++ * a Live page. Streamer BYO providers are not routed here (llm.js keeps calling them directly).+++ */+++'use strict';+++const principal = require('../net/network-principal');++++++const AUDIENCE = 'openvibe.ai';+++const TERMINAL = new Set(['succeeded', 'cached', 'failed', 'cancelled']);++++++/** llm.complete() kind -> the OpenVibe.AI workflow that owns that feature (same table as the service). */+++const KIND_TO_WORKFLOW = {+++ ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',+++ chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',+++ moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',+++ arena_persona: 'live.arena.persona', arena_quotes: 'live.arena.quotes', arena_scene: 'live.arena.scene', arena_beef_judge: 'live.arena.judge', arena_mic_judge: 'live.arena.judge', arena_headline: 'live.arena.headline',+++ status_check: 'live.status_check',+++};+++const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];++++++function enabled() { return String(process.env.AI_SERVICE || '').trim().toLowerCase() === 'remote'; }+++function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }+++function workflowFor(kind) { return KIND_TO_WORKFLOW[kind] || 'live.complete'; }++++++let _lastWarn = 0;+++function warn(msg) {+++ if (Date.now() - _lastWarn < 60_000) return;+++ _lastWarn = Date.now();+++ console.warn(`[AI service] ${msg}`);+++}++++++async function _fetchJson(method, path, body, timeoutMs, retried = false) {+++ let headers;+++ try { headers = await principal.serviceHeaders(AUDIENCE); } catch (e) { warn(`no service token for ${AUDIENCE}: ${e.message}`); return null; }+++ let res;+++ try {+++ res = await fetch(`${baseUrl()}${path}`, {+++ method,+++ headers: { ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },+++ body: body ? JSON.stringify(body) : undefined,+++ signal: AbortSignal.timeout(timeoutMs),+++ });+++ } catch (e) { warn(`${method} ${path} failed: ${e.message}`); return null; }+++ const json = await res.json().catch(() => null);+++ if (res.status === 401 && !retried) { // stale/rotated token: fetch a new one once+++ principal.invalidate(AUDIENCE);+++ return _fetchJson(method, path, body, timeoutMs, true);+++ }+++ if (res.status === 429) { warn(`quota: ${(json && json.detail) || 'refused'} (retry after ${res.headers.get('retry-after') || '?'}s)`); return null; }+++ if (!res.ok && res.status !== 202) { warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`); return null; }+++ return json;+++}++++++/**+++ * Run a workflow and wait for it. Returns the run object (any terminal status) or null.+++ * opts: { target, attribution, idempotencyKey, waitMs (default 60s), timeoutMs }+++ */+++async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000 } = {}) {+++ if (!enabled()) return null;+++ const wait = Math.max(0, Math.min(60000, waitMs));+++ const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {+++ workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,+++ }, wait + 15000);+++ let r = created && created.run;+++ if (!r) return null;+++ // Still queued/running after the wait: poll briefly (the service caps a wait at 60 s).+++ const deadline = Date.now() + wait;+++ while (!TERMINAL.has(r.status) && Date.now() < deadline) {+++ await new Promise(res => setTimeout(res, 1000));+++ const again = await _fetchJson('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`, null, 15000);+++ if (!again || !again.run) return null;+++ r = again.run;+++ }+++ return r;+++}++++++/** A usable (real, finished) output or null. */+++function usable(r) {+++ if (!r || !['succeeded', 'cached'].includes(r.status)) return null;+++ if (r.synthetic) { warn(`${r.workflow && r.workflow.key} answered from the stub provider; treating it as no answer`); return null; }+++ return r.output || null;+++}++++++// Live's ai_usage table keeps metering remote runs too (per-streamer budgets, admin cost views);+++// llm.js installs the recorder so this module stays free of the database.+++let _recorder = null;+++function setRecorder(fn) { _recorder = typeof fn === 'function' ? fn : null; }+++function meter(r, m) {+++ if (!_recorder || !m || !r || r.status !== 'succeeded' || r.synthetic) return;+++ try { _recorder(r, m); } catch { /* metering is best-effort */ }+++}++++++/** opts: run() options plus meter: { kind, role, ownerUserId, source } for Live's ai_usage row. */+++async function structured(workflow, input, opts = {}) {+++ const r = await run(workflow, input, { ...opts, attribution: opts.attribution || ownerRef(opts.meter && opts.meter.ownerUserId) });+++ meter(r, opts.meter);+++ return usable(r);+++}++++++/** EntityRef for the Live user a spend is attributed to (Live's per-streamer budgets). */+++function ownerRef(ownerUserId) {+++ const id = parseInt(ownerUserId, 10);+++ return id > 0 ? { service: 'live', type: 'user', id: String(id) } : undefined;+++}++++++/**+++ * An image for a workflow input. OpenVibe.Media URLs stay URLs (the service fetches only+++ * allow-listed OpenVibe hosts); files, buffers and any other URL are downscaled here, as before,+++ * and sent inline.+++ */+++async function imageInput(image, { toVisionJpeg, maxWidth = 1024 } = {}) {+++ if (!image) return null;+++ if (typeof image === 'string' && /^https:\/\/openvibe\.media\//i.test(image)) return { url: image, max_width: maxWidth };+++ const dataUrl = toVisionJpeg ? await toVisionJpeg(image, { maxWidth, quality: 78 }) : (typeof image === 'string' && image.startsWith('data:') ? image : null);+++ return dataUrl ? { data_url: dataUrl, max_width: maxWidth } : null;+++}++++++/**+++ * llm.complete() over the service. Same argument object; same result shape+++ * ({ text, json, usage, model, provider, shared, latencyMs, cost }) or null.+++ */+++async function complete(o = {}, { toVisionJpeg } = {}) {+++ const role = ROLES.includes(o.role) ? o.role : 'legacy';+++ let image = null;+++ if (o.image) {+++ image = await imageInput(o.image, { toVisionJpeg, maxWidth: o.imageMaxWidth || 1024 });+++ if (!image) return null;+++ }+++ const system = typeof o.system === 'string' ? o.system+++ : Array.isArray(o.system) ? o.system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: Boolean(x.cache) })) : undefined;+++ const input = {+++ role, kind: o.kind ? String(o.kind).slice(0, 64) : undefined, source: o.source ? String(o.source).slice(0, 64) : undefined,+++ system: system && system.length ? system : undefined,+++ messages: Array.isArray(o.messages) && o.messages.length ? o.messages.filter(m => m && m.role).map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })) : undefined,+++ user: o.user != null ? String(o.user) : undefined,+++ image: image || undefined,+++ json: o.json ? { name: o.json.name, schema: o.json.schema, strict: o.json.strict, description: o.json.description } : undefined,+++ max_tokens: Math.max(1, Math.round(o.maxTokens || 400)),+++ temperature: o.temperature == null ? undefined : o.temperature,+++ timeout_ms: o.timeoutMs ? Math.max(1000, Math.min(120000, o.timeoutMs)) : undefined,+++ cache_key: o.cacheKey ? String(o.cacheKey).slice(0, 128) : undefined,+++ };+++ if (!input.user && !input.messages) input.user = '';+++ const started = Date.now();+++ const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2) });+++ const out = usable(r);+++ if (!out) return null;+++ const usage = { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 };+++ return {+++ text: typeof out.text === 'string' ? out.text : '',+++ json: out.json || null,+++ usage,+++ model: (r.provenance && r.provenance.model) || null,+++ provider: 'openvibe-ai',+++ shared: true,+++ latencyMs: Date.now() - started,+++ cost: (r.usage && r.usage.cost_usd) || 0,+++ runId: r.id,+++ workflow: r.workflow && r.workflow.key,+++ };+++}++++++module.exports = { enabled, run, structured, complete, usable, imageInput, ownerRef, workflowFor, setRecorder, KIND_TO_WORKFLOW, AUDIENCE };++diff --git a/server/ai/llm.js b/server/ai/llm.js++index 6824fed..67dc39c 100644++--- a/server/ai/llm.js++@@ -21,6 +21,7 @@++ 'use strict';++ const fs = require('fs');++ const db = require('../db/database');+++const aiService = require('./ai-service');++++ const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];++ const DEFAULT_TIMEOUT_MS = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };++@@ -30,7 +31,8 @@ function b(k) { const v = db.getSetting(k); return v === true || v === 'true' ||++ function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }++++ // ── Gates ────────────────────────────────────────────────────++-function isEnabled() { return b('ai_enabled') && !!s('ai_api_key'); }+++// AI_SERVICE=remote: the provider key lives in OpenVibe.AI, so the admin master switch alone decides.+++function isEnabled() { return b('ai_enabled') && (aiService.enabled() || !!s('ai_api_key')); }++ function withinBudget() {++ const cap = num('ai_max_cost_usd_per_day', 0);++ if (!cap || cap <= 0) return true;++@@ -294,6 +296,22 @@ function parseJsonLoose(text) {++ */++ async function complete(o = {}) {++ const role = ROLES.includes(o.role) ? o.role : 'legacy';+++ // AI_SERVICE=remote: shared-key calls become workflow runs on OpenVibe.AI (a streamer's BYO+++ // provider override still goes straight to that provider, below). Usage is still recorded here+++ // so per-streamer budgets and the admin cost views keep working unchanged.+++ if (aiService.enabled() && !(o.provider && (o.provider.apiKey || o.provider.baseUrl))) {+++ if (!isEnabled() || !withinBudget()) return null;+++ const r = await aiService.complete({ ...o, role }, { toVisionJpeg });+++ if (!r) { console.warn(`[AI] ${role}/${o.kind || role} via OpenVibe.AI returned no answer`); return null; }+++ if (o.json && !r.json) r.json = parseJsonLoose(r.text);+++ try {+++ db.recordAiUsage({+++ kind: o.kind || role, model: r.model, input_tokens: r.usage.input, output_tokens: r.usage.output, cached_tokens: r.usage.cached || 0,+++ cost_usd: r.cost || 0, owner_user_id: o.ownerUserId || null, source: o.source || null, role, provider: 'openvibe-ai', latency_ms: r.latencyMs,+++ });+++ } catch { /* metering is best-effort */ }+++ return r;+++ }++ const p = resolveProvider(role, o.provider || null);++ if (p.shared) { if (!isEnabled() || !withinBudget()) return null; }++ else if (!p.apiKey && !/localhost|127\.0\.0\.1|:\d+$/.test(p.baseUrl) && /api\.openai\.com|anthropic\.com/i.test(p.baseUrl)) return null;++@@ -357,4 +375,11 @@ async function testProvider(override = null) {++ } catch (e) { return { ok: false, error: e.message }; }++ }+++++// Remote structured workflows (translate, paste/frame analysis, overviews, recap) meter here too.+++aiService.setRecorder((r, m) => db.recordAiUsage({+++ kind: m.kind || (r.workflow && r.workflow.key) || 'remote', model: (r.provenance && r.provenance.model) || null,+++ input_tokens: (r.usage && r.usage.tokens_in) || 0, output_tokens: (r.usage && r.usage.tokens_out) || 0, cached_tokens: 0,+++ cost_usd: (r.usage && r.usage.cost_usd) || 0, owner_user_id: m.ownerUserId || null, source: m.source || null, role: m.role || null, provider: 'openvibe-ai', latency_ms: null,+++}));+++++ module.exports = { complete, testProvider, resolveProvider, modelForRole, priceFor, estimateCost, toVisionJpeg, parseJsonLoose, isEnabled, withinBudget, defaultModel, ROLES };++diff --git a/server/ai/media-analysis.js b/server/ai/media-analysis.js++index 3b4421c..a1da565 100644++--- a/server/ai/media-analysis.js++@@ -297,7 +297,14 @@ async function analyzeMedia(src, { streamId = null, userId = null, numFrames = n++ if (frames.length) parts.push('Visual observations across the video (in order):\n' + frames.map(f => `- ${f.description}`).join('\n'));++ if (transcript) parts.push('Audio transcript (sampled from the recording):\n"' + transcript.slice(0, 4000) + '"');++ const prompt = `You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don't invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n${parts.join('\n\n')}`;++- overview = await ai.summarizeText(prompt, 400, 'media_overview');+++ const aiService = require('./ai-service');+++ if (aiService.enabled()) {+++ // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.media.overview).+++ const o = ai.isEnabled() && ai.withinBudget()+++ ? await aiService.structured('live.media.overview', { frames: frames.map(f => String(f.description || '').slice(0, 1000)).filter(Boolean).slice(0, 60), transcript: String(transcript || '').slice(0, 200000) }, { meter: { kind: 'media_overview', role: 'legacy', ownerUserId: userId } })+++ : null;+++ overview = o && o.overview ? o.overview : null;+++ } else overview = await ai.summarizeText(prompt, 400, 'media_overview');++ if (overview) overview = overview.slice(0, 2000);++ }++ return { overview: overview || null, transcript: transcript || '', segments: segments || [], frames, duration };++diff --git a/server/i18n/translate.js b/server/i18n/translate.js++index 64209fd..86514ef 100644++--- a/server/i18n/translate.js++@@ -23,6 +23,8 @@ const crypto = require('crypto');++ const db = require('../db/database');++ let llm = null;++ try { llm = require('../ai/llm'); } catch { llm = null; }+++let aiService = null;+++try { aiService = require('../ai/ai-service'); } catch { aiService = null; }++++ const LANG_NAMES = {++ en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', ar: 'Arabic',++@@ -191,6 +193,16 @@ async function translate(text, { from = 'auto', to = 'en', context = 'chat', max++ const ok = await _slot();++ if (!ok) return null;++ try {+++ if (aiService && aiService.enabled()) {+++ // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.translate).+++ const o = await aiService.structured('live.translate', { text: String(text).trim(), from, to, context, max_tokens: maxTokens || undefined });+++ if (o && o.unchanged) { _memSet(key, ''); return null; }+++ const remote = o && typeof o.text === 'string' ? o.text.trim() : '';+++ if (!remote) return null;+++ _memSet(key, remote);+++ try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, remote]); } catch { /* */ }+++ return remote;+++ }++ const src = from === 'auto' ? 'the source language' : langName(from);++ const kind = context === 'bio' ? 'a streamer\'s profile bio'++ : context === 'speech' ? 'lines of live-stream speech (a casual gamer talking to his chat)'++diff --git a/server/internal/routes.js b/server/internal/routes.js++index 540fcb2..f5b4007 100644++--- a/server/internal/routes.js++@@ -32,6 +32,15 @@ router.post('/ai/site-copy', async (req, res) => {++ const linkList = links.map(l => ({ id: clean(l.id, 60), name: clean(l.name, 60), about: clean(l.about, 120) }));++ _siteCopyBusy = true;++ try {+++ const aiService = require('../ai/ai-service');+++ if (aiService.enabled()) {+++ // AI_SERVICE=remote: OpenVibe.AI owns this workflow (network.site_copy). Network should call it+++ // directly; until it does, this endpoint answers from there instead of from Live's own key.+++ const run = await aiService.run('network.site_copy', { sites: facts, links: linkList });+++ const out = aiService.usable(run);+++ if (!out || !Array.isArray(out.sites)) return res.status(503).json({ ok: false, error: 'AI unavailable or over budget' });+++ return res.json({ ok: true, model: (run.provenance && run.provenance.model) || null, run_id: run.id, sites: out.sites });+++ }++ const llm = require('../ai/llm');++ const out = await llm.complete({++ role: 'summary', kind: 'site_copy', source: 'network-footer', maxTokens: 2200, temperature: 0.7, timeoutMs: 60000,++diff --git a/server/recap/recap.js b/server/recap/recap.js++index 9bafab8..bb131e6 100644++--- a/server/recap/recap.js++@@ -148,8 +148,15 @@ async function aiWriteup(g) {++ };++ const system = `You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.`;++ let r = null;++- try { r = await llm.complete({ role: 'summary', kind: 'stream_recap', source: 'recap', ownerUserId: g.streamer.id, system, user: JSON.stringify(facts), json: SCHEMA, maxTokens: 500, temperature: 0.8, timeoutMs: 40000 }); } catch (e) { console.warn('[Recap] model call failed:', e.message); return null; }++- const out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : null));+++ let out = null;+++ const aiService = require('../ai/ai-service');+++ if (aiService.enabled()) {+++ // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.stream.recap).+++ out = await aiService.structured('live.stream.recap', { facts }, { target: { service: 'live', type: 'stream', id: String(g.stream.id) }, meter: { kind: 'stream_recap', role: 'summary', source: 'recap', ownerUserId: g.streamer.id } });+++ } else {+++ try { r = await llm.complete({ role: 'summary', kind: 'stream_recap', source: 'recap', ownerUserId: g.streamer.id, system, user: JSON.stringify(facts), json: SCHEMA, maxTokens: 500, temperature: 0.8, timeoutMs: 40000 }); } catch (e) { console.warn('[Recap] model call failed:', e.message); return null; }+++ out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : null));+++ }++ if (!out || !out.headline || !out.summary) return null;++ return { headline: String(out.headline).trim().slice(0, 90), summary: String(out.summary).trim().slice(0, 500), moment: String(out.moment || '').trim().slice(0, 200), tags: (Array.isArray(out.tags) ? out.tags : []).map(t => String(t).trim().toLowerCase()).filter(Boolean).slice(0, 4), grade: ['S', 'A', 'B', 'C'].includes(out.grade) ? out.grade : 'B' };++ }++diff --git a/test/ai-service-client.test.js b/test/ai-service-client.test.js++new file mode 100644++index 0000000..024f740++--- /dev/null++@@ -0,0 +1,146 @@+++'use strict';++++++// AI_SERVICE=remote (roadmap Wave 13): Live's AI goes to OpenVibe.AI as workflow runs with a service+++// token for audience openvibe.ai. Against a stub Network (token endpoint) and a stub AI service:+++// off by default; structured workflows return their output; llm.complete()-shaped calls become+++// passthrough runs of the workflow that owns the feature, attributed to the streamer; synthetic+++// (stub-provider) answers, failed runs, quota refusals and an unreachable service are all "no+++// answer" (null); a still-running run is polled; a rejected token is refreshed once.++++++const assert = require('assert');+++const http = require('http');+++const crypto = require('crypto');+++const { serviceAuth } = require('openvibe-contracts');++++++process.env.OV_OAUTH_CLIENT_ID = 'live';+++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';+++delete process.env.AI_SERVICE;++++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });+++let tokenCalls = 0;+++const calls = [];+++const script = { next: [] }; // queued responses for the AI stub: { status, body } | function++++++const network = http.createServer((req, res) => {+++ let raw = '';+++ req.on('data', (c) => { raw += c; });+++ req.on('end', () => {+++ tokenCalls++;+++ const f = new URLSearchParams(raw);+++ const now = Math.floor(Date.now() / 1000);+++ res.setHeader('Content-Type', 'application/json');+++ res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create', 'ai.run.read'], iat: now, exp: now + 300, jti: `tok_${tokenCalls}abcdefgh` }, keys.privateKey), expires_in: 300 }));+++ });+++});++++++const ai = http.createServer((req, res) => {+++ let raw = '';+++ req.on('data', (c) => { raw += c; });+++ req.on('end', () => {+++ const auth = String(req.headers.authorization || '');+++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.ai' });+++ const body = raw ? JSON.parse(raw) : null;+++ calls.push({ method: req.method, url: req.url, body, tokenOk: v.ok, jti: v.ok ? v.claims.jti : null });+++ let out = script.next.shift() || { status: 500, body: { code: 'unscripted' } };+++ if (typeof out === 'function') out = out(body, req);+++ res.statusCode = out.status;+++ res.setHeader('Content-Type', 'application/json');+++ if (out.headers) for (const [k, val] of Object.entries(out.headers)) res.setHeader(k, val);+++ res.end(JSON.stringify(out.body));+++ });+++});++++++const run = (over = {}) => ({ id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: 'live.translate', version: 1 }, output: { text: 'hello everyone', unchanged: false }, provenance: { model: 'gpt-5-nano', provider: 'shared' }, usage: { tokens_in: 30, tokens_out: 5, cost_usd: 0.0001 }, ...over });++++++(async () => {+++ await new Promise((r) => network.listen(0, '127.0.0.1', r));+++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));+++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;+++ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;+++ const svc = require('../server/ai/ai-service');+++ const metered = [];+++ svc.setRecorder((r, m) => metered.push({ run: r.id, ...m }));++++++ // 0. Off by default: nothing is sent.+++ assert.strictEqual(svc.enabled(), false);+++ assert.strictEqual(await svc.structured('live.translate', { text: 'x', to: 'en' }), null);+++ assert.strictEqual(calls.length, 0, 'default behaviour unchanged: no calls to OpenVibe.AI');++++++ process.env.AI_SERVICE = 'remote';+++ assert.strictEqual(svc.enabled(), true);++++++ // 1. A structured workflow: token for openvibe.ai, the run's output back, usage metered for the streamer.+++ script.next.push({ status: 201, body: { run: run() } });+++ const out = await svc.structured('live.translate', { text: 'みなさん', from: 'ja', to: 'en' }, { meter: { kind: 'translate', ownerUserId: 42 } });+++ assert.deepStrictEqual(out, { text: 'hello everyone', unchanged: false });+++ const c1 = calls.pop();+++ assert.ok(c1.tokenOk, 'Live calls with a service token for audience openvibe.ai');+++ assert.strictEqual(c1.method, 'POST');+++ assert.match(c1.url, /^\/api\/v1\/runs\?wait=\d+$/);+++ assert.strictEqual(c1.body.workflow, 'live.translate');+++ assert.deepStrictEqual(c1.body.attribution, { service: 'live', type: 'user', id: '42' });+++ assert.deepStrictEqual(metered.pop(), { run: run().id, kind: 'translate', ownerUserId: 42 });++++++ // 2. Synthetic (stub provider), failed runs, quota refusals and an unreachable service are "no answer".+++ script.next.push({ status: 201, body: { run: run({ synthetic: true }) } });+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }, { meter: { kind: 'translate' } }), null);+++ assert.strictEqual(metered.length, 0, 'synthetic output is not metered');+++ script.next.push({ status: 201, body: { run: run({ status: 'failed', output: null, error: { code: 'provider.unavailable' } }) } });+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);+++ script.next.push({ status: 429, headers: { 'Retry-After': '30' }, body: { code: 'quota.exceeded', detail: 'quota exceeded' } });+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);+++ const saved = process.env.OV_AI_INTERNAL_URL;+++ process.env.OV_AI_INTERNAL_URL = 'http://127.0.0.1:1';+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);+++ process.env.OV_AI_INTERNAL_URL = saved;++++++ // 3. Still running after the wait: poll the run until it finishes.+++ script.next.push({ status: 202, body: { run: run({ status: 'running', output: null }) } });+++ script.next.push({ status: 200, body: { run: run({ status: 'running', output: null }) } });+++ script.next.push({ status: 200, body: { run: run() } });+++ calls.length = 0;+++ assert.deepStrictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }, { waitMs: 5000 }), run().output);+++ assert.deepStrictEqual(calls.map((c) => c.method), ['POST', 'GET', 'GET']);+++ assert.strictEqual(calls[1].url, `/api/v1/runs/${run().id}`);++++++ // 4. A rejected token is refreshed once and the call retried.+++ const before = tokenCalls;+++ script.next.push({ status: 401, body: { code: 'token.expired' } });+++ script.next.push({ status: 201, body: { run: run() } });+++ calls.length = 0;+++ assert.ok(await svc.structured('live.translate', { text: 'a b', to: 'en' }));+++ assert.strictEqual(tokenCalls, before + 1, 'a new token was fetched');+++ assert.notStrictEqual(calls[0].jti, calls[1].jti);++++++ // 5. llm.complete()-shaped calls: passthrough run of the owning workflow, llm.complete result shape back.+++ script.next.push((body) => ({ status: 201, body: { run: run({ workflow: { key: body.workflow, version: 1 }, output: { text: '{"skip":true,"lines":[]}', json: { skip: true, lines: [] } }, provenance: { model: 'gpt-5' } }) } }));+++ calls.length = 0;+++ const r = await svc.complete({+++ role: 'director', kind: 'ai_viewers_director', source: 'ai_viewers', ownerUserId: 7,+++ system: [{ text: 'RULES', cache: true }, { text: '' }], user: 'plan now', json: { name: 'plan', schema: { type: 'object' }, strict: true }, maxTokens: 330, temperature: 0.9, cacheKey: 'chan:7',+++ });+++ const sent = calls[0].body;+++ assert.strictEqual(sent.workflow, 'live.viewers.plan');+++ assert.deepStrictEqual(sent.input.system, [{ text: 'RULES', cache: true }]);+++ assert.strictEqual(sent.input.role, 'director');+++ assert.strictEqual(sent.input.user, 'plan now');+++ assert.strictEqual(sent.input.max_tokens, 330);+++ assert.deepStrictEqual(sent.input.json.schema, { type: 'object' });+++ assert.deepStrictEqual(sent.attribution, { service: 'live', type: 'user', id: '7' });+++ assert.deepStrictEqual(r.json, { skip: true, lines: [] });+++ assert.strictEqual(r.model, 'gpt-5');+++ assert.deepStrictEqual(r.usage, { input: 30, output: 5, cached: 0 });+++ assert.strictEqual(r.provider, 'openvibe-ai');+++ assert.strictEqual(svc.workflowFor('something_new'), 'live.complete', 'unknown kinds still have a workflow');++++++ // 6. Images: OpenVibe.Media URLs are passed as URLs; anything else is downscaled here and sent inline.+++ assert.deepStrictEqual(await svc.imageInput('https://openvibe.media/t/9', {}), { url: 'https://openvibe.media/t/9', max_width: 1024 });+++ const inline = await svc.imageInput('/tmp/frame.jpg', { toVisionJpeg: async () => 'data:image/jpeg;base64,AAAA', maxWidth: 768 });+++ assert.deepStrictEqual(inline, { data_url: 'data:image/jpeg;base64,AAAA', max_width: 768 });+++ assert.strictEqual(await svc.imageInput('https://evil.example/x.png', { toVisionJpeg: async () => null }), null, 'other URLs are not forwarded');++++++ network.close();+++ ai.close();+++ console.log('ai service client: all checks passed');+++ process.exit(0);+++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,114 @@++# Moving Live's AI into OpenVibe.AI++++OpenVibe.Live grew into the network's de facto AI service: every feature called model providers++through `server/ai/llm.js` with the shared key held in Live's admin settings, and Network's footer++copy called Live's `/internal/ai/site-copy`. This document is the cutover plan: what moves here,++what stays in Live, how to import, and how to switch and roll back.++++## What moves and what stays++++### Records that move here++++| Live source | Becomes in OpenVibe.AI | How |++|---|---|---|++| `site_settings.ai_provider`, `ai_base_url`, `ai_model`, `ai_enabled`, `ai_input_cost_per_mtok`, `ai_output_cost_per_mtok` | the `shared` provider and its default model | **environment**, with the same names upper-cased (`AI_PROVIDER`, …). The import prints the values and records a hold |++| `site_settings.ai_api_key` | `shared.secret_ref = env:AI_API_KEY` | **environment only**. The import records a `secret` hold and never prints the value |++| `site_settings.ai_model_chat` / `_vision` / `_director` / `_summary` | a new version of route `live.<role>` with that model | import (`created_by = import`; the boot seed never overwrites it) |++| `site_settings.ai_pricing_json` | `models` rows for provider `shared` with cost metadata | import. The `default` entry and prefix matching stay in `AI_PRICING_JSON` (held) |++| `site_settings.ai_max_cost_usd_per_day` | quota `global` / `day` / `max_cost_usd` | import (also `AI_MAX_COST_USD_PER_DAY` at boot) |++| `site_settings.ai_viewers_global_cap_usd_per_day` | quota `global` / `day` / `max_cost_usd`, `workflow_prefix live.viewers.` | import |++| `ai_usage` | `usage_daily` (requester `service:live`, attribution `live:user:<owner>`, workflow from the Live kind, provider `shared` or `live-byo`) and today's quota windows | import, one ledger row per usage id |++| `channel_ai_config.daily_budget_cents` (shared key) | quota `attribution` `live:user:<id>` / `day` / `max_cost_usd`, prefix `live.viewers.` | import |++| `channel_ai_config` BYO key / base URL | — | **held** (`byo_provider`): per-actor provider secrets are not stored here yet, so BYO calls keep going straight to the streamer's provider from Live |++| `translations` | — | **held** (`unrepresentable`): rows are keyed by `sha1(from\|to\|text)` without the source text, so they cannot become input-hashed cache entries. Live keeps them as its read-through cache; new translations are also cached here (per service) |++| `ai_timeline_cache` | — | **held** (`stays_in_live`): an assembled Live read model with no model output |++| `ai_chatbot_configs` | — | **held** (`superseded`): Live already migrated it into `channel_ai_config` |++| Live's prompts | versioned templates and workflows (see below) | code (`server/workflows/*.js`), seeded at boot |++++### Records that stay in Live++++`stream_memories`, `stream_timeline_events`, `streamer_overviews`, `stream_recaps`, `vod_ai_state`,++`clip_ai_state`, `chat_ai_summaries`, `channel_ai_bots`, `ai_viewer_threads`, `ai_viewer_log`,++`channel_ai_config.settings_json`, and the `ai_*_enabled` feature switches. They are Live's product++state: AI produces a draft/evidence package (a run), and Live decides what to store and show. Each++run is attributable to a workflow, its version, a model and a run id — never to a person — and Live++can keep that `run_id` next to what it stored.++++## Live features and their workflows++++Structured workflows own their prompt here (templates copied verbatim from Live):++++| Live code | Workflow |++|---|---|++| `i18n/translate.js` translate / translateChatMessage / translateLines | `live.translate` (cache per service) |++| `ai-analysis.analyzeImagePaste` (paste_image, moment_frame) | `live.paste.describe_image` |++| `ai-analysis.analyzeTextPaste` | `live.paste.summarize_text` |++| `ai-analysis.analyzeStreamFrame` (stream memories, live pastes) | `live.stream.describe_frame` |++| `ai-analysis.summarizeStreamMemories` (overview + inferred category) | `live.stream.summarize` |++| `ai-analysis.generateStreamerOverview` | `live.streamer.overview` |++| `media-analysis.analyzeMedia` (VOD/clip overview) | `live.media.overview` |++| `recap/recap.js` aiWriteup | `live.stream.recap` |++| `internal/routes.js` `/internal/ai/site-copy` (Network footer) | `network.site_copy` |++| `transcribe.js` (whisper.cpp) | `live.media.transcribe` — available here; Live keeps transcribing locally for now (see below) |++++Passthrough workflows carry a prompt Live still renders itself (the run keeps only its hash):++++| Live `llm.complete()` kind | Workflow |++|---|---|++| `ai_viewers_director` / `ai_viewers_reply` / `ai_viewers` / `ai_viewers_fold` | `live.viewers.plan` / `.reply` / `.line` / `.fold` |++| `chat_global`, `chat_user`, `chat_relay`, `chat_anon`, `combined_overview`, `session_titles` | `live.chat.insight` |++| `moment_vod_rank`, `moment_pick` | `live.moments.rank`, `live.moments.pick` |++| `auto_clip_confirm` | `live.clips.confirm` |++| `hero_slogans`, `easter_egg`, `home_star` | `live.hero.slogans`, `live.easter_egg`, `live.home.star` |++| `arena_persona`, `arena_quotes`, `arena_scene`, `arena_beef_judge`/`arena_mic_judge`, `arena_headline` | `live.arena.*` |++| `status_check` | `live.status_check` |++| anything else | `live.complete` |++++Moving a passthrough feature's prompt here later is a normal change: add a template and a++structured workflow, point Live's call at it, and the passthrough key stays for old runs.++++## Cutover++++1. **Deploy OpenVibe.AI** (`/opt/openvibe.ai`, unit `deploy/systemd/openvibe-ai.service`, env++ `/etc/openvibe/ai.env` from `.env.example`). Leave `AI_STUB_FALLBACK` off in production.++2. **Dry-run the import** against a snapshot of Live's database:++ ```bash++ node scripts/import-from-live.js --live-db /path/live-snapshot.db --dry-run++ ```++ Copy the `environment` lines it prints into `/etc/openvibe/ai.env` (set `AI_API_KEY` from Live's++ `ai_api_key` yourself — the script never prints it), restart, check `GET /api/ready`.++3. **Import**: the same command without `--dry-run`. Re-running is safe: the ledger skips what is++ already imported and applies changed settings once. Review `import_holds`.++4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for++ audience `openvibe.ai`, namespaces `live.*` and `network.*` (the latter only while Live still++ answers `/internal/ai/site-copy`).++5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:++ ```++ AI_SERVICE=remote++ OV_AI_INTERNAL_URL=http://127.0.0.1:4700++ ```++ and restart Live. Live keeps its `ai_enabled` switch and local daily cap, and keeps writing++ `ai_usage` rows (provider `openvibe-ai`) so per-streamer AI-viewer budgets and the admin cost++ page work unchanged.++6. **Verify**: `GET /api/v1/runs?workflow=live.translate` (with an `ai.usage.read` token) shows runs++ from `service:live`; Live's logs show no `[AI service]` warnings.++7. **Network**: give Network's client `ai.run.create` with namespace `network.*` and call++ `POST /api/v1/runs {"workflow":"network.site_copy", ...}` directly instead of Live's++ `/internal/ai/site-copy` (same input and output shape). Until then Live's endpoint forwards to++ the same workflow.++++**Rollback**: unset `AI_SERVICE` in `live.env` and restart Live. Nothing in Live's database changed++shape; the shared key must still be in Live's settings for that path (keep it until the cutover++has held for a while).++++## Not moved yet++++- **Streamer BYO providers** stay in Live (`llm.js` provider override): they need per-actor++ provider records with secret references here first.++- **Local transcription** (live timeline segments, VOD/clip transcripts, stream memories audio)++ still runs whisper.cpp inside Live: live segments are local files with a latency budget, and the++ VOD path resumes per 5-minute window. `live.media.transcribe` is ready here (fetches from++ allow-listed OpenVibe hosts only) for when VOD transcription moves.++- **YAMNet sound events** (`audio-events.js`) and frame capture (`stream-vision.js`) are Live++ capture code, not model calls; only the vision call moved.++- **Run events** (`ai.run.queued|succeeded|failed|cached`) are not published to OpenVibe.Events yet.nnew file mode 100644@@@ -0,0 +1,224 @@++#!/usr/bin/env node++'use strict';++/**++ * Import the AI records that belong to OpenVibe.AI from a snapshot of OpenVibe.Live's database.++ *++ * node scripts/import-from-live.js --live-db /path/to/live-snapshot.db [--ai-db ./data/ai.db] [--dry-run]++ *++ * Reads the Live snapshot READ-ONLY. Idempotent: every imported source row is recorded in++ * import_ledger (source, key, content hash), so a second run imports nothing new and a changed++ * setting is applied once. Rows this service cannot represent are recorded in import_holds with a++ * reason — never silently dropped, never guessed. --dry-run prints the plan and writes nothing.++ *++ * What moves (see docs/migration.md for the full table):++ * site_settings ai_model_<role> -> a new version of route live.<role> with that model (origin import)++ * site_settings ai_pricing_json -> model rows with cost metadata for provider `shared`++ * site_settings ai_max_cost_usd_per_day -> quota global/day max_cost_usd++ * site_settings ai_viewers_global_cap_usd_per_day -> quota global/day max_cost_usd, workflow_prefix live.viewers.++ * site_settings ai_provider / ai_base_url / ai_model / ai_api_key -> HELD: these are environment++ * (AI_PROVIDER, AI_BASE_URL, AI_MODEL, AI_API_KEY); the script prints which names to set, never values of secrets++ * ai_usage -> usage_daily (per day, requester service:live, attribution live:user:<owner>,++ * workflow by Live kind), plus today's quota windows++ * channel_ai_config -> attribution quota live:user:<id>/day for live.viewers.* (shared key budgets);++ * BYO keys are HELD (per-actor provider secrets are not stored here yet)++ * translations, ai_timeline_cache, ai_chatbot_configs -> HELD (one row each; reasons in the hold)++ * Everything else AI-shaped in Live (stream_memories, streamer_overviews, stream_recaps, vod/clip_ai_state,++ * chat_ai_summaries, channel_ai_bots, ai_viewer_*) is Live product state and stays in Live.++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++const { load } = require('../server/config');++const { openDb } = require('../server/db');++const { createRegistry } = require('../server/registry');++const { createQuotas } = require('../server/quota');++const { seed } = require('../server/workflows/seed');++const { KIND_TO_WORKFLOW } = require('../server/workflows/live');++const { sha256, iso } = require('../server/util');++++/** Live ai_usage.kind -> the workflow that now does that job. */++const KIND_MAP = {++ ...KIND_TO_WORKFLOW,++ translate: 'live.translate', paste_image: 'live.paste.describe_image', moment_frame: 'live.paste.describe_image', paste_text: 'live.paste.summarize_text',++ stream_memory: 'live.stream.summarize', streamer_overview: 'live.streamer.overview', media_overview: 'live.media.overview', stream_recap: 'live.stream.recap',++ site_copy: 'network.site_copy',++};++const ROLES = ['chat', 'vision', 'director', 'summary'];++++function parseArgs(argv) {++ const out = { dryRun: false, liveDb: null, aiDb: null, quiet: false };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--dry-run') out.dryRun = true;++ else if (a === '--quiet') out.quiet = true;++ else if (a === '--live-db') out.liveDb = argv[++i];++ else if (a === '--ai-db') out.aiDb = argv[++i];++ else if (a === '-h' || a === '--help') out.help = true;++ else throw new Error(`unknown argument ${a}`);++ }++ return out;++}++++function hasTable(db, name) {++ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));++}++++function run({ liveDb, aiDb, dryRun = false, env = process.env, log = console.log, now = Date.now() } = {}) {++ if (!liveDb || !fs.existsSync(liveDb)) throw new Error('--live-db <snapshot> is required and must exist');++ const live = new Database(liveDb, { readonly: true, fileMustExist: true });++ const config = load({ ...env, AI_DB_PATH: aiDb || env.AI_DB_PATH || './data/ai.db' });++ const aiPath = path.resolve(config.dbPath);++++ // Dry run: never create or write the AI database; read its ledger if it already exists.++ let db = null;++ let registry = null;++ let quotas = null;++ if (!dryRun) {++ db = openDb(aiPath);++ registry = createRegistry(db, { env });++ quotas = createQuotas(db, { registry });++ seed({ registry, quotas, config, env, db });++ } else if (fs.existsSync(aiPath)) {++ db = new Database(aiPath, { readonly: true, fileMustExist: true });++ }++ const ledgerGet = (source, key) => (db && hasTable(db, 'import_ledger') ? db.prepare('SELECT target FROM import_ledger WHERE source = ? AND source_key = ?').get(source, String(key)) : null);++ const holdGet = (source, key) => (db && hasTable(db, 'import_holds') ? db.prepare('SELECT 1 FROM import_holds WHERE source = ? AND source_key = ?').get(source, String(key)) : null);++ const stats = {};++ const bump = (source, what, n = 1) => { stats[source] = stats[source] || { imported: 0, unchanged: 0, held: 0 }; stats[source][what] += n; };++ const ledger = (source, key, target) => { if (!dryRun) db.prepare('INSERT OR REPLACE INTO import_ledger (source, source_key, target, imported_at) VALUES (?, ?, ?, ?)').run(source, String(key), target, iso(now)); };++ const hold = (source, key, reason, detail = null) => {++ if (holdGet(source, key)) return bump(source, 'unchanged');++ if (!dryRun) db.prepare('INSERT OR REPLACE INTO import_holds (source, source_key, reason, detail, held_at) VALUES (?, ?, ?, ?, ?)').run(source, String(key), reason, detail, iso(now));++ bump(source, 'held');++ };++ /** Apply once per (source, key, content): unchanged content is skipped on re-runs. */++ const once = (source, key, content, apply) => {++ const h = sha256(content);++ const prev = ledgerGet(source, key);++ if (prev && prev.target.endsWith(`#${h}`)) return bump(source, 'unchanged');++ const target = dryRun ? 'dry-run' : apply();++ ledger(source, key, `${target}#${h}`);++ bump(source, 'imported');++ };++++ // ── site_settings ──++ const settings = hasTable(live, 'site_settings') ? Object.fromEntries(live.prepare("SELECT key, value FROM site_settings WHERE key LIKE 'ai_%'").all().map(r => [r.key, r.value])) : {};++ const envAdvice = [];++ for (const [k, envName] of [['ai_provider', 'AI_PROVIDER'], ['ai_base_url', 'AI_BASE_URL'], ['ai_model', 'AI_MODEL'], ['ai_enabled', 'AI_ENABLED'], ['ai_input_cost_per_mtok', 'AI_INPUT_COST_PER_MTOK'], ['ai_output_cost_per_mtok', 'AI_OUTPUT_COST_PER_MTOK']]) {++ if (settings[k] != null && settings[k] !== '') { envAdvice.push(`${envName}=${settings[k]}`); hold('live.site_settings', k, 'environment', `set ${envName} in /etc/openvibe/ai.env`); }++ }++ if (settings.ai_api_key) {++ envAdvice.push('AI_API_KEY=<copy the value of Live\'s ai_api_key setting; not printed>');++ hold('live.site_settings', 'ai_api_key', 'secret', 'secrets are environment references (env:AI_API_KEY); set it in /etc/openvibe/ai.env');++ }++ for (const role of ROLES) {++ const m = String(settings[`ai_model_${role}`] || '').trim();++ if (!m) continue;++ once('live.site_settings', `ai_model_${role}`, m, () => {++ const cur = registry.getRoute(`live.${role}`);++ const v = registry.createRouteVersion(`live.${role}`, { primary: { provider: 'shared', model: m }, fallbacks: cur ? cur.fallbacks : [] }, { actor: 'import' });++ return `route:live.${role}@${v.version}`;++ });++ }++ if (settings.ai_pricing_json) {++ let table = null;++ try { table = JSON.parse(settings.ai_pricing_json); } catch { table = null; }++ if (!table || typeof table !== 'object') hold('live.site_settings', 'ai_pricing_json', 'unparseable', 'not JSON');++ else {++ for (const [model, p] of Object.entries(table)) {++ if (model === 'default' || !p || typeof p !== 'object') { hold('live.site_settings', `ai_pricing_json.${model}`, 'environment', 'prefix/default pricing stays in AI_PRICING_JSON'); continue; }++ once('live.site_settings', `ai_pricing_json.${model}`, p, () => {++ registry.upsertModel({ provider_key: 'shared', model_key: model, type: 'chat', cost: { in_per_mtok: Number(p.in), out_per_mtok: Number(p.out), cached_per_mtok: p.cached != null ? Number(p.cached) : null }, metadata: { imported_from: 'live.ai_pricing_json' } }, { actor: 'import' });++ return `model:shared/${model}`;++ });++ }++ }++ }++ const cap = Number(settings.ai_max_cost_usd_per_day);++ if (cap > 0) once('live.site_settings', 'ai_max_cost_usd_per_day', cap, () => `quota:${quotas.upsert({ scope_type: 'global', window: 'day', max_cost_usd: cap }, { actor: 'import', origin: 'import' }).id}`);++ const vcap = Number(settings.ai_viewers_global_cap_usd_per_day);++ if (vcap > 0) once('live.site_settings', 'ai_viewers_global_cap_usd_per_day', vcap, () => `quota:${quotas.upsert({ scope_type: 'global', window: 'day', max_cost_usd: vcap, workflow_prefix: 'live.viewers.' }, { actor: 'import', origin: 'import' }).id}`);++++ // ── ai_usage -> usage_daily (+ today's windows) ──++ if (hasTable(live, 'ai_usage')) {++ const cols = live.prepare('PRAGMA table_info(ai_usage)').all().map(c => c.name);++ const has = (c) => cols.includes(c);++ const rows = live.prepare(`SELECT id, kind, model, input_tokens, output_tokens, cost_usd, created_at,++ ${has('owner_user_id') ? 'owner_user_id' : 'NULL AS owner_user_id'}, ${has('cached_tokens') ? 'cached_tokens' : '0 AS cached_tokens'}, ${has('provider') ? 'provider' : "'shared' AS provider"}++ FROM ai_usage ORDER BY id`).all();++ const today = iso(now).slice(0, 10);++ const dayStart = Math.floor(now / 86400000) * 86400;++ const insDaily = db && !dryRun ? db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)++ VALUES (?, 'service:live', ?, ?, ?, ?, 1, ?, ?, ?, ?)++ ON CONFLICT(day, requester, attribution, workflow_key, provider_key, model_key) DO UPDATE SET requests = requests + 1,++ tokens_in = tokens_in + excluded.tokens_in, tokens_out = tokens_out + excluded.tokens_out, tokens_cached = tokens_cached + excluded.tokens_cached, cost_usd = cost_usd + excluded.cost_usd`) : null;++ const insCounter = db && !dryRun ? db.prepare(`INSERT INTO usage_counters (scope_type, scope_id, window, window_start, workflow_prefix, requests, tokens, cost_usd) VALUES (?, ?, 'day', ?, '', 1, ?, ?)++ ON CONFLICT(scope_type, scope_id, window, window_start, workflow_prefix) DO UPDATE SET requests = requests + 1, tokens = tokens + excluded.tokens, cost_usd = cost_usd + excluded.cost_usd`) : null;++ const tx = (fn) => (db && !dryRun ? db.transaction(fn)() : fn());++ tx(() => {++ for (const r of rows) {++ if (ledgerGet('live.ai_usage', r.id)) { bump('live.ai_usage', 'unchanged'); continue; }++ const day = String(r.created_at || '').slice(0, 10);++ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { hold('live.ai_usage', r.id, 'unrepresentable', 'no valid created_at'); continue; }++ const wf = KIND_MAP[r.kind] || 'live.complete';++ const attribution = r.owner_user_id ? `live:user:${r.owner_user_id}` : '';++ const provider = r.provider === 'byo' ? 'live-byo' : 'shared';++ const tokens = (Number(r.input_tokens) || 0) + (Number(r.output_tokens) || 0);++ if (!dryRun) {++ insDaily.run(day, attribution, wf, provider, r.model || '', Number(r.input_tokens) || 0, Number(r.output_tokens) || 0, Number(r.cached_tokens) || 0, Number(r.cost_usd) || 0);++ if (day === today && provider === 'shared') {++ insCounter.run('global', '*', dayStart, tokens, Number(r.cost_usd) || 0);++ insCounter.run('service', 'live', dayStart, tokens, Number(r.cost_usd) || 0);++ if (attribution) insCounter.run('attribution', attribution, dayStart, tokens, Number(r.cost_usd) || 0);++ }++ }++ ledger('live.ai_usage', r.id, `usage_daily:${day}/${wf}#`);++ bump('live.ai_usage', 'imported');++ }++ });++ }++++ // ── channel_ai_config -> per-streamer AI-viewer budgets; BYO keys held ──++ if (hasTable(live, 'channel_ai_config')) {++ for (const c of live.prepare('SELECT user_id, use_shared_key, daily_budget_cents, byo_key, byo_base_url FROM channel_ai_config').all()) {++ if (Number(c.use_shared_key) === 1 || c.use_shared_key == null) {++ const usd = (Number(c.daily_budget_cents) || 0) / 100;++ if (usd <= 0) { bump('live.channel_ai_config', 'unchanged'); continue; }++ once('live.channel_ai_config', c.user_id, usd, () => `quota:${quotas.upsert({ scope_type: 'attribution', scope_id: `live:user:${c.user_id}`, window: 'day', max_cost_usd: usd, workflow_prefix: 'live.viewers.' }, { actor: 'import', origin: 'import' }).id}`);++ } else {++ let host = '';++ try { host = c.byo_base_url ? new URL(c.byo_base_url).host : ''; } catch { host = ''; }++ hold('live.channel_ai_config', c.user_id, 'byo_provider', `streamer ${c.user_id} uses their own ${c.byo_key ? 'key' : 'self-hosted endpoint'}${host ? ` (${host})` : ''}; BYO providers keep running in Live (llm.js provider override) until per-actor provider secrets exist here`);++ }++ }++ }++++ // ── Whole-table holds ──++ const tableHold = (table, reason, detail) => {++ if (!hasTable(live, table)) return;++ const n = live.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;++ hold(`live.${table}`, '*', reason, `${n} row(s): ${detail}`);++ };++ tableHold('translations', 'unrepresentable', 'keyed by sha1(from|to|text) without the source text, so they cannot become input-hashed cache entries; Live keeps them as its local read-through cache and new translations are cached here per service');++ tableHold('ai_timeline_cache', 'stays_in_live', 'an assembled Live read model (no model output); Live keeps serving it');++ tableHold('ai_chatbot_configs', 'superseded', 'Live already migrated these rows into channel_ai_config');++++ live.close();++ if (db) db.close();++ const report = { dry_run: dryRun, ai_db: aiPath, sources: stats, environment: envAdvice };++ log(JSON.stringify(report, null, 2));++ return report;++}++++if (require.main === module) {++ require('dotenv').config();++ let args;++ try { args = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }++ if (args.help || !args.liveDb) {++ console.error('usage: node scripts/import-from-live.js --live-db <snapshot.db> [--ai-db <ai.db>] [--dry-run]');++ process.exit(args.help ? 0 : 2);++ }++ try { run({ liveDb: args.liveDb, aiDb: args.aiDb, dryRun: args.dryRun }); } catch (e) { console.error(`[import] ${e.message}`); process.exit(1); }++}++++module.exports = { run, KIND_MAP };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++// scripts/import-from-live.js against a synthetic Live snapshot: --dry-run writes nothing; a real++// run imports usage, budgets, per-role models and pricing, and holds what cannot be represented++// (secrets, BYO keys, text-less translation cache); a second run imports nothing; a new Live row++// is picked up alone. Secret values never appear in the report or the holds.++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++const { suite, tmpDir } = require('./helpers');++const { run } = require('../scripts/import-from-live');++++const t = suite('import');++const dir = tmpDir();++const liveDb = path.join(dir, 'live-snapshot.db');++const aiDb = path.join(dir, 'ai.db');++const SECRET = 'sk-live-snapshot-secret-DO-NOT-PRINT';++const env = { NODE_ENV: 'test', AI_QUOTA_SERVICE_RPM: '0', AI_QUOTA_SERVICE_RPD: '0' };++const quiet = () => {};++const today = new Date().toISOString().slice(0, 10);++++t.test('build a Live snapshot', () => {++ const d = new Database(liveDb);++ d.exec(`CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', description TEXT, type TEXT, updated_at DATETIME);++ CREATE TABLE ai_usage (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, cost_usd REAL, owner_user_id INTEGER, source TEXT, created_at DATETIME, cached_tokens INTEGER, role TEXT, provider TEXT, latency_ms INTEGER);++ CREATE TABLE channel_ai_config (user_id INTEGER PRIMARY KEY, enabled INTEGER, use_shared_key INTEGER, daily_budget_cents INTEGER, byo_key TEXT, byo_base_url TEXT, byo_model TEXT, settings_json TEXT);++ CREATE TABLE translations (key TEXT PRIMARY KEY, src TEXT, dst TEXT, text TEXT NOT NULL, created_at DATETIME);++ CREATE TABLE ai_timeline_cache (user_id INTEGER PRIMARY KEY, payload TEXT, generated_at DATETIME);++ CREATE TABLE ai_chatbot_configs (user_id INTEGER PRIMARY KEY, api_token TEXT);`);++ const set = d.prepare('INSERT INTO site_settings (key, value) VALUES (?, ?)');++ for (const [k, v] of [['ai_enabled', 'true'], ['ai_provider', 'openai'], ['ai_api_key', SECRET], ['ai_model', 'gpt-5-mini'], ['ai_model_director', 'gpt-5'], ['ai_model_vision', ''],++ ['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"default":{"in":1,"out":2}}'], ['ai_max_cost_usd_per_day', '5'], ['ai_viewers_global_cap_usd_per_day', '2']]) set.run(k, v);++ const use = d.prepare('INSERT INTO ai_usage (kind, model, input_tokens, output_tokens, cost_usd, owner_user_id, created_at, cached_tokens, provider) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');++ use.run('translate', 'gpt-5-nano', 100, 20, 0.01, null, `${today} 10:00:00`, 0, 'shared');++ use.run('ai_viewers_director', 'gpt-5', 1000, 200, 0.2, 42, `${today} 10:01:00`, 500, 'shared');++ use.run('ai_viewers', 'llama3', 50, 10, 0, 7, '2026-09-01 08:00:00', 0, 'byo');++ use.run('weird_kind', 'gpt-5-mini', 10, 10, 0.001, null, '2026-09-02 08:00:00', 0, 'shared');++ use.run('paste_text', 'gpt-5-mini', 10, 10, 0.001, null, 'garbage', 0, 'shared');++ const ch = d.prepare('INSERT INTO channel_ai_config (user_id, use_shared_key, daily_budget_cents, byo_key, byo_base_url) VALUES (?, ?, ?, ?, ?)');++ ch.run(42, 1, 20, '', '');++ ch.run(7, 0, 20, 'sk-byo-streamer-secret', 'https://llm.example.org/v1');++ d.prepare("INSERT INTO translations (key, src, dst, text) VALUES ('abc', 'ja', 'en', 'hello')").run();++ d.close();++});++++t.test('--dry-run reports the plan and writes nothing', () => {++ const r = run({ liveDb, aiDb, dryRun: true, env, log: quiet });++ assert.strictEqual(r.dry_run, true);++ assert.strictEqual(fs.existsSync(aiDb), false, 'no AI database created');++ assert.strictEqual(r.sources['live.ai_usage'].imported, 4);++ assert.strictEqual(r.sources['live.ai_usage'].held, 1);++ assert.ok(!JSON.stringify(r).includes(SECRET));++});++++t.test('a real run imports and holds, without leaking secrets', () => {++ const r = run({ liveDb, aiDb, env, log: quiet });++ assert.ok(!JSON.stringify(r).includes(SECRET) && !JSON.stringify(r).includes('sk-byo'));++ assert.ok(r.environment.some(l => l.startsWith('AI_API_KEY=<')), 'tells the operator which secret NAME to set');++ const d = new Database(aiDb, { readonly: true });++ const quotas = d.prepare('SELECT * FROM quotas WHERE origin = ?').all('import');++ assert.ok(quotas.some(q => q.scope_type === 'global' && q.max_cost_usd === 5 && !q.workflow_prefix));++ assert.ok(quotas.some(q => q.scope_type === 'global' && q.max_cost_usd === 2 && q.workflow_prefix === 'live.viewers.'));++ assert.ok(quotas.some(q => q.scope_type === 'attribution' && q.scope_id === 'live:user:42' && q.max_cost_usd === 0.2));++ const route = d.prepare("SELECT * FROM routes WHERE key = 'live.director' ORDER BY version DESC LIMIT 1").get();++ assert.strictEqual(route.primary_model, 'gpt-5');++ assert.strictEqual(route.created_by, 'import');++ const model = d.prepare("SELECT * FROM models WHERE provider_key = 'shared' AND model_key = 'gpt-5-nano'").get();++ assert.strictEqual(model.cost_in_per_mtok, 0.05);++ const usage = d.prepare('SELECT * FROM usage_daily').all();++ assert.ok(usage.some(u => u.workflow_key === 'live.translate' && u.day === today));++ assert.ok(usage.some(u => u.workflow_key === 'live.viewers.plan' && u.attribution === 'live:user:42' && u.tokens_cached === 500));++ assert.ok(usage.some(u => u.provider_key === 'live-byo'));++ assert.ok(usage.some(u => u.workflow_key === 'live.complete'), 'unknown kinds land on live.complete');++ const counter = d.prepare("SELECT * FROM usage_counters WHERE scope_type = 'global' AND window = 'day'").get();++ assert.ok(Math.abs(counter.cost_usd - 0.21) < 1e-9, "today's spend counts toward today's cap");++ const holds = d.prepare('SELECT * FROM import_holds').all();++ const reasons = Object.fromEntries(holds.map(h => [`${h.source}:${h.source_key}`, h.reason]));++ assert.strictEqual(reasons['live.site_settings:ai_api_key'], 'secret');++ assert.strictEqual(reasons['live.channel_ai_config:7'], 'byo_provider');++ assert.strictEqual(reasons['live.translations:*'], 'unrepresentable');++ assert.strictEqual(reasons['live.ai_usage:5'], 'unrepresentable');++ assert.ok(!JSON.stringify(holds).includes(SECRET) && !JSON.stringify(holds).includes('sk-byo'));++ d.close();++});++++t.test('a second run imports nothing and changes no totals', () => {++ const before = new Database(aiDb, { readonly: true });++ const sum = (x) => x.prepare('SELECT SUM(requests) r, SUM(cost_usd) c FROM usage_daily').get();++ const s1 = sum(before);++ before.close();++ const r = run({ liveDb, aiDb, env, log: quiet });++ for (const [src, s] of Object.entries(r.sources)) assert.strictEqual(s.imported + s.held, 0, `${src} imported again: ${JSON.stringify(s)}`);++ const after = new Database(aiDb, { readonly: true });++ assert.deepStrictEqual(sum(after), s1);++ assert.strictEqual(after.prepare("SELECT COUNT(*) n FROM routes WHERE key = 'live.director'").get().n, 2, 'no extra route version');++ after.close();++});++++t.test('new Live rows and changed settings are picked up alone', () => {++ const d = new Database(liveDb);++ d.prepare("INSERT INTO ai_usage (kind, model, input_tokens, output_tokens, cost_usd, created_at, provider) VALUES ('hero_slogans', 'gpt-5-mini', 5, 5, 0.002, ?, 'shared')").run(`${today} 11:00:00`);++ d.prepare("UPDATE site_settings SET value = '8' WHERE key = 'ai_max_cost_usd_per_day'").run();++ d.close();++ const r = run({ liveDb, aiDb, env, log: quiet });++ assert.strictEqual(r.sources['live.ai_usage'].imported, 1);++ assert.strictEqual(r.sources['live.site_settings'].imported, 1);++ const a = new Database(aiDb, { readonly: true });++ assert.strictEqual(a.prepare("SELECT max_cost_usd FROM quotas WHERE scope_type = 'global' AND workflow_prefix IS NULL").get().max_cost_usd, 8);++ a.close();++});++++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,596 @@
@@ -0,0 +1,596 @@
+diff --git a/.env.example b/.env.example
+index 31f53b7..6911d85 100644
+--- a/.env.example
+@@ -16,6 +16,15 @@ ADMIN_PASSWORD=changeme123
+ # channels keep using the English model and live captions stay hidden.
+ # WHISPER_MODEL_MULTI=/home/openvibe/whisper.cpp/models/ggml-small.bin
+
++# OpenVibe.AI (roadmap Wave 13). AI_SERVICE=remote sends Live's shared-key AI calls (chat AI, moments,
++# paste/frame analysis, overviews, translation, recaps, slogans, arena, footer copy) to the AI service as
++# workflow runs, with a service token for audience openvibe.ai (grant ai.run.create for client `live`).
++# The admin ai_enabled switch and the local daily cap still apply; the provider key lives in the AI
++# service. Streamer BYO keys and local whisper.cpp transcription stay here. Unset = Live calls
++# providers itself, exactly as before.
++#AI_SERVICE=remote
++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
++
+ # Server
+ PORT=3000
+ HOST=0.0.0.0
+diff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
+index 559a58a..70297da 100644
+--- a/server/ai/ai-analysis.js
+@@ -16,7 +16,10 @@ function b(k) { const v = db.getSetting(k); return v === true || v === 'true' ||
+ function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }
+
+ const llm = require('./llm');
++const aiService = require('./ai-service');
+ function isEnabled() { return llm.isEnabled(); }
++// AI_SERVICE=remote: the analyses below whose prompt moved to OpenVibe.AI run as its workflows.
++const remote = () => aiService.enabled() && isEnabled() && withinBudget();
+ function pasteAnalysisEnabled() { return isEnabled() && b('ai_paste_analysis_enabled'); }
+ function streamMemoryEnabled() { return isEnabled() && b('ai_stream_memory_enabled'); }
+ // Local whisper.cpp transcription (default on when installed). Independent of the
+@@ -121,6 +124,12 @@ async function _toVisionJpeg(image, opts = {}) {
+
+ /** Describe an image paste → { description, tags }. */
+ async function analyzeImagePaste(image, title, kind = 'paste_image') {
++ if (aiService.enabled()) {
++ if (!remote()) return null;
++ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 1280 });
++ const o = img ? await aiService.structured('live.paste.describe_image', { title: String(title || '').slice(0, 500), image: img }, { meter: { kind, role: 'vision' } }) : null;
++ return o && o.description ? { description: o.description, tags: o.tags || [] } : null;
++ }
+ const prompt = `You are describing an uploaded image/screenshot for a paste titled "${(title || '').slice(0, 120)}".
+ Reply ONLY with compact JSON: {"description":"1-2 sentence description of what the image shows","tags":["3-6","short","lowercase","tags"]}.`;
+ const text = await _complete({ prompt, image, maxTokens: 300, kind, role: 'vision', imageMaxWidth: 1280 });
+@@ -131,6 +140,11 @@ Reply ONLY with compact JSON: {"description":"1-2 sentence description of what t
+
+ /** Summarize a text paste → { description }. */
+ async function analyzeTextPaste(content, title) {
++ if (aiService.enabled()) {
++ if (!remote() || !String(content || '').trim()) return null;
++ const o = await aiService.structured('live.paste.summarize_text', { title: String(title || '').slice(0, 500), content: String(content || '').slice(0, 200000) }, { meter: { kind: 'paste_text', role: 'legacy' } });
++ return o && o.description ? { description: o.description, tags: [] } : null;
++ }
+ const snippet = String(content || '').slice(0, 6000);
+ const prompt = `Summarize what this pasted text is about in one short sentence (max 200 chars), plainly. Title: "${(title || '').slice(0, 120)}".\n\n---\n${snippet}`;
+ const text = await _complete({ prompt, maxTokens: 120, kind: 'paste_text' });
+@@ -139,6 +153,12 @@ async function analyzeTextPaste(content, title) {
+
+ /** Analyze a live-stream frame → { description, tags }. */
+ async function analyzeStreamFrame(image) {
++ if (aiService.enabled()) {
++ if (!remote()) return null;
++ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 768 });
++ const o = img ? await aiService.structured('live.stream.describe_frame', { image: img }, { meter: { kind: 'stream_memory', role: 'vision' } }) : null;
++ return o && o.description ? { description: o.description, tags: o.tags || [], worthy: o.worthy === true, title: o.worthy === true ? String(o.title || '').slice(0, 80) : '' } : null;
++ }
+ // One vision call does three jobs: the memory description, the tags, and a screenshot-worthiness
+ // verdict + caption (so live pastes need no extra call).
+ const prompt = `This is a frame from a live stream. Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a face/reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption for it, else empty>"}.`;
+@@ -160,6 +180,18 @@ async function summarizeStreamMemories(memories, streamId = null) {
+ // overview reflects the entire stream since it started, not just the latest frame.
+ const lines = (memories || []).slice(-80).map(m => `- ${m.description}`).join('\n');
+ if (!lines) return null;
++ if (aiService.enabled()) {
++ if (!remote()) return null;
++ const input = { observations: (memories || []).slice(-80).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean) };
++ if (streamId) {
++ try {
++ input.speech = (db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || []).slice(-200).map(r => ({ start_sec: Number(r.start_sec) || 0, text: String(r.text || '').slice(0, 2000) }));
++ input.sounds = (db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || []).slice(-60).map(r => ({ start_sec: Number(r.start_sec) || 0, label: String(r.label || '').slice(0, 120), confidence: Number(r.confidence || 0) }));
++ } catch { /* timeline optional */ }
++ }
++ const o = await aiService.structured('live.stream.summarize', input, { target: streamId ? { service: 'live', type: 'stream', id: String(streamId) } : undefined, meter: { kind: 'stream_memory', role: 'legacy' } });
++ return o && o.overview ? { overview: o.overview, category: normalizeCategory(o.category), tags: Array.isArray(o.tags) ? o.tags.map(String).slice(0, 6) : [] } : null;
++ }
+
+ // Fold in the audio timeline when one exists. Previously the transcript reached this
+ // prompt only as a 500-char slice embedded inside a memory's description string, with
+@@ -230,7 +262,14 @@ async function generateStreamerOverview(userId) {
+
+ const prompt = `You are building an internal profile of a livestreamer for site staff, using aggregated signals across their streams, VODs, and pastes. Write a concise overview (4-8 sentences) covering: what they stream / their content niche, recurring themes or activities, tone/vibe, and anything notable for moderation. Be factual and neutral; do NOT invent specifics that aren't supported by the signals below.\n\n${ctx}`;
+
+- const text = await _complete({ prompt, maxTokens: 550, kind: 'streamer_overview' });
++ const text = aiService.enabled()
++ ? ((await aiService.structured('live.streamer.overview', {
++ streamer: { username: String(user.username), display_name: String(user.display_name || user.username), bio: String(channel?.bio || user.bio || '').slice(0, 2000), category: (channel?.ai_category || channel?.category) ? String(channel.ai_category || channel.category) : undefined, category_inferred: Boolean(channel?.ai_category) },
++ memories: memories.slice(0, 40).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean),
++ vods: vods.map(v => ({ title: String(v.title || 'Untitled VOD').slice(0, 300), category: (v.ai_category || v.category) ? String(v.ai_category || v.category) : undefined })),
++ pastes: pastes.filter(p => p.ai_summary).map(p => ({ title: String(p.title || 'paste').slice(0, 300), summary: String(p.ai_summary).slice(0, 600) })),
++ }, { target: { service: 'live', type: 'user', id: String(userId) }, meter: { kind: 'streamer_overview', role: 'legacy' } })) || {}).overview || null
++ : await _complete({ prompt, maxTokens: 550, kind: 'streamer_overview' });
+ if (!text) return null;
+ const overview = text.slice(0, 4000);
+ try {
+diff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
+new file mode 100644
+index 0000000..9b0ef43
+--- /dev/null
+@@ -0,0 +1,181 @@
++/**
++ * ai-service.js — Live as a CONSUMER of OpenVibe.AI (roadmap Wave 13).
++ *
++ * With AI_SERVICE=remote, Live stops calling model providers itself: every shared-key AI call
++ * becomes a workflow run on OpenVibe.AI (ai.openvibe.network, internal OV_AI_INTERNAL_URL),
++ * authenticated with Live's Network service token for audience openvibe.ai (network-principal).
++ * Unset (the default), nothing here is used and Live behaves exactly as before.
++ *
++ * structured(workflow, input, opts) -> the run's output object, or null
++ * for workflows whose prompt now lives in OpenVibe.AI (live.translate, live.paste.*,
++ * live.stream.*, live.streamer.overview, live.media.overview, live.stream.recap,
++ * network.site_copy)
++ * complete(o, { toVisionJpeg }) -> the llm.complete() result shape, or null
++ * for every other llm.complete() call: the prompt Live rendered is sent as a passthrough run of
++ * the workflow that owns that feature (KIND_TO_WORKFLOW), so each run is still attributable
++ * to one workflow + model + run
++ *
++ * null means "no AI answer" exactly like llm.complete() returning null today (AI off, over
++ * budget, provider down): quota refusals (429), failed runs, unreachable service, and SYNTHETIC
++ * answers (the AI service's stub provider) all come back as null — synthetic text never reaches
++ * a Live page. Streamer BYO providers are not routed here (llm.js keeps calling them directly).
++ */
++'use strict';
++const principal = require('../net/network-principal');
++
++const AUDIENCE = 'openvibe.ai';
++const TERMINAL = new Set(['succeeded', 'cached', 'failed', 'cancelled']);
++
++/** llm.complete() kind -> the OpenVibe.AI workflow that owns that feature (same table as the service). */
++const KIND_TO_WORKFLOW = {
++ ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
++ chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
++ moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',
++ arena_persona: 'live.arena.persona', arena_quotes: 'live.arena.quotes', arena_scene: 'live.arena.scene', arena_beef_judge: 'live.arena.judge', arena_mic_judge: 'live.arena.judge', arena_headline: 'live.arena.headline',
++ status_check: 'live.status_check',
++};
++const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
++
++function enabled() { return String(process.env.AI_SERVICE || '').trim().toLowerCase() === 'remote'; }
++function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }
++function workflowFor(kind) { return KIND_TO_WORKFLOW[kind] || 'live.complete'; }
++
++let _lastWarn = 0;
++function warn(msg) {
++ if (Date.now() - _lastWarn < 60_000) return;
++ _lastWarn = Date.now();
++ console.warn(`[AI service] ${msg}`);
++}
++
++async function _fetchJson(method, path, body, timeoutMs, retried = false) {
++ let headers;
++ try { headers = await principal.serviceHeaders(AUDIENCE); } catch (e) { warn(`no service token for ${AUDIENCE}: ${e.message}`); return null; }
++ let res;
++ try {
++ res = await fetch(`${baseUrl()}${path}`, {
++ method,
++ headers: { ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
++ body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (e) { warn(`${method} ${path} failed: ${e.message}`); return null; }
++ const json = await res.json().catch(() => null);
++ if (res.status === 401 && !retried) { // stale/rotated token: fetch a new one once
++ principal.invalidate(AUDIENCE);
++ return _fetchJson(method, path, body, timeoutMs, true);
++ }
++ if (res.status === 429) { warn(`quota: ${(json && json.detail) || 'refused'} (retry after ${res.headers.get('retry-after') || '?'}s)`); return null; }
++ if (!res.ok && res.status !== 202) { warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`); return null; }
++ return json;
++}
++
++/**
++ * Run a workflow and wait for it. Returns the run object (any terminal status) or null.
++ * opts: { target, attribution, idempotencyKey, waitMs (default 60s), timeoutMs }
++ */
++async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000 } = {}) {
++ if (!enabled()) return null;
++ const wait = Math.max(0, Math.min(60000, waitMs));
++ const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
++ workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,
++ }, wait + 15000);
++ let r = created && created.run;
++ if (!r) return null;
++ // Still queued/running after the wait: poll briefly (the service caps a wait at 60 s).
++ const deadline = Date.now() + wait;
++ while (!TERMINAL.has(r.status) && Date.now() < deadline) {
++ await new Promise(res => setTimeout(res, 1000));
++ const again = await _fetchJson('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`, null, 15000);
++ if (!again || !again.run) return null;
++ r = again.run;
++ }
++ return r;
++}
++
++/** A usable (real, finished) output or null. */
++function usable(r) {
++ if (!r || !['succeeded', 'cached'].includes(r.status)) return null;
++ if (r.synthetic) { warn(`${r.workflow && r.workflow.key} answered from the stub provider; treating it as no answer`); return null; }
++ return r.output || null;
++}
++
++// Live's ai_usage table keeps metering remote runs too (per-streamer budgets, admin cost views);
++// llm.js installs the recorder so this module stays free of the database.
++let _recorder = null;
++function setRecorder(fn) { _recorder = typeof fn === 'function' ? fn : null; }
++function meter(r, m) {
++ if (!_recorder || !m || !r || r.status !== 'succeeded' || r.synthetic) return;
++ try { _recorder(r, m); } catch { /* metering is best-effort */ }
++}
++
++/** opts: run() options plus meter: { kind, role, ownerUserId, source } for Live's ai_usage row. */
++async function structured(workflow, input, opts = {}) {
++ const r = await run(workflow, input, { ...opts, attribution: opts.attribution || ownerRef(opts.meter && opts.meter.ownerUserId) });
++ meter(r, opts.meter);
++ return usable(r);
++}
++
++/** EntityRef for the Live user a spend is attributed to (Live's per-streamer budgets). */
++function ownerRef(ownerUserId) {
++ const id = parseInt(ownerUserId, 10);
++ return id > 0 ? { service: 'live', type: 'user', id: String(id) } : undefined;
++}
++
++/**
++ * An image for a workflow input. OpenVibe.Media URLs stay URLs (the service fetches only
++ * allow-listed OpenVibe hosts); files, buffers and any other URL are downscaled here, as before,
++ * and sent inline.
++ */
++async function imageInput(image, { toVisionJpeg, maxWidth = 1024 } = {}) {
++ if (!image) return null;
++ if (typeof image === 'string' && /^https:\/\/openvibe\.media\//i.test(image)) return { url: image, max_width: maxWidth };
++ const dataUrl = toVisionJpeg ? await toVisionJpeg(image, { maxWidth, quality: 78 }) : (typeof image === 'string' && image.startsWith('data:') ? image : null);
++ return dataUrl ? { data_url: dataUrl, max_width: maxWidth } : null;
++}
++
++/**
++ * llm.complete() over the service. Same argument object; same result shape
++ * ({ text, json, usage, model, provider, shared, latencyMs, cost }) or null.
++ */
++async function complete(o = {}, { toVisionJpeg } = {}) {
++ const role = ROLES.includes(o.role) ? o.role : 'legacy';
++ let image = null;
++ if (o.image) {
++ image = await imageInput(o.image, { toVisionJpeg, maxWidth: o.imageMaxWidth || 1024 });
++ if (!image) return null;
++ }
++ const system = typeof o.system === 'string' ? o.system
++ : Array.isArray(o.system) ? o.system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: Boolean(x.cache) })) : undefined;
++ const input = {
++ role, kind: o.kind ? String(o.kind).slice(0, 64) : undefined, source: o.source ? String(o.source).slice(0, 64) : undefined,
++ system: system && system.length ? system : undefined,
++ messages: Array.isArray(o.messages) && o.messages.length ? o.messages.filter(m => m && m.role).map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })) : undefined,
++ user: o.user != null ? String(o.user) : undefined,
++ image: image || undefined,
++ json: o.json ? { name: o.json.name, schema: o.json.schema, strict: o.json.strict, description: o.json.description } : undefined,
++ max_tokens: Math.max(1, Math.round(o.maxTokens || 400)),
++ temperature: o.temperature == null ? undefined : o.temperature,
++ timeout_ms: o.timeoutMs ? Math.max(1000, Math.min(120000, o.timeoutMs)) : undefined,
++ cache_key: o.cacheKey ? String(o.cacheKey).slice(0, 128) : undefined,
++ };
++ if (!input.user && !input.messages) input.user = '';
++ const started = Date.now();
++ const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2) });
++ const out = usable(r);
++ if (!out) return null;
++ const usage = { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 };
++ return {
++ text: typeof out.text === 'string' ? out.text : '',
++ json: out.json || null,
++ usage,
++ model: (r.provenance && r.provenance.model) || null,
++ provider: 'openvibe-ai',
++ shared: true,
++ latencyMs: Date.now() - started,
++ cost: (r.usage && r.usage.cost_usd) || 0,
++ runId: r.id,
++ workflow: r.workflow && r.workflow.key,
++ };
++}
++
++module.exports = { enabled, run, structured, complete, usable, imageInput, ownerRef, workflowFor, setRecorder, KIND_TO_WORKFLOW, AUDIENCE };
+diff --git a/server/ai/llm.js b/server/ai/llm.js
+index 6824fed..67dc39c 100644
+--- a/server/ai/llm.js
+@@ -21,6 +21,7 @@
+ 'use strict';
+ const fs = require('fs');
+ const db = require('../db/database');
++const aiService = require('./ai-service');
+
+ const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
+ const DEFAULT_TIMEOUT_MS = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
+@@ -30,7 +31,8 @@ function b(k) { const v = db.getSetting(k); return v === true || v === 'true' ||
+ function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }
+
+ // ── Gates ────────────────────────────────────────────────────
+-function isEnabled() { return b('ai_enabled') && !!s('ai_api_key'); }
++// AI_SERVICE=remote: the provider key lives in OpenVibe.AI, so the admin master switch alone decides.
++function isEnabled() { return b('ai_enabled') && (aiService.enabled() || !!s('ai_api_key')); }
+ function withinBudget() {
+ const cap = num('ai_max_cost_usd_per_day', 0);
+ if (!cap || cap <= 0) return true;
+@@ -294,6 +296,22 @@ function parseJsonLoose(text) {
+ */
+ async function complete(o = {}) {
+ const role = ROLES.includes(o.role) ? o.role : 'legacy';
++ // AI_SERVICE=remote: shared-key calls become workflow runs on OpenVibe.AI (a streamer's BYO
++ // provider override still goes straight to that provider, below). Usage is still recorded here
++ // so per-streamer budgets and the admin cost views keep working unchanged.
++ if (aiService.enabled() && !(o.provider && (o.provider.apiKey || o.provider.baseUrl))) {
++ if (!isEnabled() || !withinBudget()) return null;
++ const r = await aiService.complete({ ...o, role }, { toVisionJpeg });
++ if (!r) { console.warn(`[AI] ${role}/${o.kind || role} via OpenVibe.AI returned no answer`); return null; }
++ if (o.json && !r.json) r.json = parseJsonLoose(r.text);
++ try {
++ db.recordAiUsage({
++ kind: o.kind || role, model: r.model, input_tokens: r.usage.input, output_tokens: r.usage.output, cached_tokens: r.usage.cached || 0,
++ cost_usd: r.cost || 0, owner_user_id: o.ownerUserId || null, source: o.source || null, role, provider: 'openvibe-ai', latency_ms: r.latencyMs,
++ });
++ } catch { /* metering is best-effort */ }
++ return r;
++ }
+ const p = resolveProvider(role, o.provider || null);
+ if (p.shared) { if (!isEnabled() || !withinBudget()) return null; }
+ else if (!p.apiKey && !/localhost|127\.0\.0\.1|:\d+$/.test(p.baseUrl) && /api\.openai\.com|anthropic\.com/i.test(p.baseUrl)) return null;
+@@ -357,4 +375,11 @@ async function testProvider(override = null) {
+ } catch (e) { return { ok: false, error: e.message }; }
+ }
+
++// Remote structured workflows (translate, paste/frame analysis, overviews, recap) meter here too.
++aiService.setRecorder((r, m) => db.recordAiUsage({
++ kind: m.kind || (r.workflow && r.workflow.key) || 'remote', model: (r.provenance && r.provenance.model) || null,
++ input_tokens: (r.usage && r.usage.tokens_in) || 0, output_tokens: (r.usage && r.usage.tokens_out) || 0, cached_tokens: 0,
++ cost_usd: (r.usage && r.usage.cost_usd) || 0, owner_user_id: m.ownerUserId || null, source: m.source || null, role: m.role || null, provider: 'openvibe-ai', latency_ms: null,
++}));
++
+ module.exports = { complete, testProvider, resolveProvider, modelForRole, priceFor, estimateCost, toVisionJpeg, parseJsonLoose, isEnabled, withinBudget, defaultModel, ROLES };
+diff --git a/server/ai/media-analysis.js b/server/ai/media-analysis.js
+index 3b4421c..a1da565 100644
+--- a/server/ai/media-analysis.js
+@@ -297,7 +297,14 @@ async function analyzeMedia(src, { streamId = null, userId = null, numFrames = n
+ if (frames.length) parts.push('Visual observations across the video (in order):\n' + frames.map(f => `- ${f.description}`).join('\n'));
+ if (transcript) parts.push('Audio transcript (sampled from the recording):\n"' + transcript.slice(0, 4000) + '"');
+ const prompt = `You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don't invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n${parts.join('\n\n')}`;
+- overview = await ai.summarizeText(prompt, 400, 'media_overview');
++ const aiService = require('./ai-service');
++ if (aiService.enabled()) {
++ // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.media.overview).
++ const o = ai.isEnabled() && ai.withinBudget()
++ ? await aiService.structured('live.media.overview', { frames: frames.map(f => String(f.description || '').slice(0, 1000)).filter(Boolean).slice(0, 60), transcript: String(transcript || '').slice(0, 200000) }, { meter: { kind: 'media_overview', role: 'legacy', ownerUserId: userId } })
++ : null;
++ overview = o && o.overview ? o.overview : null;
++ } else overview = await ai.summarizeText(prompt, 400, 'media_overview');
+ if (overview) overview = overview.slice(0, 2000);
+ }
+ return { overview: overview || null, transcript: transcript || '', segments: segments || [], frames, duration };
+diff --git a/server/i18n/translate.js b/server/i18n/translate.js
+index 64209fd..86514ef 100644
+--- a/server/i18n/translate.js
+@@ -23,6 +23,8 @@ const crypto = require('crypto');
+ const db = require('../db/database');
+ let llm = null;
+ try { llm = require('../ai/llm'); } catch { llm = null; }
++let aiService = null;
++try { aiService = require('../ai/ai-service'); } catch { aiService = null; }
+
+ const LANG_NAMES = {
+ en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', ar: 'Arabic',
+@@ -191,6 +193,16 @@ async function translate(text, { from = 'auto', to = 'en', context = 'chat', max
+ const ok = await _slot();
+ if (!ok) return null;
+ try {
++ if (aiService && aiService.enabled()) {
++ // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.translate).
++ const o = await aiService.structured('live.translate', { text: String(text).trim(), from, to, context, max_tokens: maxTokens || undefined });
++ if (o && o.unchanged) { _memSet(key, ''); return null; }
++ const remote = o && typeof o.text === 'string' ? o.text.trim() : '';
++ if (!remote) return null;
++ _memSet(key, remote);
++ try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, remote]); } catch { /* */ }
++ return remote;
++ }
+ const src = from === 'auto' ? 'the source language' : langName(from);
+ const kind = context === 'bio' ? 'a streamer\'s profile bio'
+ : context === 'speech' ? 'lines of live-stream speech (a casual gamer talking to his chat)'
+diff --git a/server/internal/routes.js b/server/internal/routes.js
+index 540fcb2..f5b4007 100644
+--- a/server/internal/routes.js
+@@ -32,6 +32,15 @@ router.post('/ai/site-copy', async (req, res) => {
+ const linkList = links.map(l => ({ id: clean(l.id, 60), name: clean(l.name, 60), about: clean(l.about, 120) }));
+ _siteCopyBusy = true;
+ try {
++ const aiService = require('../ai/ai-service');
++ if (aiService.enabled()) {
++ // AI_SERVICE=remote: OpenVibe.AI owns this workflow (network.site_copy). Network should call it
++ // directly; until it does, this endpoint answers from there instead of from Live's own key.
++ const run = await aiService.run('network.site_copy', { sites: facts, links: linkList });
++ const out = aiService.usable(run);
++ if (!out || !Array.isArray(out.sites)) return res.status(503).json({ ok: false, error: 'AI unavailable or over budget' });
++ return res.json({ ok: true, model: (run.provenance && run.provenance.model) || null, run_id: run.id, sites: out.sites });
++ }
+ const llm = require('../ai/llm');
+ const out = await llm.complete({
+ role: 'summary', kind: 'site_copy', source: 'network-footer', maxTokens: 2200, temperature: 0.7, timeoutMs: 60000,
+diff --git a/server/recap/recap.js b/server/recap/recap.js
+index 9bafab8..bb131e6 100644
+--- a/server/recap/recap.js
+@@ -148,8 +148,15 @@ async function aiWriteup(g) {
+ };
+ const system = `You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.`;
+ let r = null;
+- try { r = await llm.complete({ role: 'summary', kind: 'stream_recap', source: 'recap', ownerUserId: g.streamer.id, system, user: JSON.stringify(facts), json: SCHEMA, maxTokens: 500, temperature: 0.8, timeoutMs: 40000 }); } catch (e) { console.warn('[Recap] model call failed:', e.message); return null; }
+- const out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : null));
++ let out = null;
++ const aiService = require('../ai/ai-service');
++ if (aiService.enabled()) {
++ // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.stream.recap).
++ out = await aiService.structured('live.stream.recap', { facts }, { target: { service: 'live', type: 'stream', id: String(g.stream.id) }, meter: { kind: 'stream_recap', role: 'summary', source: 'recap', ownerUserId: g.streamer.id } });
++ } else {
++ try { r = await llm.complete({ role: 'summary', kind: 'stream_recap', source: 'recap', ownerUserId: g.streamer.id, system, user: JSON.stringify(facts), json: SCHEMA, maxTokens: 500, temperature: 0.8, timeoutMs: 40000 }); } catch (e) { console.warn('[Recap] model call failed:', e.message); return null; }
++ out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : null));
++ }
+ if (!out || !out.headline || !out.summary) return null;
+ return { headline: String(out.headline).trim().slice(0, 90), summary: String(out.summary).trim().slice(0, 500), moment: String(out.moment || '').trim().slice(0, 200), tags: (Array.isArray(out.tags) ? out.tags : []).map(t => String(t).trim().toLowerCase()).filter(Boolean).slice(0, 4), grade: ['S', 'A', 'B', 'C'].includes(out.grade) ? out.grade : 'B' };
+ }
+diff --git a/test/ai-service-client.test.js b/test/ai-service-client.test.js
+new file mode 100644
+index 0000000..024f740
+--- /dev/null
+@@ -0,0 +1,146 @@
++'use strict';
++
++// AI_SERVICE=remote (roadmap Wave 13): Live's AI goes to OpenVibe.AI as workflow runs with a service
++// token for audience openvibe.ai. Against a stub Network (token endpoint) and a stub AI service:
++// off by default; structured workflows return their output; llm.complete()-shaped calls become
++// passthrough runs of the workflow that owns the feature, attributed to the streamer; synthetic
++// (stub-provider) answers, failed runs, quota refusals and an unreachable service are all "no
++// answer" (null); a still-running run is polled; a rejected token is refreshed once.
++
++const assert = require('assert');
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++
++process.env.OV_OAUTH_CLIENT_ID = 'live';
++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
++delete process.env.AI_SERVICE;
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++let tokenCalls = 0;
++const calls = [];
++const script = { next: [] }; // queued responses for the AI stub: { status, body } | function
++
++const network = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ tokenCalls++;
++ const f = new URLSearchParams(raw);
++ const now = Math.floor(Date.now() / 1000);
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create', 'ai.run.read'], iat: now, exp: now + 300, jti: `tok_${tokenCalls}abcdefgh` }, keys.privateKey), expires_in: 300 }));
++ });
++});
++
++const ai = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const auth = String(req.headers.authorization || '');
++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.ai' });
++ const body = raw ? JSON.parse(raw) : null;
++ calls.push({ method: req.method, url: req.url, body, tokenOk: v.ok, jti: v.ok ? v.claims.jti : null });
++ let out = script.next.shift() || { status: 500, body: { code: 'unscripted' } };
++ if (typeof out === 'function') out = out(body, req);
++ res.statusCode = out.status;
++ res.setHeader('Content-Type', 'application/json');
++ if (out.headers) for (const [k, val] of Object.entries(out.headers)) res.setHeader(k, val);
++ res.end(JSON.stringify(out.body));
++ });
++});
++
++const run = (over = {}) => ({ id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: 'live.translate', version: 1 }, output: { text: 'hello everyone', unchanged: false }, provenance: { model: 'gpt-5-nano', provider: 'shared' }, usage: { tokens_in: 30, tokens_out: 5, cost_usd: 0.0001 }, ...over });
++
++(async () => {
++ await new Promise((r) => network.listen(0, '127.0.0.1', r));
++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;
++ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;
++ const svc = require('../server/ai/ai-service');
++ const metered = [];
++ svc.setRecorder((r, m) => metered.push({ run: r.id, ...m }));
++
++ // 0. Off by default: nothing is sent.
++ assert.strictEqual(svc.enabled(), false);
++ assert.strictEqual(await svc.structured('live.translate', { text: 'x', to: 'en' }), null);
++ assert.strictEqual(calls.length, 0, 'default behaviour unchanged: no calls to OpenVibe.AI');
++
++ process.env.AI_SERVICE = 'remote';
++ assert.strictEqual(svc.enabled(), true);
++
++ // 1. A structured workflow: token for openvibe.ai, the run's output back, usage metered for the streamer.
++ script.next.push({ status: 201, body: { run: run() } });
++ const out = await svc.structured('live.translate', { text: 'みなさん', from: 'ja', to: 'en' }, { meter: { kind: 'translate', ownerUserId: 42 } });
++ assert.deepStrictEqual(out, { text: 'hello everyone', unchanged: false });
++ const c1 = calls.pop();
++ assert.ok(c1.tokenOk, 'Live calls with a service token for audience openvibe.ai');
++ assert.strictEqual(c1.method, 'POST');
++ assert.match(c1.url, /^\/api\/v1\/runs\?wait=\d+$/);
++ assert.strictEqual(c1.body.workflow, 'live.translate');
++ assert.deepStrictEqual(c1.body.attribution, { service: 'live', type: 'user', id: '42' });
++ assert.deepStrictEqual(metered.pop(), { run: run().id, kind: 'translate', ownerUserId: 42 });
++
++ // 2. Synthetic (stub provider), failed runs, quota refusals and an unreachable service are "no answer".
++ script.next.push({ status: 201, body: { run: run({ synthetic: true }) } });
++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }, { meter: { kind: 'translate' } }), null);
++ assert.strictEqual(metered.length, 0, 'synthetic output is not metered');
++ script.next.push({ status: 201, body: { run: run({ status: 'failed', output: null, error: { code: 'provider.unavailable' } }) } });
++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);
++ script.next.push({ status: 429, headers: { 'Retry-After': '30' }, body: { code: 'quota.exceeded', detail: 'quota exceeded' } });
++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);
++ const saved = process.env.OV_AI_INTERNAL_URL;
++ process.env.OV_AI_INTERNAL_URL = 'http://127.0.0.1:1';
++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);
++ process.env.OV_AI_INTERNAL_URL = saved;
++
++ // 3. Still running after the wait: poll the run until it finishes.
++ script.next.push({ status: 202, body: { run: run({ status: 'running', output: null }) } });
++ script.next.push({ status: 200, body: { run: run({ status: 'running', output: null }) } });
++ script.next.push({ status: 200, body: { run: run() } });
++ calls.length = 0;
++ assert.deepStrictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }, { waitMs: 5000 }), run().output);
++ assert.deepStrictEqual(calls.map((c) => c.method), ['POST', 'GET', 'GET']);
++ assert.strictEqual(calls[1].url, `/api/v1/runs/${run().id}`);
++
++ // 4. A rejected token is refreshed once and the call retried.
++ const before = tokenCalls;
++ script.next.push({ status: 401, body: { code: 'token.expired' } });
++ script.next.push({ status: 201, body: { run: run() } });
++ calls.length = 0;
++ assert.ok(await svc.structured('live.translate', { text: 'a b', to: 'en' }));
++ assert.strictEqual(tokenCalls, before + 1, 'a new token was fetched');
++ assert.notStrictEqual(calls[0].jti, calls[1].jti);
++
++ // 5. llm.complete()-shaped calls: passthrough run of the owning workflow, llm.complete result shape back.
++ script.next.push((body) => ({ status: 201, body: { run: run({ workflow: { key: body.workflow, version: 1 }, output: { text: '{"skip":true,"lines":[]}', json: { skip: true, lines: [] } }, provenance: { model: 'gpt-5' } }) } }));
++ calls.length = 0;
++ const r = await svc.complete({
++ role: 'director', kind: 'ai_viewers_director', source: 'ai_viewers', ownerUserId: 7,
++ system: [{ text: 'RULES', cache: true }, { text: '' }], user: 'plan now', json: { name: 'plan', schema: { type: 'object' }, strict: true }, maxTokens: 330, temperature: 0.9, cacheKey: 'chan:7',
++ });
++ const sent = calls[0].body;
++ assert.strictEqual(sent.workflow, 'live.viewers.plan');
++ assert.deepStrictEqual(sent.input.system, [{ text: 'RULES', cache: true }]);
++ assert.strictEqual(sent.input.role, 'director');
++ assert.strictEqual(sent.input.user, 'plan now');
++ assert.strictEqual(sent.input.max_tokens, 330);
++ assert.deepStrictEqual(sent.input.json.schema, { type: 'object' });
++ assert.deepStrictEqual(sent.attribution, { service: 'live', type: 'user', id: '7' });
++ assert.deepStrictEqual(r.json, { skip: true, lines: [] });
++ assert.strictEqual(r.model, 'gpt-5');
++ assert.deepStrictEqual(r.usage, { input: 30, output: 5, cached: 0 });
++ assert.strictEqual(r.provider, 'openvibe-ai');
++ assert.strictEqual(svc.workflowFor('something_new'), 'live.complete', 'unknown kinds still have a workflow');
++
++ // 6. Images: OpenVibe.Media URLs are passed as URLs; anything else is downscaled here and sent inline.
++ assert.deepStrictEqual(await svc.imageInput('https://openvibe.media/t/9', {}), { url: 'https://openvibe.media/t/9', max_width: 1024 });
++ const inline = await svc.imageInput('/tmp/frame.jpg', { toVisionJpeg: async () => 'data:image/jpeg;base64,AAAA', maxWidth: 768 });
++ assert.deepStrictEqual(inline, { data_url: 'data:image/jpeg;base64,AAAA', max_width: 768 });
++ assert.strictEqual(await svc.imageInput('https://evil.example/x.png', { toVisionJpeg: async () => null }), null, 'other URLs are not forwarded');
++
++ network.close();
++ ai.close();
++ console.log('ai service client: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+# Moving Live's AI into OpenVibe.AI
+
+OpenVibe.Live grew into the network's de facto AI service: every feature called model providers
+through `server/ai/llm.js` with the shared key held in Live's admin settings, and Network's footer
+copy called Live's `/internal/ai/site-copy`. This document is the cutover plan: what moves here,
+what stays in Live, how to import, and how to switch and roll back.
+
+## What moves and what stays
+
+### Records that move here
+
+| Live source | Becomes in OpenVibe.AI | How |
+|---|---|---|
+| `site_settings.ai_provider`, `ai_base_url`, `ai_model`, `ai_enabled`, `ai_input_cost_per_mtok`, `ai_output_cost_per_mtok` | the `shared` provider and its default model | **environment**, with the same names upper-cased (`AI_PROVIDER`, …). The import prints the values and records a hold |
+| `site_settings.ai_api_key` | `shared.secret_ref = env:AI_API_KEY` | **environment only**. The import records a `secret` hold and never prints the value |
+| `site_settings.ai_model_chat` / `_vision` / `_director` / `_summary` | a new version of route `live.<role>` with that model | import (`created_by = import`; the boot seed never overwrites it) |
+| `site_settings.ai_pricing_json` | `models` rows for provider `shared` with cost metadata | import. The `default` entry and prefix matching stay in `AI_PRICING_JSON` (held) |
+| `site_settings.ai_max_cost_usd_per_day` | quota `global` / `day` / `max_cost_usd` | import (also `AI_MAX_COST_USD_PER_DAY` at boot) |
+| `site_settings.ai_viewers_global_cap_usd_per_day` | quota `global` / `day` / `max_cost_usd`, `workflow_prefix live.viewers.` | import |
+| `ai_usage` | `usage_daily` (requester `service:live`, attribution `live:user:<owner>`, workflow from the Live kind, provider `shared` or `live-byo`) and today's quota windows | import, one ledger row per usage id |
+| `channel_ai_config.daily_budget_cents` (shared key) | quota `attribution` `live:user:<id>` / `day` / `max_cost_usd`, prefix `live.viewers.` | import |
+| `channel_ai_config` BYO key / base URL | — | **held** (`byo_provider`): per-actor provider secrets are not stored here yet, so BYO calls keep going straight to the streamer's provider from Live |
+| `translations` | — | **held** (`unrepresentable`): rows are keyed by `sha1(from\|to\|text)` without the source text, so they cannot become input-hashed cache entries. Live keeps them as its read-through cache; new translations are also cached here (per service) |
+| `ai_timeline_cache` | — | **held** (`stays_in_live`): an assembled Live read model with no model output |
+| `ai_chatbot_configs` | — | **held** (`superseded`): Live already migrated it into `channel_ai_config` |
+| Live's prompts | versioned templates and workflows (see below) | code (`server/workflows/*.js`), seeded at boot |
+
+### Records that stay in Live
+
+`stream_memories`, `stream_timeline_events`, `streamer_overviews`, `stream_recaps`, `vod_ai_state`,
+`clip_ai_state`, `chat_ai_summaries`, `channel_ai_bots`, `ai_viewer_threads`, `ai_viewer_log`,
+`channel_ai_config.settings_json`, and the `ai_*_enabled` feature switches. They are Live's product
+state: AI produces a draft/evidence package (a run), and Live decides what to store and show. Each
+run is attributable to a workflow, its version, a model and a run id — never to a person — and Live
+can keep that `run_id` next to what it stored.
+
+## Live features and their workflows
+
+Structured workflows own their prompt here (templates copied verbatim from Live):
+
+| Live code | Workflow |
+|---|---|
+| `i18n/translate.js` translate / translateChatMessage / translateLines | `live.translate` (cache per service) |
+| `ai-analysis.analyzeImagePaste` (paste_image, moment_frame) | `live.paste.describe_image` |
+| `ai-analysis.analyzeTextPaste` | `live.paste.summarize_text` |
+| `ai-analysis.analyzeStreamFrame` (stream memories, live pastes) | `live.stream.describe_frame` |
+| `ai-analysis.summarizeStreamMemories` (overview + inferred category) | `live.stream.summarize` |
+| `ai-analysis.generateStreamerOverview` | `live.streamer.overview` |
+| `media-analysis.analyzeMedia` (VOD/clip overview) | `live.media.overview` |
+| `recap/recap.js` aiWriteup | `live.stream.recap` |
+| `internal/routes.js` `/internal/ai/site-copy` (Network footer) | `network.site_copy` |
+| `transcribe.js` (whisper.cpp) | `live.media.transcribe` — available here; Live keeps transcribing locally for now (see below) |
+
+Passthrough workflows carry a prompt Live still renders itself (the run keeps only its hash):
+
+| Live `llm.complete()` kind | Workflow |
+|---|---|
+| `ai_viewers_director` / `ai_viewers_reply` / `ai_viewers` / `ai_viewers_fold` | `live.viewers.plan` / `.reply` / `.line` / `.fold` |
+| `chat_global`, `chat_user`, `chat_relay`, `chat_anon`, `combined_overview`, `session_titles` | `live.chat.insight` |
+| `moment_vod_rank`, `moment_pick` | `live.moments.rank`, `live.moments.pick` |
+| `auto_clip_confirm` | `live.clips.confirm` |
+| `hero_slogans`, `easter_egg`, `home_star` | `live.hero.slogans`, `live.easter_egg`, `live.home.star` |
+| `arena_persona`, `arena_quotes`, `arena_scene`, `arena_beef_judge`/`arena_mic_judge`, `arena_headline` | `live.arena.*` |
+| `status_check` | `live.status_check` |
+| anything else | `live.complete` |
+
+Moving a passthrough feature's prompt here later is a normal change: add a template and a
+structured workflow, point Live's call at it, and the passthrough key stays for old runs.
+
+## Cutover
+
+1. **Deploy OpenVibe.AI** (`/opt/openvibe.ai`, unit `deploy/systemd/openvibe-ai.service`, env
+ `/etc/openvibe/ai.env` from `.env.example`). Leave `AI_STUB_FALLBACK` off in production.
+2. **Dry-run the import** against a snapshot of Live's database:
+ ```bash
+ node scripts/import-from-live.js --live-db /path/live-snapshot.db --dry-run
+ ```
+ Copy the `environment` lines it prints into `/etc/openvibe/ai.env` (set `AI_API_KEY` from Live's
+ `ai_api_key` yourself — the script never prints it), restart, check `GET /api/ready`.
+3. **Import**: the same command without `--dry-run`. Re-running is safe: the ledger skips what is
+ already imported and applies changed settings once. Review `import_holds`.
+4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for
+ audience `openvibe.ai`, namespaces `live.*` and `network.*` (the latter only while Live still
+ answers `/internal/ai/site-copy`).
+5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:
+ ```
+ AI_SERVICE=remote
+ OV_AI_INTERNAL_URL=http://127.0.0.1:4700
+ ```
+ and restart Live. Live keeps its `ai_enabled` switch and local daily cap, and keeps writing
+ `ai_usage` rows (provider `openvibe-ai`) so per-streamer AI-viewer budgets and the admin cost
+ page work unchanged.
+6. **Verify**: `GET /api/v1/runs?workflow=live.translate` (with an `ai.usage.read` token) shows runs
+ from `service:live`; Live's logs show no `[AI service]` warnings.
+7. **Network**: give Network's client `ai.run.create` with namespace `network.*` and call
+ `POST /api/v1/runs {"workflow":"network.site_copy", ...}` directly instead of Live's
+ `/internal/ai/site-copy` (same input and output shape). Until then Live's endpoint forwards to
+ the same workflow.
+
+**Rollback**: unset `AI_SERVICE` in `live.env` and restart Live. Nothing in Live's database changed
+shape; the shared key must still be in Live's settings for that path (keep it until the cutover
+has held for a while).
+
+## Not moved yet
+
+- **Streamer BYO providers** stay in Live (`llm.js` provider override): they need per-actor
+ provider records with secret references here first.
+- **Local transcription** (live timeline segments, VOD/clip transcripts, stream memories audio)
+ still runs whisper.cpp inside Live: live segments are local files with a latency budget, and the
+ VOD path resumes per 5-minute window. `live.media.transcribe` is ready here (fetches from
+ allow-listed OpenVibe hosts only) for when VOD transcription moves.
+- **YAMNet sound events** (`audio-events.js`) and frame capture (`stream-vision.js`) are Live
+ capture code, not model calls; only the vision call moved.
+- **Run events** (`ai.run.queued|succeeded|failed|cached`) are not published to OpenVibe.Events yet.
new file mode 100644
new file mode 100644
@@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Import the AI records that belong to OpenVibe.AI from a snapshot of OpenVibe.Live's database.
+ *
+ * node scripts/import-from-live.js --live-db /path/to/live-snapshot.db [--ai-db ./data/ai.db] [--dry-run]
+ *
+ * Reads the Live snapshot READ-ONLY. Idempotent: every imported source row is recorded in
+ * import_ledger (source, key, content hash), so a second run imports nothing new and a changed
+ * setting is applied once. Rows this service cannot represent are recorded in import_holds with a
+ * reason — never silently dropped, never guessed. --dry-run prints the plan and writes nothing.
+ *
+ * What moves (see docs/migration.md for the full table):
+ * site_settings ai_model_<role> -> a new version of route live.<role> with that model (origin import)
+ * site_settings ai_pricing_json -> model rows with cost metadata for provider `shared`
+ * site_settings ai_max_cost_usd_per_day -> quota global/day max_cost_usd
+ * site_settings ai_viewers_global_cap_usd_per_day -> quota global/day max_cost_usd, workflow_prefix live.viewers.
+ * site_settings ai_provider / ai_base_url / ai_model / ai_api_key -> HELD: these are environment
+ * (AI_PROVIDER, AI_BASE_URL, AI_MODEL, AI_API_KEY); the script prints which names to set, never values of secrets
+ * ai_usage -> usage_daily (per day, requester service:live, attribution live:user:<owner>,
+ * workflow by Live kind), plus today's quota windows
+ * channel_ai_config -> attribution quota live:user:<id>/day for live.viewers.* (shared key budgets);
+ * BYO keys are HELD (per-actor provider secrets are not stored here yet)
+ * translations, ai_timeline_cache, ai_chatbot_configs -> HELD (one row each; reasons in the hold)
+ * Everything else AI-shaped in Live (stream_memories, streamer_overviews, stream_recaps, vod/clip_ai_state,
+ * chat_ai_summaries, channel_ai_bots, ai_viewer_*) is Live product state and stays in Live.
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { load } = require('../server/config');
+const { openDb } = require('../server/db');
+const { createRegistry } = require('../server/registry');
+const { createQuotas } = require('../server/quota');
+const { seed } = require('../server/workflows/seed');
+const { KIND_TO_WORKFLOW } = require('../server/workflows/live');
+const { sha256, iso } = require('../server/util');
+
+/** Live ai_usage.kind -> the workflow that now does that job. */
+const KIND_MAP = {
+ ...KIND_TO_WORKFLOW,
+ translate: 'live.translate', paste_image: 'live.paste.describe_image', moment_frame: 'live.paste.describe_image', paste_text: 'live.paste.summarize_text',
+ stream_memory: 'live.stream.summarize', streamer_overview: 'live.streamer.overview', media_overview: 'live.media.overview', stream_recap: 'live.stream.recap',
+ site_copy: 'network.site_copy',
+};
+const ROLES = ['chat', 'vision', 'director', 'summary'];
+
+function parseArgs(argv) {
+ const out = { dryRun: false, liveDb: null, aiDb: null, quiet: false };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--dry-run') out.dryRun = true;
+ else if (a === '--quiet') out.quiet = true;
+ else if (a === '--live-db') out.liveDb = argv[++i];
+ else if (a === '--ai-db') out.aiDb = argv[++i];
+ else if (a === '-h' || a === '--help') out.help = true;
+ else throw new Error(`unknown argument ${a}`);
+ }
+ return out;
+}
+
+function hasTable(db, name) {
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
+}
+
+function run({ liveDb, aiDb, dryRun = false, env = process.env, log = console.log, now = Date.now() } = {}) {
+ if (!liveDb || !fs.existsSync(liveDb)) throw new Error('--live-db <snapshot> is required and must exist');
+ const live = new Database(liveDb, { readonly: true, fileMustExist: true });
+ const config = load({ ...env, AI_DB_PATH: aiDb || env.AI_DB_PATH || './data/ai.db' });
+ const aiPath = path.resolve(config.dbPath);
+
+ // Dry run: never create or write the AI database; read its ledger if it already exists.
+ let db = null;
+ let registry = null;
+ let quotas = null;
+ if (!dryRun) {
+ db = openDb(aiPath);
+ registry = createRegistry(db, { env });
+ quotas = createQuotas(db, { registry });
+ seed({ registry, quotas, config, env, db });
+ } else if (fs.existsSync(aiPath)) {
+ db = new Database(aiPath, { readonly: true, fileMustExist: true });
+ }
+ const ledgerGet = (source, key) => (db && hasTable(db, 'import_ledger') ? db.prepare('SELECT target FROM import_ledger WHERE source = ? AND source_key = ?').get(source, String(key)) : null);
+ const holdGet = (source, key) => (db && hasTable(db, 'import_holds') ? db.prepare('SELECT 1 FROM import_holds WHERE source = ? AND source_key = ?').get(source, String(key)) : null);
+ const stats = {};
+ const bump = (source, what, n = 1) => { stats[source] = stats[source] || { imported: 0, unchanged: 0, held: 0 }; stats[source][what] += n; };
+ const ledger = (source, key, target) => { if (!dryRun) db.prepare('INSERT OR REPLACE INTO import_ledger (source, source_key, target, imported_at) VALUES (?, ?, ?, ?)').run(source, String(key), target, iso(now)); };
+ const hold = (source, key, reason, detail = null) => {
+ if (holdGet(source, key)) return bump(source, 'unchanged');
+ if (!dryRun) db.prepare('INSERT OR REPLACE INTO import_holds (source, source_key, reason, detail, held_at) VALUES (?, ?, ?, ?, ?)').run(source, String(key), reason, detail, iso(now));
+ bump(source, 'held');
+ };
+ /** Apply once per (source, key, content): unchanged content is skipped on re-runs. */
+ const once = (source, key, content, apply) => {
+ const h = sha256(content);
+ const prev = ledgerGet(source, key);
+ if (prev && prev.target.endsWith(`#${h}`)) return bump(source, 'unchanged');
+ const target = dryRun ? 'dry-run' : apply();
+ ledger(source, key, `${target}#${h}`);
+ bump(source, 'imported');
+ };
+
+ // ── site_settings ──
+ const settings = hasTable(live, 'site_settings') ? Object.fromEntries(live.prepare("SELECT key, value FROM site_settings WHERE key LIKE 'ai_%'").all().map(r => [r.key, r.value])) : {};
+ const envAdvice = [];
+ for (const [k, envName] of [['ai_provider', 'AI_PROVIDER'], ['ai_base_url', 'AI_BASE_URL'], ['ai_model', 'AI_MODEL'], ['ai_enabled', 'AI_ENABLED'], ['ai_input_cost_per_mtok', 'AI_INPUT_COST_PER_MTOK'], ['ai_output_cost_per_mtok', 'AI_OUTPUT_COST_PER_MTOK']]) {
+ if (settings[k] != null && settings[k] !== '') { envAdvice.push(`${envName}=${settings[k]}`); hold('live.site_settings', k, 'environment', `set ${envName} in /etc/openvibe/ai.env`); }
+ }
+ if (settings.ai_api_key) {
+ envAdvice.push('AI_API_KEY=<copy the value of Live\'s ai_api_key setting; not printed>');
+ hold('live.site_settings', 'ai_api_key', 'secret', 'secrets are environment references (env:AI_API_KEY); set it in /etc/openvibe/ai.env');
+ }
+ for (const role of ROLES) {
+ const m = String(settings[`ai_model_${role}`] || '').trim();
+ if (!m) continue;
+ once('live.site_settings', `ai_model_${role}`, m, () => {
+ const cur = registry.getRoute(`live.${role}`);
+ const v = registry.createRouteVersion(`live.${role}`, { primary: { provider: 'shared', model: m }, fallbacks: cur ? cur.fallbacks : [] }, { actor: 'import' });
+ return `route:live.${role}@${v.version}`;
+ });
+ }
+ if (settings.ai_pricing_json) {
+ let table = null;
+ try { table = JSON.parse(settings.ai_pricing_json); } catch { table = null; }
+ if (!table || typeof table !== 'object') hold('live.site_settings', 'ai_pricing_json', 'unparseable', 'not JSON');
+ else {
+ for (const [model, p] of Object.entries(table)) {
+ if (model === 'default' || !p || typeof p !== 'object') { hold('live.site_settings', `ai_pricing_json.${model}`, 'environment', 'prefix/default pricing stays in AI_PRICING_JSON'); continue; }
+ once('live.site_settings', `ai_pricing_json.${model}`, p, () => {
+ registry.upsertModel({ provider_key: 'shared', model_key: model, type: 'chat', cost: { in_per_mtok: Number(p.in), out_per_mtok: Number(p.out), cached_per_mtok: p.cached != null ? Number(p.cached) : null }, metadata: { imported_from: 'live.ai_pricing_json' } }, { actor: 'import' });
+ return `model:shared/${model}`;
+ });
+ }
+ }
+ }
+ const cap = Number(settings.ai_max_cost_usd_per_day);
+ if (cap > 0) once('live.site_settings', 'ai_max_cost_usd_per_day', cap, () => `quota:${quotas.upsert({ scope_type: 'global', window: 'day', max_cost_usd: cap }, { actor: 'import', origin: 'import' }).id}`);
+ const vcap = Number(settings.ai_viewers_global_cap_usd_per_day);
+ if (vcap > 0) once('live.site_settings', 'ai_viewers_global_cap_usd_per_day', vcap, () => `quota:${quotas.upsert({ scope_type: 'global', window: 'day', max_cost_usd: vcap, workflow_prefix: 'live.viewers.' }, { actor: 'import', origin: 'import' }).id}`);
+
+ // ── ai_usage -> usage_daily (+ today's windows) ──
+ if (hasTable(live, 'ai_usage')) {
+ const cols = live.prepare('PRAGMA table_info(ai_usage)').all().map(c => c.name);
+ const has = (c) => cols.includes(c);
+ const rows = live.prepare(`SELECT id, kind, model, input_tokens, output_tokens, cost_usd, created_at,
+ ${has('owner_user_id') ? 'owner_user_id' : 'NULL AS owner_user_id'}, ${has('cached_tokens') ? 'cached_tokens' : '0 AS cached_tokens'}, ${has('provider') ? 'provider' : "'shared' AS provider"}
+ FROM ai_usage ORDER BY id`).all();
+ const today = iso(now).slice(0, 10);
+ const dayStart = Math.floor(now / 86400000) * 86400;
+ const insDaily = db && !dryRun ? db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)
+ VALUES (?, 'service:live', ?, ?, ?, ?, 1, ?, ?, ?, ?)
+ ON CONFLICT(day, requester, attribution, workflow_key, provider_key, model_key) DO UPDATE SET requests = requests + 1,
+ tokens_in = tokens_in + excluded.tokens_in, tokens_out = tokens_out + excluded.tokens_out, tokens_cached = tokens_cached + excluded.tokens_cached, cost_usd = cost_usd + excluded.cost_usd`) : null;
+ const insCounter = db && !dryRun ? db.prepare(`INSERT INTO usage_counters (scope_type, scope_id, window, window_start, workflow_prefix, requests, tokens, cost_usd) VALUES (?, ?, 'day', ?, '', 1, ?, ?)
+ ON CONFLICT(scope_type, scope_id, window, window_start, workflow_prefix) DO UPDATE SET requests = requests + 1, tokens = tokens + excluded.tokens, cost_usd = cost_usd + excluded.cost_usd`) : null;
+ const tx = (fn) => (db && !dryRun ? db.transaction(fn)() : fn());
+ tx(() => {
+ for (const r of rows) {
+ if (ledgerGet('live.ai_usage', r.id)) { bump('live.ai_usage', 'unchanged'); continue; }
+ const day = String(r.created_at || '').slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { hold('live.ai_usage', r.id, 'unrepresentable', 'no valid created_at'); continue; }
+ const wf = KIND_MAP[r.kind] || 'live.complete';
+ const attribution = r.owner_user_id ? `live:user:${r.owner_user_id}` : '';
+ const provider = r.provider === 'byo' ? 'live-byo' : 'shared';
+ const tokens = (Number(r.input_tokens) || 0) + (Number(r.output_tokens) || 0);
+ if (!dryRun) {
+ insDaily.run(day, attribution, wf, provider, r.model || '', Number(r.input_tokens) || 0, Number(r.output_tokens) || 0, Number(r.cached_tokens) || 0, Number(r.cost_usd) || 0);
+ if (day === today && provider === 'shared') {
+ insCounter.run('global', '*', dayStart, tokens, Number(r.cost_usd) || 0);
+ insCounter.run('service', 'live', dayStart, tokens, Number(r.cost_usd) || 0);
+ if (attribution) insCounter.run('attribution', attribution, dayStart, tokens, Number(r.cost_usd) || 0);
+ }
+ }
+ ledger('live.ai_usage', r.id, `usage_daily:${day}/${wf}#`);
+ bump('live.ai_usage', 'imported');
+ }
+ });
+ }
+
+ // ── channel_ai_config -> per-streamer AI-viewer budgets; BYO keys held ──
+ if (hasTable(live, 'channel_ai_config')) {
+ for (const c of live.prepare('SELECT user_id, use_shared_key, daily_budget_cents, byo_key, byo_base_url FROM channel_ai_config').all()) {
+ if (Number(c.use_shared_key) === 1 || c.use_shared_key == null) {
+ const usd = (Number(c.daily_budget_cents) || 0) / 100;
+ if (usd <= 0) { bump('live.channel_ai_config', 'unchanged'); continue; }
+ once('live.channel_ai_config', c.user_id, usd, () => `quota:${quotas.upsert({ scope_type: 'attribution', scope_id: `live:user:${c.user_id}`, window: 'day', max_cost_usd: usd, workflow_prefix: 'live.viewers.' }, { actor: 'import', origin: 'import' }).id}`);
+ } else {
+ let host = '';
+ try { host = c.byo_base_url ? new URL(c.byo_base_url).host : ''; } catch { host = ''; }
+ hold('live.channel_ai_config', c.user_id, 'byo_provider', `streamer ${c.user_id} uses their own ${c.byo_key ? 'key' : 'self-hosted endpoint'}${host ? ` (${host})` : ''}; BYO providers keep running in Live (llm.js provider override) until per-actor provider secrets exist here`);
+ }
+ }
+ }
+
+ // ── Whole-table holds ──
+ const tableHold = (table, reason, detail) => {
+ if (!hasTable(live, table)) return;
+ const n = live.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
+ hold(`live.${table}`, '*', reason, `${n} row(s): ${detail}`);
+ };
+ tableHold('translations', 'unrepresentable', 'keyed by sha1(from|to|text) without the source text, so they cannot become input-hashed cache entries; Live keeps them as its local read-through cache and new translations are cached here per service');
+ tableHold('ai_timeline_cache', 'stays_in_live', 'an assembled Live read model (no model output); Live keeps serving it');
+ tableHold('ai_chatbot_configs', 'superseded', 'Live already migrated these rows into channel_ai_config');
+
+ live.close();
+ if (db) db.close();
+ const report = { dry_run: dryRun, ai_db: aiPath, sources: stats, environment: envAdvice };
+ log(JSON.stringify(report, null, 2));
+ return report;
+}
+
+if (require.main === module) {
+ require('dotenv').config();
+ let args;
+ try { args = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
+ if (args.help || !args.liveDb) {
+ console.error('usage: node scripts/import-from-live.js --live-db <snapshot.db> [--ai-db <ai.db>] [--dry-run]');
+ process.exit(args.help ? 0 : 2);
+ }
+ try { run({ liveDb: args.liveDb, aiDb: args.aiDb, dryRun: args.dryRun }); } catch (e) { console.error(`[import] ${e.message}`); process.exit(1); }
+}
+
+module.exports = { run, KIND_MAP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+// scripts/import-from-live.js against a synthetic Live snapshot: --dry-run writes nothing; a real
+// run imports usage, budgets, per-role models and pricing, and holds what cannot be represented
+// (secrets, BYO keys, text-less translation cache); a second run imports nothing; a new Live row
+// is picked up alone. Secret values never appear in the report or the holds.
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { suite, tmpDir } = require('./helpers');
+const { run } = require('../scripts/import-from-live');
+
+const t = suite('import');
+const dir = tmpDir();
+const liveDb = path.join(dir, 'live-snapshot.db');
+const aiDb = path.join(dir, 'ai.db');
+const SECRET = 'sk-live-snapshot-secret-DO-NOT-PRINT';
+const env = { NODE_ENV: 'test', AI_QUOTA_SERVICE_RPM: '0', AI_QUOTA_SERVICE_RPD: '0' };
+const quiet = () => {};
+const today = new Date().toISOString().slice(0, 10);
+
+t.test('build a Live snapshot', () => {
+ const d = new Database(liveDb);
+ d.exec(`CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', description TEXT, type TEXT, updated_at DATETIME);
+ CREATE TABLE ai_usage (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, cost_usd REAL, owner_user_id INTEGER, source TEXT, created_at DATETIME, cached_tokens INTEGER, role TEXT, provider TEXT, latency_ms INTEGER);
+ CREATE TABLE channel_ai_config (user_id INTEGER PRIMARY KEY, enabled INTEGER, use_shared_key INTEGER, daily_budget_cents INTEGER, byo_key TEXT, byo_base_url TEXT, byo_model TEXT, settings_json TEXT);
+ CREATE TABLE translations (key TEXT PRIMARY KEY, src TEXT, dst TEXT, text TEXT NOT NULL, created_at DATETIME);
+ CREATE TABLE ai_timeline_cache (user_id INTEGER PRIMARY KEY, payload TEXT, generated_at DATETIME);
+ CREATE TABLE ai_chatbot_configs (user_id INTEGER PRIMARY KEY, api_token TEXT);`);
+ const set = d.prepare('INSERT INTO site_settings (key, value) VALUES (?, ?)');
+ for (const [k, v] of [['ai_enabled', 'true'], ['ai_provider', 'openai'], ['ai_api_key', SECRET], ['ai_model', 'gpt-5-mini'], ['ai_model_director', 'gpt-5'], ['ai_model_vision', ''],
+ ['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"default":{"in":1,"out":2}}'], ['ai_max_cost_usd_per_day', '5'], ['ai_viewers_global_cap_usd_per_day', '2']]) set.run(k, v);
+ const use = d.prepare('INSERT INTO ai_usage (kind, model, input_tokens, output_tokens, cost_usd, owner_user_id, created_at, cached_tokens, provider) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
+ use.run('translate', 'gpt-5-nano', 100, 20, 0.01, null, `${today} 10:00:00`, 0, 'shared');
+ use.run('ai_viewers_director', 'gpt-5', 1000, 200, 0.2, 42, `${today} 10:01:00`, 500, 'shared');
+ use.run('ai_viewers', 'llama3', 50, 10, 0, 7, '2026-09-01 08:00:00', 0, 'byo');
+ use.run('weird_kind', 'gpt-5-mini', 10, 10, 0.001, null, '2026-09-02 08:00:00', 0, 'shared');
+ use.run('paste_text', 'gpt-5-mini', 10, 10, 0.001, null, 'garbage', 0, 'shared');
+ const ch = d.prepare('INSERT INTO channel_ai_config (user_id, use_shared_key, daily_budget_cents, byo_key, byo_base_url) VALUES (?, ?, ?, ?, ?)');
+ ch.run(42, 1, 20, '', '');
+ ch.run(7, 0, 20, 'sk-byo-streamer-secret', 'https://llm.example.org/v1');
+ d.prepare("INSERT INTO translations (key, src, dst, text) VALUES ('abc', 'ja', 'en', 'hello')").run();
+ d.close();
+});
+
+t.test('--dry-run reports the plan and writes nothing', () => {
+ const r = run({ liveDb, aiDb, dryRun: true, env, log: quiet });
+ assert.strictEqual(r.dry_run, true);
+ assert.strictEqual(fs.existsSync(aiDb), false, 'no AI database created');
+ assert.strictEqual(r.sources['live.ai_usage'].imported, 4);
+ assert.strictEqual(r.sources['live.ai_usage'].held, 1);
+ assert.ok(!JSON.stringify(r).includes(SECRET));
+});
+
+t.test('a real run imports and holds, without leaking secrets', () => {
+ const r = run({ liveDb, aiDb, env, log: quiet });
+ assert.ok(!JSON.stringify(r).includes(SECRET) && !JSON.stringify(r).includes('sk-byo'));
+ assert.ok(r.environment.some(l => l.startsWith('AI_API_KEY=<')), 'tells the operator which secret NAME to set');
+ const d = new Database(aiDb, { readonly: true });
+ const quotas = d.prepare('SELECT * FROM quotas WHERE origin = ?').all('import');
+ assert.ok(quotas.some(q => q.scope_type === 'global' && q.max_cost_usd === 5 && !q.workflow_prefix));
+ assert.ok(quotas.some(q => q.scope_type === 'global' && q.max_cost_usd === 2 && q.workflow_prefix === 'live.viewers.'));
+ assert.ok(quotas.some(q => q.scope_type === 'attribution' && q.scope_id === 'live:user:42' && q.max_cost_usd === 0.2));
+ const route = d.prepare("SELECT * FROM routes WHERE key = 'live.director' ORDER BY version DESC LIMIT 1").get();
+ assert.strictEqual(route.primary_model, 'gpt-5');
+ assert.strictEqual(route.created_by, 'import');
+ const model = d.prepare("SELECT * FROM models WHERE provider_key = 'shared' AND model_key = 'gpt-5-nano'").get();
+ assert.strictEqual(model.cost_in_per_mtok, 0.05);
+ const usage = d.prepare('SELECT * FROM usage_daily').all();
+ assert.ok(usage.some(u => u.workflow_key === 'live.translate' && u.day === today));
+ assert.ok(usage.some(u => u.workflow_key === 'live.viewers.plan' && u.attribution === 'live:user:42' && u.tokens_cached === 500));
+ assert.ok(usage.some(u => u.provider_key === 'live-byo'));
+ assert.ok(usage.some(u => u.workflow_key === 'live.complete'), 'unknown kinds land on live.complete');
+ const counter = d.prepare("SELECT * FROM usage_counters WHERE scope_type = 'global' AND window = 'day'").get();
+ assert.ok(Math.abs(counter.cost_usd - 0.21) < 1e-9, "today's spend counts toward today's cap");
+ const holds = d.prepare('SELECT * FROM import_holds').all();
+ const reasons = Object.fromEntries(holds.map(h => [`${h.source}:${h.source_key}`, h.reason]));
+ assert.strictEqual(reasons['live.site_settings:ai_api_key'], 'secret');
+ assert.strictEqual(reasons['live.channel_ai_config:7'], 'byo_provider');
+ assert.strictEqual(reasons['live.translations:*'], 'unrepresentable');
+ assert.strictEqual(reasons['live.ai_usage:5'], 'unrepresentable');
+ assert.ok(!JSON.stringify(holds).includes(SECRET) && !JSON.stringify(holds).includes('sk-byo'));
+ d.close();
+});
+
+t.test('a second run imports nothing and changes no totals', () => {
+ const before = new Database(aiDb, { readonly: true });
+ const sum = (x) => x.prepare('SELECT SUM(requests) r, SUM(cost_usd) c FROM usage_daily').get();
+ const s1 = sum(before);
+ before.close();
+ const r = run({ liveDb, aiDb, env, log: quiet });
+ for (const [src, s] of Object.entries(r.sources)) assert.strictEqual(s.imported + s.held, 0, `${src} imported again: ${JSON.stringify(s)}`);
+ const after = new Database(aiDb, { readonly: true });
+ assert.deepStrictEqual(sum(after), s1);
+ assert.strictEqual(after.prepare("SELECT COUNT(*) n FROM routes WHERE key = 'live.director'").get().n, 2, 'no extra route version');
+ after.close();
+});
+
+t.test('new Live rows and changed settings are picked up alone', () => {
+ const d = new Database(liveDb);
+ d.prepare("INSERT INTO ai_usage (kind, model, input_tokens, output_tokens, cost_usd, created_at, provider) VALUES ('hero_slogans', 'gpt-5-mini', 5, 5, 0.002, ?, 'shared')").run(`${today} 11:00:00`);
+ d.prepare("UPDATE site_settings SET value = '8' WHERE key = 'ai_max_cost_usd_per_day'").run();
+ d.close();
+ const r = run({ liveDb, aiDb, env, log: quiet });
+ assert.strictEqual(r.sources['live.ai_usage'].imported, 1);
+ assert.strictEqual(r.sources['live.site_settings'].imported, 1);
+ const a = new Database(aiDb, { readonly: true });
+ assert.strictEqual(a.prepare("SELECT max_cost_usd FROM quotas WHERE scope_type = 'global' AND workflow_prefix IS NULL").get().max_cost_usd, 8);
+ a.close();
+});
+
+t.run();