strtolower(trim($vars['AI_ENABLED'] ?? 'false')) === 'true', 'url' => rtrim(trim($vars["{$host}_OLLAMA_URL"] ?? ''), '/'), 'model' => trim($vars["{$host}_OLLAMA_MODEL"] ?? ''), 'embed_model' => trim($vars["{$host}_OLLAMA_EMBED_MODEL"] ?? 'nomic-embed-text'), 'db' => trim($vars['AI_INDEX_DB'] ?? '') ?: AI_DATA_DIR . '/ai_index.db', 'k' => (int)($vars['AI_SEARCH_K'] ?? 8), 'per_file' => (int)($vars['AI_SEARCH_PER_FILE'] ?? 3), 'timeout' => (int)($vars['AI_REQUEST_TIMEOUT'] ?? 240), 'connect' => (int)($vars['AI_CONNECT_TIMEOUT'] ?? 5), ]; // AI_INDEX_DB is written as "$DATA_DIR/ai_index.db" in conf; the shell expands it, PHP does // not. Left as a literal it would name a file that cannot exist. $cfg['db'] = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $cfg['db']); return $cfg; } function vv_ai_enabled(): bool { return vv_ai_config()['enabled']; } // Index size, coverage and staleness. Staleness compares the newest git-tracked file against // the last build: the indexer only ever ingests tracked content, so anything else would report // permanent drift. function vv_ai_index_stats(): array { $cfg = vv_ai_config(); $out = ['exists' => false, 'chunks' => 0, 'files' => 0, 'size' => 0, 'built' => null, 'newest_source' => null, 'stale' => null, 'kinds' => []]; if (!file_exists($cfg['db'])) return $out; $out['exists'] = true; $out['size'] = (int)@filesize($cfg['db']); $db = escapeshellarg($cfg['db']); $q = fn(string $sql) => trim((string)@shell_exec('sqlite3 ' . $db . ' ' . escapeshellarg($sql) . ' 2>/dev/null')); $out['chunks'] = (int)$q('SELECT COUNT(*) FROM vv_chunks;'); $out['files'] = (int)$q('SELECT COUNT(*) FROM vv_files;'); $built = (int)$q('SELECT MAX(indexed) FROM vv_files;'); $out['built'] = $built ?: null; foreach (explode("\n", $q('SELECT kind, COUNT(*) FROM vv_chunks GROUP BY kind;')) as $line) { if (!$line) continue; [$k, $n] = array_pad(explode('|', $line, 2), 2, 0); if ($k !== '') $out['kinds'][$k] = (int)$n; } $newest = trim((string)@shell_exec( 'cd ' . escapeshellarg(SCRIPTS_DIR) . ' && git ls-files -z 2>/dev/null' . ' | xargs -0 stat -c %Y 2>/dev/null | sort -rn | head -1' )); if ($newest !== '') { $out['newest_source'] = (int)$newest; if ($out['built'] !== null) $out['stale'] = (int)$newest > $out['built']; } return $out; } // Ollama reachability and — the number that matters on this hardware — whether the generation // model is fully resident on the GPU. size_vram below size means layers are on the CPU, which // costs roughly 4x throughput and is invisible everywhere else. function vv_ai_runtime_stats(): array { $cfg = vv_ai_config(); $out = ['reachable' => false, 'loaded' => null, 'offload_pct' => null, 'context' => null, 'vram_used' => null, 'vram_total' => null, 'gpu' => null]; if ($cfg['url'] === '') return $out; $ctx = stream_context_create(['http' => [ 'method' => 'GET', 'timeout' => max(2, $cfg['connect']), 'ignore_errors' => true, ]]); $raw = @file_get_contents($cfg['url'] . '/api/ps', false, $ctx); if ($raw === false) return $out; $out['reachable'] = true; $ps = json_decode($raw, true); foreach ($ps['models'] ?? [] as $m) { if (($m['name'] ?? '') !== $cfg['model']) continue; $size = (int)($m['size'] ?? 0); $vram = (int)($m['size_vram'] ?? 0); $out['loaded'] = true; $out['context'] = $m['context_length'] ?? null; $out['vram_used'] = $vram; $out['vram_total'] = $size; $out['offload_pct'] = $size > 0 ? (int)round($vram / $size * 100) : null; break; } if ($out['loaded'] === null) $out['loaded'] = false; $gpu = trim((string)@shell_exec( 'nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu ' . '--format=csv,noheader,nounits 2>/dev/null | tail -1' )); if ($gpu !== '') { $p = array_map('trim', explode(',', $gpu)); if (count($p) >= 4) { $out['gpu'] = ['name' => $p[0], 'mem_used' => (int)$p[1], 'mem_total' => (int)$p[2], 'util' => (int)$p[3]]; } } return $out; } // Models Ollama actually has on disk. The configured model naming a tag that does not exist is // the single most likely misconfiguration here — it is what happens whenever a model is pulled, // wired into conf, and later removed. function vv_ai_models_available(): ?array { $cfg = vv_ai_config(); if ($cfg['url'] === '') return null; $ctx = stream_context_create(['http' => ['timeout' => max(2, $cfg['connect']), 'ignore_errors' => true]]); $raw = @file_get_contents($cfg['url'] . '/api/tags', false, $ctx); if ($raw === false) return null; $d = json_decode($raw, true); if (!isset($d['models'])) return null; return array_map(fn($m) => [ 'name' => $m['name'] ?? '', 'size' => (int)($m['size'] ?? 0), ], $d['models']); } // Currently resident models with their offload split. function vv_ai_models_loaded(): ?array { $cfg = vv_ai_config(); if ($cfg['url'] === '') return null; $ctx = stream_context_create(['http' => ['timeout' => max(2, $cfg['connect']), 'ignore_errors' => true]]); $raw = @file_get_contents($cfg['url'] . '/api/ps', false, $ctx); if ($raw === false) return null; $d = json_decode($raw, true); if (!isset($d['models'])) return null; $out = []; foreach ($d['models'] as $m) { $size = (int)($m['size'] ?? 0); $vram = (int)($m['size_vram'] ?? 0); $out[] = [ 'name' => $m['name'] ?? '', 'context' => $m['context_length'] ?? null, 'size' => $size, 'vram' => $vram, 'offload' => $size > 0 ? (int)round($vram / $size * 100) : null, ]; } return $out; } // vv_meta records the embed model and dimensionality the index was built with. function vv_ai_index_meta(): array { $cfg = vv_ai_config(); if (!file_exists($cfg['db'])) return []; // Columns are k/v, not key/value. Named wrongly this returns nothing and the embedder-match // check reports "unrecorded" — a silent pass on the one mismatch that returns confident // nonsense rather than an error. $raw = trim((string)@shell_exec( 'sqlite3 ' . escapeshellarg($cfg['db']) . ' ' . escapeshellarg('SELECT k, v FROM vv_meta;') . ' 2>/dev/null' )); $out = []; foreach (explode("\n", $raw) as $line) { if ($line === '') continue; [$k, $v] = array_pad(explode('|', $line, 2), 2, ''); if ($k !== '') $out[$k] = $v; } return $out; } // Config checked against reality. Each check is ok | warn | bad, with the remedy attached — // the point is to name the setting that is wrong, not merely report that something failed. function vv_ai_health(): array { $cfg = vv_ai_config(); $checks = []; $add = function (string $id, string $label, string $state, string $detail, string $fix = '') use (&$checks) { $checks[] = ['id' => $id, 'label' => $label, 'state' => $state, 'detail' => $detail, 'fix' => $fix]; }; $add('enabled', 'AI enabled', $cfg['enabled'] ? 'ok' : 'bad', $cfg['enabled'] ? 'AI_ENABLED=true' : 'AI_ENABLED is false', $cfg['enabled'] ? '' : 'Set AI_ENABLED=true in master.conf'); if (!command_exists_node()) { $add('node', 'node runtime', 'bad', 'node not found on PATH', 'Retrieval shells to AI/lib/cli.js and cannot run without it'); } if ($cfg['url'] === '') { $add('url', 'Ollama URL', 'bad', 'not configured', 'Set ' . strtoupper(vv_detect_host()) . '_OLLAMA_URL in the host conf'); return $checks; } $avail = vv_ai_models_available(); if ($avail === null) { $add('reach', 'Ollama reachable', 'bad', 'no response from ' . $cfg['url'], 'Check the Ollama container is running and the URL is correct'); return $checks; } $add('reach', 'Ollama reachable', 'ok', $cfg['url']); $names = array_column($avail, 'name'); // The configured tag not existing is the failure this whole section is for. if ($cfg['model'] === '') { $add('gen', 'Generation model', 'bad', 'not configured', 'Set ' . strtoupper(vv_detect_host()) . '_OLLAMA_MODEL in the host conf'); } elseif (!vv_ai_model_installed($cfg['model'], $names)) { $add('gen', 'Generation model', 'bad', $cfg['model'] . ' is not installed', 'Either `ollama pull` it, or point _OLLAMA_MODEL at one of: ' . implode(', ', array_slice($names, 0, 4))); } else { $add('gen', 'Generation model', 'ok', $cfg['model']); } if ($cfg['embed_model'] === '' || !vv_ai_model_installed($cfg['embed_model'], $names)) { $add('embed', 'Embedding model', 'bad', ($cfg['embed_model'] ?: 'not configured') . ' is not installed', 'Retrieval cannot embed a query without it — `ollama pull ' . ($cfg['embed_model'] ?: 'nomic-embed-text') . '`'); } else { $add('embed', 'Embedding model', 'ok', $cfg['embed_model']); } $ix = vv_ai_index_stats(); $meta = vv_ai_index_meta(); if (!$ix['exists']) { $add('index', 'Index', 'bad', 'not built', 'Run AI/ai_index.sh'); } else { $add('index', 'Index', 'ok', number_format($ix['chunks']) . ' chunks from ' . $ix['files'] . ' files'); // A vector built by one embedding model is meaningless to another. Changing the embed // model without reindexing does not error — it silently returns nonsense, scored // confidently, which is the hardest failure here to notice from the answers alone. $builtWith = $meta['embed_model'] ?? ''; if ($builtWith !== '' && $cfg['embed_model'] !== '' && vv_ai_model_norm($builtWith) !== vv_ai_model_norm($cfg['embed_model'])) { $add('embedmatch', 'Index / embedder match', 'bad', 'index built with ' . $builtWith . ', conf says ' . $cfg['embed_model'], 'Vectors from different models are not comparable — rerun AI/ai_index.sh --force'); } else { $add('embedmatch', 'Index / embedder match', 'ok', $builtWith ?: 'unrecorded'); } if ($ix['stale'] === true) { $add('fresh', 'Index freshness', 'warn', 'tracked files are newer than the index', 'Answers may cite code that has changed — rerun AI/ai_index.sh'); } else { $add('fresh', 'Index freshness', 'ok', 'current'); } } $loaded = vv_ai_models_loaded(); if ($loaded !== null && $cfg['model'] !== '') { $hit = null; foreach ($loaded as $m) if ($m['name'] === $cfg['model']) { $hit = $m; break; } if ($hit === null) { $add('resident', 'Model resident', 'warn', 'not loaded — first question will load it'); } elseif ($hit['offload'] !== null && $hit['offload'] < 100) { $add('resident', 'GPU offload', 'bad', $hit['offload'] . '% on GPU — layers on CPU', 'Roughly 4x slower on this card. Lower num_ctx or use a smaller quant'); } else { $add('resident', 'GPU offload', 'ok', '100% on GPU at ' . ($hit['context'] ?? '?') . ' ctx'); } } return $checks; } // Ollama reports tags fully qualified — nomic-embed-text:latest — while conf commonly carries // the bare name, and both are valid references. Comparing literally reports an installed model // as missing, which is exactly the false alarm that makes a health panel worth ignoring. function vv_ai_model_norm(string $name): string { $name = trim($name); if ($name === '') return ''; // A colon in the final path segment is a tag; anything else is a registry path. $last = substr($name, strrpos($name, '/') === false ? 0 : strrpos($name, '/') + 1); return str_contains($last, ':') ? $name : $name . ':latest'; } function vv_ai_model_installed(string $name, array $available): bool { $n = vv_ai_model_norm($name); foreach ($available as $m) { if (vv_ai_model_norm($m) === $n) return true; } return false; } function command_exists_node(): bool { static $has = null; if ($has !== null) return $has; return $has = trim((string)@shell_exec('command -v node 2>/dev/null')) !== ''; } // Recent warnings and errors across the orchestrator logs. Read live rather than indexed: // logs churn constantly, would dominate a 3341-chunk index, and embedding similarity retrieves // them poorly compared with recency plus a severity filter. function vv_ai_recent_logs(int $max = 40): array { $dir = '/var/log/varaverk'; if (!is_dir($dir)) return []; $cmd = 'grep -rhE "\[(ERROR|WARN|CRITICAL|FAILED)\]|✗" ' . escapeshellarg($dir) . ' --include="*.log" 2>/dev/null | tail -' . max(1, min($max, 200)); $raw = (string)@shell_exec($cmd); $out = []; foreach (explode("\n", $raw) as $line) { $line = trim(preg_replace('/\033\[[0-9;]*[mK]/', '', $line)); if ($line === '') continue; $out[] = mb_substr($line, 0, 300); } return $out; } function vv_ai_stats(): array { $cfg = vv_ai_config(); return [ 'enabled' => $cfg['enabled'], 'model' => $cfg['model'], 'embed_model' => $cfg['embed_model'], 'url' => $cfg['url'], 'k' => $cfg['k'], 'index' => vv_ai_index_stats(), 'runtime' => vv_ai_runtime_stats(), 'loaded' => vv_ai_models_loaded(), 'health' => vv_ai_health(), 'ts' => time(), ]; } // ── Shared collection ───────────────────────────────────────────────────────── // vv_ai_stats() costs about a second on this host — vv_ai_runtime_stats() alone is 60-480ms // depending on how quickly Ollama and nvidia-smi answer, and it was being paid by the AI tab // every 30 seconds per open tab, plus again by anything else that wanted the same numbers. // // So it is collected once, by Tools/api_cache_writer.php, into the 'ai' cache; every surface // reads that. This is the same arrangement the monitor and arrs payloads already use and for the // same reason — polling faster cannot make the figures newer, it only decides how soon a page // notices the writer's update. // // ?live=1 stays available for the one case that needs it: you changed something and want to see // the result rather than a payload written before you changed it. function vv_ai_stats_cached(bool $live = false): array { if (!$live) { $c = vv_cache_read('ai', 300); if ($c !== null) return $c; } return vv_ai_stats(); } // The Monitor tab's slice of the same collection. Derived rather than collected: taking the AI // row's figures from a second call to vv_ai_runtime_stats() would pay the whole cost twice per // cache write, and — worse — the dashboard and the AI tab could disagree about whether the model // is resident, because they would have asked at different moments. // // Tokens are deliberately absent. This block once carried today's total for a single line on the // AI card; that line is now a card of its own, which fetches the whole ledger from // api/ai.php?action=tokens because it shows the week and all-time beside today. Leaving the field // here would have run vv_ai_token_stats() on every cache write — once a minute, forever — and // discarded all but one number of it that nothing reads. function vv_ai_monitor_block(array $stats): array { return [ 'model' => $stats['model'] ?? '', 'runtime' => $stats['runtime'] ?? [], 'index' => $stats['index'] ?? [], ]; } // Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string]. function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array { $cfg = vv_ai_config(); if ($query === '') return ['ok' => false, 'error' => 'query is required', 'results' => []]; if ($cfg['url'] === '') return ['ok' => false, 'error' => 'Ollama URL is not configured', 'results' => []]; if (!file_exists($cfg['db'])) return ['ok' => false, 'error' => 'No index — run AI/ai_index.sh', 'results' => []]; if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) { return ['ok' => false, 'error' => 'Unknown kind: ' . $kind, 'results' => []]; } $cli = SCRIPTS_DIR . '/AI/lib/cli.js'; if (!file_exists($cli)) return ['ok' => false, 'error' => 'AI/lib/cli.js not found', 'results' => []]; $k = max(1, min($k ?? $cfg['k'], 25)); $cmd = 'timeout ' . max(10, $cfg['connect'] * 6) . ' node --no-warnings ' . escapeshellarg($cli) . ' search' . ' --db=' . escapeshellarg($cfg['db']) . ' --url=' . escapeshellarg($cfg['url']) . ' --model=' . escapeshellarg($cfg['embed_model']) . ' --query=' . escapeshellarg($query) . ' --k=' . $k . ' --per-file=' . max(1, $cfg['per_file']) . ($kind !== '' ? ' --kind=' . escapeshellarg($kind) : '') . ($section !== '' ? ' --section=' . escapeshellarg($section) : '') . ' --json 2>/dev/null'; $raw = trim((string)@shell_exec($cmd)); if ($raw === '') return ['ok' => false, 'error' => 'Retrieval produced no output', 'results' => []]; $d = json_decode($raw, true); if (!is_array($d) || !isset($d['results'])) { return ['ok' => false, 'error' => 'Retrieval returned unparseable output', 'results' => []]; } return ['ok' => true, 'results' => $d['results'], 'intents' => $d['intents'] ?? [], 'scanned' => $d['scanned'] ?? 0]; } // ── Question shape ─────────────────────────────────────────────────────────────────────────── // // "Varaverk" is a coined word with no spell-check and an awkward keyboard shape, so it arrives // misspelled often — varavrk, veraverk, varavek, varverk. Edit distance catches those without a // hand-written pattern that would need extending every time a new typo appears. // // This is search, not identity. The rule against fuzzy hostname matching is about deciding which // machine you are talking to, where a near-miss must never resolve; here a near-miss only widens // a document search, and the cost of being wrong is a slightly odd set of passages. function vv_ai_mentions_varaverk(string $q): bool { foreach (preg_split('/[^a-z0-9]+/i', mb_strtolower($q), -1, PREG_SPLIT_NO_EMPTY) as $w) { if (strlen($w) < 5 || strlen($w) > 12) continue; // bound the comparison window if ($w === 'varaverk' || levenshtein($w, 'varaverk') <= 2) return true; } return false; } // "What is Varaverk", "explain varavrk", "whats this thing do". Intent routing boosts PURPOSE // for these, which surfaces every script's one-line purpose and buries the top-level prose that // actually defines the system — so the model answers that the context does not define it, while // README.md sits in the index unread. function vv_ai_is_definitional(string $q): bool { if (!vv_ai_mentions_varaverk($q)) return false; return (bool)preg_match( '/\b(what\'?s?|whats|define|definition|describe|explain|overview|tell me about|' . 'purpose of|point of|elevator|in a nutshell)\b/i', $q); } // ── Memory ─────────────────────────────────────────────────────────────────────────────────── // // A small operator-maintained file handed to the model at the start of every conversation. // Injected, never indexed: it changes constantly, and vector similarity is the wrong retrieval // mechanism for "things I was told to remember". Living under DATA_DIR keeps it out of the // repository and therefore out of the index by construction. // Two memory slots, and the distinction between them is the whole point. // // assisted — written by the operator. Authoritative: the prompt tells the model to prefer it // over retrieved passages, because a human asserting a fact about their own machine // outranks a doc that may be stale. // learned — proposed by the assistant and accepted by the operator. A hint only. Retrieval // overrules it, and it is trimmed first when the budget bites. A model that can // write memory the prompt ranks above the source code will restate its own mistakes // forever, so it is deliberately given the weaker seat. // // Kept as two named slots rather than an arbitrary list: two is what the split is for, and a // general N-file scheme would be machinery serving nobody. function vv_ai_memory_path(string $kind = 'assisted'): string { $vars = vv_conf_vars(); $key = $kind === 'learned' ? 'AI_MEMORY_LEARNED_FILE' : 'AI_MEMORY_ASSISTED_FILE'; $p = trim($vars[$key] ?? ''); // Back-compat. Installs upgraded from the single-file era have AI_MEMORY_FILE set and a // populated ai_memory.md; losing that silently would drop everything the operator had // written. The legacy path wins only while the new one does not exist. if ($kind === 'assisted') { $legacy = trim($vars['AI_MEMORY_FILE'] ?? ''); $legacy = $legacy !== '' ? $legacy : AI_DATA_DIR . '/ai_memory.md'; $legacy = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $legacy); $newp = $p !== '' ? str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $p) : AI_DATA_DIR . '/mem_assisted.md'; if (!file_exists($newp) && file_exists($legacy)) return $legacy; return $newp; } $p = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $p); return $p !== '' ? $p : AI_DATA_DIR . '/mem_learned.md'; } // A hard ceiling on the learned file, well under the total. Without it a store that grows on its // own eventually occupies the whole budget and the operator's own memory is what gets truncated // away — the exact inversion of which one matters. function vv_ai_memory_learned_max(): int { $vars = vv_conf_vars(); $n = (int)($vars['AI_MEMORY_LEARNED_MAX_CHARS'] ?? 1200); return max(0, min($n, vv_ai_memory_max())); } // Which profiles a slot is given to. "*" means every profile. Narrowing it is how a General Chat // question about bash syntax stops carrying this machine's PCIe topology. function vv_ai_memory_applies(string $kind, string $profile): bool { $vars = vv_conf_vars(); // The learned slot answers to the same gate that fills it. Without this, switching // AI_MEMORY_LEARN_ENABLED off stopped new proposals but left every line already kept in every // future prompt — a gate that closes the tap and not the tank. Gated here rather than at the // one call site so any future consumer of vv_ai_memory_assemble() inherits it, and deliberately // NOT in vv_ai_memory_read(), which reports what is on disk for the AI tab to display. if ($kind === 'learned' && strtolower(trim($vars['AI_MEMORY_LEARN_ENABLED'] ?? 'false')) !== 'true') return false; $key = $kind === 'learned' ? 'AI_MEMORY_LEARNED_PROFILES' : 'AI_MEMORY_ASSISTED_PROFILES'; $spec = trim($vars[$key] ?? '*'); if ($spec === '' || $spec === '*') return true; if ($profile === '') return true; foreach (explode(',', $spec) as $one) { if (strcasecmp(trim($one), $profile) === 0) return true; } return false; } function vv_ai_memory_max(): int { $vars = vv_conf_vars(); $n = (int)($vars['AI_MEMORY_MAX_CHARS'] ?? 4000); return max(200, min($n, 20000)); } // Returns ['text'=>string,'chars'=>int,'truncated'=>bool,'exists'=>bool]. // Truncates rather than refusing: an over-long memory file should cost its own tail, not the // whole conversation, and the notice tells the model its knowledge is incomplete rather than // letting it assume it saw everything. // Assembles what this profile actually gets, as two separately-labelled blocks so the caller can // state a different precedence for each. Budget is applied to the pair: learned is capped by its // own ceiling first, then trimmed against whatever the assisted file leaves — assisted is never // shortened to make room for learned. function vv_ai_memory_assemble(string $profile = ''): array { $max = vv_ai_memory_max(); $out = ['assisted' => '', 'learned' => '', 'chars' => 0, 'truncated' => false, 'files' => []]; $read = function (string $kind) use ($profile, &$out): string { if (!vv_ai_memory_applies($kind, $profile)) return ''; $p = vv_ai_memory_path($kind); if (!file_exists($p)) return ''; $t = trim((string)@file_get_contents($p)); if ($t === '') return ''; $out['files'][] = ['kind' => $kind, 'path' => $p, 'chars' => mb_strlen($t)]; return $t; }; $assisted = $read('assisted'); $learned = $read('learned'); // The operator's file gets the budget first, in full. If it alone exceeds the cap that is a // problem to report, not one to solve by dropping the other slot silently. if (mb_strlen($assisted) > $max) { $assisted = mb_substr($assisted, 0, $max) . "\n\n[assisted memory truncated at {$max} characters]"; $out['truncated'] = true; $learned = ''; } else { $room = min(vv_ai_memory_learned_max(), $max - mb_strlen($assisted)); if ($room <= 0) { $learned = ''; } elseif (mb_strlen($learned) > $room) { // Cut on a line boundary — half a sentence asserted as fact is worse than one fewer. $learned = mb_substr($learned, 0, $room); $cut = mb_strrpos($learned, "\n"); if ($cut !== false && $cut > 0) $learned = mb_substr($learned, 0, $cut); $out['truncated'] = true; } } $out['assisted'] = $assisted; $out['learned'] = $learned; $out['chars'] = mb_strlen($assisted) + mb_strlen($learned); return $out; } function vv_ai_memory_read(string $kind = 'assisted'): array { $p = vv_ai_memory_path($kind); $max = $kind === 'learned' ? vv_ai_memory_learned_max() : vv_ai_memory_max(); if (!file_exists($p)) return ['text' => '', 'chars' => 0, 'truncated' => false, 'exists' => false]; $raw = (string)@file_get_contents($p); $len = mb_strlen($raw); if ($len <= $max) { return ['text' => $raw, 'chars' => $len, 'truncated' => false, 'exists' => true]; } return [ 'text' => mb_substr($raw, 0, $max) . "\n\n[memory truncated at {$max} characters]", 'chars' => $len, 'truncated' => true, 'exists' => true, ]; } // Atomic, and refuses to exceed the cap. The cap is a context budget shared with retrieval and // reasoning on every turn, so it is enforced on write rather than silently trimmed on read. function vv_ai_memory_write(string $text, string $kind = 'assisted'): array { $p = vv_ai_memory_path($kind); $max = $kind === 'learned' ? vv_ai_memory_learned_max() : vv_ai_memory_max(); $len = mb_strlen($text); if ($len > $max) { $what = $kind === 'learned' ? 'Learned memory' : 'Memory'; return ['ok' => false, 'error' => "{$what} is {$len} characters; the limit is {$max}. " . "It is included in every prompt, so it competes with retrieval for context."]; } $dir = dirname($p); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { return ['ok' => false, 'error' => 'Cannot create ' . $dir]; } $tmp = $p . '.vv.tmp'; if (@file_put_contents($tmp, $text) === false) { return ['ok' => false, 'error' => 'Cannot write ' . $tmp]; } if (!@rename($tmp, $p)) { @unlink($tmp); return ['ok' => false, 'error' => 'Cannot install ' . $p]; } return ['ok' => true, 'chars' => $len]; } // ── Token accounting ───────────────────────────────────────────────────────────────────────── // One appended row per completed turn: date|time|host|profile|source|prompt|completion|tok_s. // Pipe-delimited under data/ to match arr_cleanup_stats.db and bandwidth_history.db rather than // inventing a format for one feature. // // Recording is best-effort and never raises. A turn that answered correctly must not be reported // as failed because its accounting row could not be written — the number is the cheap part here // and the answer is the expensive one. function vv_ai_token_db(): string { $p = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, trim(vv_conf_vars()['AI_TOKEN_DB'] ?? '')); return $p !== '' ? $p : AI_DATA_DIR . '/ai_token_history.db'; } function vv_ai_token_retain(): int { $n = (int)(vv_conf_vars()['AI_TOKEN_RETAIN_ROWS'] ?? 20000); return max(500, min($n, 500000)); } // LOCK_EX because two turns can finish together — the composer allows a second question while // the first is still generating, and each runs in its own detached worker. function vv_ai_token_record(string $profile, string $source, int $prompt, int $completion, ?float $tokS): void { if ($prompt <= 0 && $completion <= 0) return; // nothing generated; not a turn worth a row $db = vv_ai_token_db(); $dir = dirname($db); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return; $row = implode('|', [ date('Y-m-d'), date('H:i:s'), vv_detect_host(), preg_replace('/[^a-z0-9_-]/i', '', $profile) ?: 'unknown', preg_replace('/[^a-z0-9_-]/i', '', $source) ?: 'unknown', max(0, $prompt), max(0, $completion), $tokS === null ? '' : number_format($tokS, 1, '.', ''), ]) . "\n"; @file_put_contents($db, $row, FILE_APPEND | LOCK_EX); vv_ai_token_prune($db); } // Pruning is gated on filesize rather than done every turn: filesize() is a stat, whereas // reading and rewriting the whole file is not something a chat turn should pay for per message. // The threshold is derived from the row cap so raising the cap does not silently disable it. function vv_ai_token_prune(string $db): void { $keep = vv_ai_token_retain(); if ((int)@filesize($db) < $keep * 60) return; // ~60 bytes a row, generously $rows = @file($db, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if (!is_array($rows) || count($rows) <= $keep) return; $tmp = $db . '.vv.tmp'; if (@file_put_contents($tmp, implode("\n", array_slice($rows, -$keep)) . "\n") === false) return; @rename($tmp, $db); } // Partner ledgers pulled by AI/ai_token_sync.sh into tmpfs. Same trick conf_sync.sh uses for // partner confs: the reader treats a missing file as "unknown", never as zero. // // The directory is AI_TOKEN_CACHE_DIR from master.conf, resolved in config.php. It used to be // hardcoded here, on the reasoning that the shell side hardcoded it too and so there was no // single value to drift away from — true when it was written, and no longer true. // Aggregates the file into today / last 7 days / all time, both overall and per host. // // Every detected host is present in 'hosts' whether or not it has rows, with 'seen' telling the // two apart. A host with no rows is not the same fact as a host with zero tokens, and the page // must be able to say "nothing collected here" rather than draw a confident 0. function vv_ai_token_stats(): array { $db = vv_ai_token_db(); $today = date('Y-m-d'); $week = date('Y-m-d', strtotime('-6 days')); // 7 days inclusive of today $blank = ['turns' => 0, 'prompt' => 0, 'completion' => 0, 'total' => 0]; $out = [ 'exists' => file_exists($db), 'today' => $blank, 'week' => $blank, 'all' => $blank, 'hosts' => [], 'profiles' => [], 'sources' => [], 'first' => null, 'last' => null, 'best_tok_s' => null, ]; $me = vv_detect_host(); foreach (vv_known_hosts() as $id => $name) { $out['hosts'][$id] = ['name' => $name, 'seen' => false, 'self' => $id === $me, 'synced' => null, 'today' => $blank, 'week' => $blank, 'all' => $blank]; } // Ledgers to read: our own, plus whatever AI/ai_token_sync.sh has pulled from partners. // // Each entry carries the host slot it is allowed to contribute rows for. A partner file may // only add rows whose host column matches its filename — a ledger copied into the wrong slot, // or a partner that somehow cached ours, would otherwise be counted twice against a total // that still looked plausible. Our own file is trusted for any host, because it is the only // one written here and its host column is written by vv_detect_host(). $ledgers = [[$db, null]]; foreach ((array)@glob(VV_AI_TOKEN_CACHE_DIR . '/host*.tokens.db') as $partnerDb) { if (!preg_match('/(host\d+)\.tokens\.db$/', $partnerDb, $m)) continue; if ($m[1] === $me) continue; // our own ledger is already read above $ledgers[] = [$partnerDb, $m[1]]; if (isset($out['hosts'][$m[1]])) $out['hosts'][$m[1]]['synced'] = @filemtime($partnerDb) ?: null; } $add = function (array $b, int $p, int $c): array { $b['turns']++; $b['prompt'] += $p; $b['completion'] += $c; $b['total'] += $p + $c; return $b; }; foreach ($ledgers as [$path, $onlyHost]) { $fh = @fopen($path, 'r'); if (!$fh) continue; while (($line = fgets($fh)) !== false) { $f = explode('|', rtrim($line, "\r\n")); if (count($f) < 8) continue; // partial write, or a format change [$date, , $host, $profile, $source, $p, $c, $tokS] = $f; if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) continue; if ($onlyHost !== null && $host !== $onlyHost) continue; $p = (int)$p; $c = (int)$c; if (!isset($out['hosts'][$host])) { // A host in the file that conf no longer lists — renamed, or a row copied in. // Shown rather than dropped, so the totals reconcile with the per-host rows. $out['hosts'][$host] = ['name' => $host, 'seen' => false, 'self' => false, 'synced' => null, 'today' => $blank, 'week' => $blank, 'all' => $blank]; } $out['hosts'][$host]['seen'] = true; foreach ([['all', true], ['week', $date >= $week], ['today', $date === $today]] as [$k, $hit]) { if (!$hit) continue; $out[$k] = $add($out[$k], $p, $c); $out['hosts'][$host][$k] = $add($out['hosts'][$host][$k], $p, $c); } $out['profiles'][$profile] = ($out['profiles'][$profile] ?? 0) + $p + $c; $out['sources'][$source] = ($out['sources'][$source] ?? 0) + $p + $c; if ($tokS !== '' && (float)$tokS > (float)($out['best_tok_s'] ?? 0)) $out['best_tok_s'] = (float)$tokS; // Min/max rather than first-row/last-row. Appends are chronological in practice, but // a file hand-edited, merged or restored out of order would otherwise report a span // that reads backwards — and the row cap means the oldest row is not permanent. if ($out['first'] === null || $date < $out['first']) $out['first'] = $date; if ($out['last'] === null || $date > $out['last']) $out['last'] = $date; } fclose($fh); } arsort($out['profiles']); arsort($out['sources']); $out['days'] = $out['first'] ? max(1, (int)((strtotime($today) - strtotime($out['first'])) / 86400) + 1) : 0; return $out; } // ── Scoped lookups for the Scheduler's assistant ────────────────────────────────────────────── // The tail of one named script's log, for the troubleshooting profile. vv_ai_recent_logs() // answers "is anything wrong anywhere"; this answers "why did THIS fail", which is a different // question and needs the ordinary lines too, not just WARN and ERROR — the last thing a script // printed before stopping is usually not labelled as a warning. // // Contained to LOG_DIR by realpath. The id arrives from a request, and a log path that escaped // would read arbitrary files into a model's context. function vv_ai_scoped_log(string $id, int $lines = 120): array { $rel = preg_replace('/\.sh$/', '', trim($id)) . '.log'; if ($rel === '' || str_contains($rel, "\0")) return ['ok' => false, 'error' => 'bad id']; $base = realpath(LOG_DIR); $path = realpath(LOG_DIR . '/' . $rel); if ($base === false || $path === false) return ['ok' => false, 'error' => 'no log yet']; if (!str_starts_with($path, $base . '/')) return ['ok' => false, 'error' => 'outside log dir']; if (!is_file($path)) return ['ok' => false, 'error' => 'no log yet']; $all = @file($path, FILE_IGNORE_NEW_LINES) ?: []; return [ 'ok' => true, 'path' => $rel, 'total' => count($all), 'mtime' => @filemtime($path) ?: null, 'tail' => array_slice($all, -max(10, min($lines, 400))), ]; } // The run record a script's wrapper writes beside its log: how the last run ended, in four // fields, without reading a line of the log. "How did it go" is answerable from this alone — // status, exit code and the window it ran in — and the log tail then supplies the detail. // Separate from vv_ai_scoped_log() because they fail independently: a script killed mid-run // leaves a log and no record, and that difference is itself the answer. function vv_ai_run_record(string $id): array { $rel = preg_replace('/\.sh$/', '', trim($id)) . '.json'; if ($rel === '' || str_contains($rel, "\0")) return ['ok' => false]; $base = realpath(LOG_DIR); $path = realpath(LOG_DIR . '/' . $rel); if ($base === false || $path === false) return ['ok' => false]; if (!str_starts_with($path, $base . '/')) return ['ok' => false]; $r = json_decode((string)@file_get_contents($path), true); if (!is_array($r) || !isset($r['start'])) return ['ok' => false]; $start = (int)$r['start']; $end = isset($r['end']) ? (int)$r['end'] : 0; return [ 'ok' => true, 'status' => (string)($r['status'] ?? '?'), 'exit' => isset($r['exit']) ? (int)$r['exit'] : null, 'start' => $start, 'end' => $end ?: null, 'duration' => $end > $start ? $end - $start : null, ]; } // Which script a run-outcome question is about, when the operator names it in prose rather than // by opening its log. "How did the daily orch go" has to resolve to Orchestrators/daily_sync_ // maintenance before anything can be attached to the context. // // Aliases are an explicit table, and matching against real log ids is exact on the underscore // tokens — no similarity scoring. The failure mode of a scored match here is attaching the wrong // script's log and answering confidently about a run the operator never asked about, which is // indistinguishable from a correct answer unless they already knew. Ambiguity returns nothing so // the question falls through to ordinary retrieval, which is merely unhelpful rather than wrong. function vv_ai_resolve_run_target(string $question): string { $q = strtolower($question); static $aliases = [ 'Orchestrators/daily_sync_maintenance' => ['daily orch', 'daily orchestrator', 'daily sync', 'daily maintenance', 'daily run'], 'Orchestrators/weekly_sync_maintenance' => ['weekly orch', 'weekly orchestrator', 'weekly sync', 'weekly maintenance', 'weekly run'], 'Orchestrators/critical_sync_maintenance' => ['critical orch', 'critical sync', 'critical run'], 'Orchestrators/intermediate_sync_maintenance' => ['intermediate orch', 'intermediate sync'], 'Orchestrators/watchdog_orchestrator' => ['watchdog orch', 'watchdog orchestrator', 'watchdogs'], 'Orchestrators/transcode_management' => ['transcode orch', 'transcode management'], 'Orchestrators/array_started' => ['array start', 'array started'], 'Orchestrators/monthly_maintenance' => ['monthly orch', 'monthly maintenance'], 'Orchestrators/sunday_morning_coffee_report' => ['coffee report', 'sunday report', 'sunday morning coffee'], ]; foreach ($aliases as $id => $phrases) { foreach ($phrases as $p) if (str_contains($q, $p)) return $id; } // Anything with a log but no alias — named outright, either as an id or a bare script name. // // Whole words only. A substring test matched the `ai` log inside the word "f-ai-l", so // "why did this run fail" resolved to ai.log and would have attached it to a question about // something else entirely. Short ids make that failure common rather than exotic: mail, again, // available, maintenance all contain it. $hits = []; foreach (vv_ai_log_ids() as $id) { foreach ([strtolower($id), strtolower(basename($id))] as $needle) { if ($needle !== '' && preg_match('/\b' . preg_quote($needle, '/') . '\b/', $q)) { $hits[] = $id; break; } } } $hits = array_unique($hits); if (count($hits) === 1) return reset($hits); // Longest match wins only when one candidate contains every other — "daily_sync_maintenance" // over "sync", not a coin toss between two unrelated scripts. if (count($hits) > 1) { usort($hits, fn($a, $b) => strlen($b) - strlen($a)); $longest = $hits[0]; foreach (array_slice($hits, 1) as $h) { if (!str_contains(strtolower($longest), strtolower(basename($h)))) return ''; } return $longest; } return ''; } // Every script id that has a log, relative to LOG_DIR and without the extension. One level of // nesting, which is how the log tree is actually laid out (Orchestrators/, Plugin/). function vv_ai_log_ids(): array { $base = realpath(LOG_DIR); if ($base === false) return []; $ids = []; foreach (glob($base . '/*.log') ?: [] as $f) { $ids[] = basename($f, '.log'); } foreach (glob($base . '/*/*.log') ?: [] as $f) { $ids[] = basename(dirname($f)) . '/' . basename($f, '.log'); } return $ids; } // Where a conf key actually lives. Deterministic on purpose: the model should narrate this, not // work it out. Searches the host's own conf and master, reports file, line and value. // // This is the "Jonny" case — someone is sure a setting is in master.conf and it is really in the // host conf. Failing to find it is the unhelpful answer; finding it and saying where is the // useful one, and neither should depend on the model guessing which file to trust. function vv_ai_find_conf_key(string $key): array { if (!preg_match('/^[A-Za-z][A-Za-z0-9_]{1,63}$/', $key)) return ['ok' => false]; $me = vv_detect_host(); $order = array_values(array_unique(array_filter([ $me !== 'unknown' ? $me . '.conf' : null, 'master.conf', ]))); foreach (vv_get_conf_files() as $f) if (!in_array($f, $order, true)) $order[] = $f; // Two passes, active before commented, across every file — not first-match-wins per line. // These confs document each setting in a comment block above it, and those blocks contain // lines like "# RSYNC_ENABLED=false → ALL rsync stops everywhere". A single pass matched // that prose at line 529 and reported the setting as commented out while the live value sat // active at line 546. Reporting an enabled setting as disabled is the exact failure this // lookup exists to prevent, so the active definition always wins. $q = preg_quote($key, '/'); $files = []; foreach ($order as $file) $files[$file] = @file(CONF_DIR . '/' . $file, FILE_IGNORE_NEW_LINES) ?: []; foreach ($files as $file => $lines) { foreach ($lines as $i => $line) { if (preg_match('/^\s*' . $q . '\s*=/', $line)) { return ['ok' => true, 'file' => $file, 'line' => $i + 1, 'text' => trim($line), 'commented' => false]; } } } foreach ($files as $file => $lines) { foreach ($lines as $i => $line) { if (preg_match('/^\s*#\s*' . $q . '\s*=/', $line)) { return ['ok' => true, 'file' => $file, 'line' => $i + 1, 'text' => trim($line), 'commented' => true]; } } } return ['ok' => false]; } // One definition of a valid scope, used by every caller. A scope names something in this page's // own view state — a conf file, a script, a log. It reaches the model as text and, for the // troubleshooting profile, composes a path under LOG_DIR, so `..` is refused outright rather // than left for the containment check downstream to catch. That check stays: this is the first // gate, not the only one. function vv_ai_scope_ok(string $scope): bool { if (!preg_match('#^[A-Za-z0-9 ._/-]{1,80}$#', $scope)) return false; if (str_contains($scope, '..')) return false; return true; } // ── AI-filed bug reports ───────────────────────────────────────────────────────────────────── // When the troubleshooter concludes the evidence shows a defect in Varaverk itself — not a // setting — it files a report here rather than only saying so in a chat window that closes. // // Filed automatically, with the care put into the guard rather than into a review queue: a // report requires a component and a quoted line of evidence, and identical findings collapse // onto one record with a seen count. Without that, asking the same question three times files // three bugs and the pile becomes noise within a week. // // Under data/ and therefore gitignored: these quote this installation's logs. function vv_ai_bugs_dir(): string { $d = AI_DATA_DIR . '/ai_bugs'; if (!is_dir($d)) @mkdir($d, 0755, true); return $d; } // Same component + same summary is the same finding. Hashing them means a recurring fault // increments a counter instead of breeding files, and the count is itself the signal: seen 40 // times is a different problem from seen once. function vv_ai_bug_file(array $r): string { return vv_ai_bugs_dir() . '/' . substr(sha1(strtolower($r['component'] . '|' . $r['summary'])), 0, 12) . '.json'; } // Does the quoted evidence actually appear in the log the model was shown? // // The prompt asks for a verbatim line. This checks it, because "manufactured a cause to have // one" is the specific failure this profile was warned against and warnings are not guards — // the same reason General Chat needed a regex behind its instruction, not just a firmer wording. // // Whitespace-normalised and matched on the longest quoted fragment: the model reliably keeps the // text and unreliably keeps the indentation. A short fragment would match by coincidence, so // anything under 25 characters is not treated as a match at all. function vv_ai_evidence_in_log(string $evidence, array $tail): bool { if (!$tail) return false; $norm = fn(string $s) => preg_replace('/\s+/', ' ', trim($s)); $hay = ' ' . implode(' ⏎ ', array_map($norm, $tail)) . ' '; $frags = array_filter(array_map($norm, preg_split('/\R/', $evidence)), fn($l) => mb_strlen($l) >= 25); usort($frags, fn($a, $b) => mb_strlen($b) <=> mb_strlen($a)); foreach (array_slice($frags, 0, 5) as $f) { if (str_contains($hay, $f)) return true; // Allow a trimmed tail of the fragment — models often drop a trailing clause. $head = mb_substr($f, 0, max(25, (int)(mb_strlen($f) * 0.6))); if (mb_strlen($head) >= 25 && str_contains($hay, $head)) return true; } return false; } // Is the named component a real file in this installation? A defect report against a path that // does not exist is a confabulation, and it is free to check. function vv_ai_component_exists(string $component): bool { $c = trim($component); if ($c === '' || !vv_ai_scope_ok($c)) return false; $base = realpath(SCRIPTS_DIR); $p = realpath(SCRIPTS_DIR . '/' . $c); return $base !== false && $p !== false && str_starts_with($p, $base . '/') && is_file($p); } function vv_ai_bug_write(string $component, string $summary, string $evidence, array $ctx = []): array { $component = trim($component); $summary = trim($summary); $evidence = trim($evidence); // Evidence is mandatory. A report that cannot quote the line it is based on is an opinion, // and this file exists to hold findings that can be checked. if ($component === '' || $summary === '' || $evidence === '') return ['ok' => false]; if (!vv_ai_scope_ok($component)) return ['ok' => false]; // Redacted on the way in, not on the way out. A report is built from this record, shown on // the AI page from this record, and pasted into an issue from this record — redacting at any // one of those leaves the other two holding the secret, and the store keeps it forever. // // Counted so the report can say something was removed. Silent redaction reads as a short log, // and a reader who cannot tell the difference cannot judge what they are looking at. $summary = vv_ai_redact($summary); $evidence = vv_ai_redact($evidence); $redacted = substr_count($summary . "\0" . $evidence, VV_AI_REDACTED); foreach ($ctx as $k => $v) { if (is_string($v)) { $ctx[$k] = vv_ai_redact($v); $redacted += substr_count($ctx[$k], VV_AI_REDACTED); } } // The commit as it was when the fault was seen, not when the report is read. "Is this already // fixed" is the first question any maintainer asks, and a version captured later answers a // different question. Slot id (host1/host2) rather than the hostname — this text is written // to be pasted into a public issue. $commit = ''; $head = @file_get_contents(SCRIPTS_DIR . '/.git/HEAD'); if (is_string($head) && preg_match('#^ref:\s*(\S+)#', trim($head), $hm)) { $commit = substr(trim((string)@file_get_contents(SCRIPTS_DIR . '/.git/' . $hm[1])), 0, 12); } elseif (is_string($head)) { $commit = substr(trim($head), 0, 12); } $rec = [ 'component' => mb_substr($component, 0, 120), 'summary' => mb_substr($summary, 0, 300), 'evidence' => mb_substr($evidence, 0, 2000), 'commit' => $commit, 'host' => vv_detect_host(), 'first' => time(), 'last' => time(), 'seen' => 1, 'open' => true, 'context' => array_slice($ctx, 0, 12), 'redacted' => $redacted, // The shape this record was written in. A report format will change; being able to read // an old record without guessing which fields it has is what makes that survivable. 'template' => 1, ]; $p = vv_ai_bug_file($rec); if (is_file($p)) { $old = json_decode((string)@file_get_contents($p), true); if (is_array($old)) { $rec['first'] = $old['first'] ?? $rec['first']; $rec['seen'] = (int)($old['seen'] ?? 0) + 1; $rec['open'] = $old['open'] ?? true; // dismissing it stays dismissed } } $rec['id'] = basename($p, '.json'); if (@file_put_contents($p, json_encode($rec, JSON_PRETTY_PRINT)) === false) return ['ok' => false]; return ['ok' => true, 'id' => $rec['id'], 'seen' => $rec['seen']]; } // Renders one stored bug as the report that gets pasted into an issue. // // The shape is built around one rule: observed fact and inference are separated and labelled. // A report that blends "the log said X" with "this is probably a permissions problem" is one that // the next reader — a person, or a model asked to look at it — will quote back as evidence. Every // heading below is either quoted output or explicitly marked as a reading of it. // // Written to be pasted somewhere public, so it carries the host slot rather than the hostname and // nothing that identifies the machine. What it does carry is the two facts a maintainer asks for // first: which commit, and how many times. function vv_ai_bug_report(array $b): string { $g = fn(string $k, $d = '') => $b[$k] ?? $d; $ctx = (array) $g('context', []); $when = fn($t) => $t ? date('Y-m-d H:i', (int) $t) : '?'; $seen = (int) $g('seen', 1); $out = "## Varaverk bug report\n\n"; $out .= "| | |\n|---|---|\n"; $out .= '| **Summary** | ' . str_replace('|', '\\|', (string) $g('summary')) . " |\n"; $out .= '| **Component** | `' . $g('component') . "` |\n"; $out .= '| **Commit** | `' . ($g('commit') ?: 'unknown') . "` |\n"; $out .= '| **Seen** | ' . $seen . '× · first ' . $when($g('first')) . ' · last ' . $when($g('last')) . " |\n"; $out .= '| **Host slot** | ' . $g('host', '?') . " |\n"; $out .= '| **Report id** | `' . $g('id', '?') . "` |\n"; $out .= '| **Template** | ' . (int) $g('template', 1) . " |\n\n"; // Whether the quote below was checked against the log it claims to come from. The worker // refuses to file a bug whose evidence it could not find, so this is normally true — but a // report that asserts it without having checked is worth less than one that says it did not. $verified = $ctx['verified'] ?? null; $vLabel = $verified === true ? 'verified present in ' . ($ctx['log'] ?? 'the log') : ($verified === false ? 'NOT found in the log — treat with suspicion' : 'not checked against a log'); $out .= "### Evidence — verbatim, " . $vLabel . "\n\n```\n" . rtrim((string) $g('evidence')) . "\n```\n\n"; // Deliberately after the evidence and deliberately labelled. This is the assistant's reading, // and the heading has to survive being skim-read: a diagnosis printed next to a log quote // gets remembered as a second log quote, and the next reader inherits a guess as a finding. if (!empty($ctx['diagnosis'])) { $out .= "### The assistant's reading — INFERENCE, not established\n\n" . "> " . str_replace("\n", "\n> ", trim((string) $ctx['diagnosis'])) . "\n\n" . "_Written by a 14B model from the evidence above. Worth checking, not worth " . "trusting._\n\n"; } if (!empty($ctx['asked'])) { $out .= "### What was being asked when it was noticed\n\n> " . str_replace("\n", "\n> ", trim((string) $ctx['asked'])) . "\n\n"; } $out .= "### Environment\n\n"; $env = array_filter([ 'Unraid ' . (vv_ai_bug_unraid_version() ?: '?'), 'PHP ' . PHP_VERSION, !empty($ctx['profile']) ? 'assistant profile: ' . $ctx['profile'] : null, !empty($ctx['scope']) ? 'scope: ' . $ctx['scope'] : null, ]); $out .= '- ' . implode("\n- ", $env) . "\n\n"; // Stated even when nothing was removed, because "0 values" and no line at all read very // differently to someone deciding whether the log looks suspiciously short. $n = (int) $g('redacted', 0); $out .= "### Redaction\n\n"; $out .= $n > 0 ? $n . ' value' . ($n === 1 ? '' : 's') . " matched a known credential or a secret-shaped " . "assignment and " . ($n === 1 ? 'was' : 'were') . " replaced with `" . VV_AI_REDACTED . "`.\n" : "Nothing matched a known credential. Note that redaction catches secrets held in this " . "install's conf, or written as an assignment — an unlabelled token printed by another " . "program would not be caught, so read the evidence before sending.\n"; return $out; } // Where a report can go from this install, resolved once so the page and the sender agree. // // Local is opt-in and off by default, because a default that files into the operator's own // tracker means every report from every other install lands somewhere the maintainer never // looks — a fallback chain is right for fetching code and wrong for sending a report. function vv_ai_bug_targets(): array { $v = vv_conf_vars(); $slot = strtoupper(vv_detect_host()); $on = strtolower(trim($v['BUG_REPORT_LOCAL_ENABLED'] ?? 'false')) === 'true'; $url = trim((string) ($v[$slot . '_BUG_REPORT_URL'] ?? '')); $repo = trim((string) ($v[$slot . '_BUG_REPORT_REPO'] ?? '')); $token = trim((string) ($v[$slot . '_BUG_REPORT_TOKEN'] ?? '')); return [ // Configured is not the same as enabled: the switch is off but the details are filled in // is a state worth showing, because it is what "why did this go to GitHub" looks like. 'local_enabled' => $on, 'local_configured' => $url !== '' && $repo !== '' && $token !== '', 'local_url' => $url, 'local_repo' => $repo, 'github_repo' => trim((string) ($v['BUG_REPORT_GITHUB_REPO'] ?? 'FailedProxy/Varaverk')), ]; } // Files the report as an issue on the operator's own Gitea. Only ever reached when the switch is // on and the details are present — never as a fallback from a failed GitHub send, because the two // go to different people and quietly substituting one for the other is the whole failure mode // this design exists to avoid. function vv_ai_bug_send_local(string $title, string $body): array { $t = vv_ai_bug_targets(); if (!$t['local_enabled']) return ['ok' => false, 'error' => 'local reporting is switched off']; if (!$t['local_configured']) return ['ok' => false, 'error' => 'local reporting is on but URL, repo or token is blank']; if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'curl is unavailable']; $v = vv_conf_vars(); $slot = strtoupper(vv_detect_host()); $token = trim((string) ($v[$slot . '_BUG_REPORT_TOKEN'] ?? '')); $api = rtrim($t['local_url'], '/') . '/api/v1/repos/' . trim($t['local_repo'], '/') . '/issues'; $ch = curl_init($api); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: token ' . $token], CURLOPT_POSTFIELDS => json_encode(['title' => $title, 'body' => $body]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_FOLLOWLOCATION => false, ]); $resp = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($resp === false) return ['ok' => false, 'error' => 'could not reach Gitea: ' . $err]; if ($code === 401 || $code === 403) return ['ok' => false, 'error' => 'Gitea refused the token (HTTP ' . $code . ')']; if ($code === 404) return ['ok' => false, 'error' => 'Gitea has no such repo (HTTP 404) — check the owner/repo']; if ($code < 200 || $code >= 300) return ['ok' => false, 'error' => 'Gitea returned HTTP ' . $code]; $d = json_decode((string) $resp, true); // The URL back is the whole point of sending server-side rather than opening a form: it is // proof the issue exists, and somewhere to go and look at it. return ['ok' => true, 'url' => $d['html_url'] ?? '', 'number' => $d['number'] ?? null]; } // Best effort, and blank rather than wrong. /etc/unraid-version is a shell assignment; on // anything that is not Unraid there is simply no file and the report says "?". function vv_ai_bug_unraid_version(): string { $raw = @file_get_contents('/etc/unraid-version'); if (!is_string($raw)) return ''; return preg_match('/"([^"]+)"/', $raw, $m) ? $m[1] : trim(explode('=', $raw, 2)[1] ?? ''); } function vv_ai_bugs_list(bool $openOnly = true): array { $out = []; foreach ((array)@glob(vv_ai_bugs_dir() . '/*.json') as $f) { $r = json_decode((string)@file_get_contents($f), true); if (!is_array($r) || ($openOnly && empty($r['open']))) continue; $out[] = $r; } usort($out, fn($a, $b) => ($b['last'] ?? 0) <=> ($a['last'] ?? 0)); return $out; } function vv_ai_bug_set_open(string $id, bool $open): bool { if (!preg_match('/^[0-9a-f]{12}$/', $id)) return false; $p = vv_ai_bugs_dir() . '/' . $id . '.json'; $r = json_decode((string)@file_get_contents($p), true); if (!is_array($r)) return false; $r['open'] = $open; return @file_put_contents($p, json_encode($r, JSON_PRETTY_PRINT)) !== false; } // ── Incident journal ───────────────────────────────────────────────────────────────────────── // "We have seen this before, and here is what it was." Appended as you work through logs, and // fed back the next time the same script is being diagnosed. // // Deliberately NOT part of the AI index. The index reads git-tracked files only, and this lives // under data/ which is gitignored — which is correct twice over: incident notes are about this // installation and should not be pushed, and retrieval by similarity is the wrong lookup here. // The right question is "what has gone wrong with THIS script before", which is an exact match // on the scope, not a vector search. Same reasoning as the standing memory file. // // Markdown rather than a delimited .db because the useful part is prose — a fix is a sentence, // not a field — and it stays hand-editable when a note turns out to be wrong. function vv_ai_incidents_path(): string { return AI_DATA_DIR . '/ai_incidents.md'; } // One entry, appended. The symptom is captured from what was being asked; the fix is written by // the operator. That split matters: the model's diagnosis is a hypothesis, and writing a // hypothesis into institutional memory as fact is how a wrong answer outlives the incident. function vv_ai_incident_add(string $scope, string $symptom, string $fix): array { $scope = trim($scope); $symptom = trim($symptom); $fix = trim($fix); if ($scope === '' || $fix === '') return ['ok' => false, 'error' => 'scope and fix are required']; if (!vv_ai_scope_ok($scope)) return ['ok' => false, 'error' => 'bad scope']; $entry = "\n## " . date('Y-m-d') . ' · ' . $scope . "\n" . ($symptom !== '' ? '**Symptom:** ' . mb_substr($symptom, 0, 400) . "\n" : '') . '**Fix:** ' . mb_substr($fix, 0, 1200) . "\n"; $p = vv_ai_incidents_path(); if (!is_dir(dirname($p)) && !@mkdir(dirname($p), 0755, true)) { return ['ok' => false, 'error' => 'cannot create data dir']; } if (!file_exists($p)) { @file_put_contents($p, "# Incident journal\n\nWhat went wrong, and what actually fixed it." . " Written by the operator from the Scheduler assistant; fed back when the same" . " script is diagnosed again.\n"); } if (@file_put_contents($p, $entry, FILE_APPEND | LOCK_EX) === false) { return ['ok' => false, 'error' => 'cannot write journal']; } return ['ok' => true]; } // Past entries for one scope, newest first. Capped hard: this rides in the prompt alongside a // 120-line log and retrieved passages, and the context budget is already the tight thing. function vv_ai_incidents_for(string $scope, int $max = 4): array { $p = vv_ai_incidents_path(); if ($scope === '' || !is_file($p)) return []; $blocks = preg_split('/^## /m', (string)@file_get_contents($p)); $want = strtolower(trim($scope)); $base = strtolower(basename(trim($scope))); $hits = []; foreach ($blocks as $b) { $b = trim($b); if ($b === '' || !str_contains($b, "\n")) continue; [$head] = explode("\n", $b, 2); $head = strtolower($head); // Match the scope as written, or its basename — the chip may carry a path where an // older entry carried only the script name. if (!str_contains($head, $want) && !str_contains($head, $base)) continue; $hits[] = '## ' . $b; } return array_slice(array_reverse($hits), 0, max(1, $max)); } // ── Stored conversations ────────────────────────────────────────────────────── // Chats live in DATA_DIR, not /tmp, because the point of storing them is that they outlive a // reboot — the job files above are deliberately the opposite. One file per conversation, same // shape as ai_bugs: a directory of small JSON records is trivially prunable and a corrupt one // costs a single chat rather than the whole history. // // The store is capped, and pruning happens on write rather than on a schedule. Nothing else // runs often enough to be trusted with it, and an unbounded directory here would quietly grow // for as long as the operator keeps talking to the assistant. function vv_ai_chats_dir(): string { $d = AI_DATA_DIR . '/ai_chats'; if (!is_dir($d)) @mkdir($d, 0755, true); return $d; } // ── Secret redaction ───────────────────────────────────────────────────────────────────────── // A conversation about settings is a conversation that contains credentials. Asking the // assistant to change an API key means typing the key, and a stored transcript is replayed into // a later prompt when the thread is reopened — so without this a secret would be written to // disk in cleartext and then handed back to the model on every subsequent turn, indefinitely. // // Redaction happens on the way to disk and to the log, never to the live turn. The model needs // the real value to carry out the change being asked for; what it does not need is that value // still sitting in the transcript a week later. const VV_AI_REDACTED = '[redacted]'; // The values this host actually holds, longest first so a key that contains another as a // substring cannot be half-replaced. Only secret-shaped conf keys contribute, and only values // long enough to be a credential — redacting every occurrence of a two-character setting would // shred ordinary prose. function vv_ai_known_secrets(): array { static $cache = null; if ($cache !== null) return $cache; $vals = []; foreach (vv_conf_vars() as $k => $v) { $v = trim((string)$v); if (strlen($v) < 8) continue; if (!vv_conf_key_is_secret((string)$k)) continue; // A value that is still a ${...} reference is a template, not a credential. if (str_contains($v, '${')) continue; $vals[] = $v; } $vals = array_values(array_unique($vals)); usort($vals, fn($a, $b) => strlen($b) <=> strlen($a)); return $cache = $vals; } // Two passes, because they catch different things. // // Known values catch a credential this host already holds, however it is worded — pasted bare, // quoted, or buried mid-sentence. Exact string matching, so there are no false positives. // // Assignment shapes catch the credential that is not in the conf yet, which is precisely the // case that matters here: "change the Emby API key to " is a secret arriving, and it // will not match anything on disk until after the write it is asking for. function vv_ai_redact(string $text): string { if ($text === '') return $text; foreach (vv_ai_known_secrets() as $secret) { $text = str_replace($secret, VV_AI_REDACTED, $text); } // NAME=value / "api_key": value / api-key: value — anchored on a secret-shaped name so an // ordinary "count=12" is untouched. $text = preg_replace( '/\b([A-Za-z0-9_\-]*(?:api[_\-]?key|password|passwd|secret|token|_pass|apikey)[A-Za-z0-9_\-]*)' . '(\s*["\']?\s*[:=]\s*["\']?)([^\s"\',;]{6,})/i', '$1$2' . VV_AI_REDACTED, $text ) ?? $text; // "set the api key to ", "password is " — the same secret arriving as prose // rather than as an assignment. $text = preg_replace( '/\b((?:api[ _\-]?key|password|passphrase|secret|token)\s+(?:to|is|as|=)\s+)["\']?([^\s"\',;]{6,})/i', '$1' . VV_AI_REDACTED, $text ) ?? $text; return $text; } // Redacts every message body in place, leaving roles and any other metadata alone. function vv_ai_redact_messages(array $messages): array { foreach ($messages as &$m) { if (isset($m['content']) && is_string($m['content'])) $m['content'] = vv_ai_redact($m['content']); } unset($m); return $messages; } function vv_ai_chats_max(): int { $n = (int)(vv_conf_vars()['AI_CHAT_HISTORY_MAX'] ?? 10); return max(1, min(50, $n)); } // Hex-only, for exactly the reason vv_ai_job_path() is: the id composes a path. Ids are minted // server-side and the client only ever echoes one back, so anything else is a caller that has // no business naming a file here. function vv_ai_chat_path(string $id): ?string { if (!preg_match('/^[0-9a-f]{32}$/', $id)) return null; return vv_ai_chats_dir() . '/' . $id . '.json'; } // The first thing asked, which is what the operator will recognise the conversation by. Falls // back rather than returning empty: a blank row in the list is indistinguishable from a broken // one. function vv_ai_chat_title(array $messages): string { foreach ($messages as $m) { if (($m['role'] ?? '') !== 'user') continue; $t = trim(preg_replace('/\s+/', ' ', (string)($m['content'] ?? ''))); if ($t !== '') return mb_substr($t, 0, 80); } return 'Untitled conversation'; } // Newest first, metadata only. The list card renders ten rows and none of them need the // transcript — sending every message of every stored chat to draw a sidebar would be the most // expensive read on the monitor page. function vv_ai_chats_list(): array { $out = []; foreach (glob(vv_ai_chats_dir() . '/*.json') ?: [] as $f) { $d = json_decode(@file_get_contents($f) ?: '', true); if (!is_array($d) || empty($d['id'])) continue; $out[] = [ 'id' => $d['id'], 'ts' => (int)($d['ts'] ?? 0), // Last activity, as distinct from ts. The list is ordered by creation so the prune // order and the visible order agree, but "which conversation was I last in" is a // different question and needs the other timestamp to answer it. 'updated' => (int)($d['updated'] ?? $d['ts'] ?? 0), 'profile' => (string)($d['profile'] ?? 'chat'), 'scope' => (string)($d['scope'] ?? ''), 'title' => (string)($d['title'] ?? 'Untitled conversation'), 'turns' => (int)($d['turns'] ?? count($d['messages'] ?? [])), ]; } usort($out, fn($a, $b) => $b['ts'] <=> $a['ts']); return $out; } function vv_ai_chat_read(string $id): ?array { $p = vv_ai_chat_path($id); if ($p === null || !file_exists($p)) return null; $d = json_decode(@file_get_contents($p) ?: '', true); return is_array($d) ? $d : null; } function vv_ai_chat_delete(string $id): bool { $p = vv_ai_chat_path($id); if ($p === null || !file_exists($p)) return false; return @unlink($p); } // Oldest go first, by stored timestamp rather than mtime — a chat that is reopened and continued // is rewritten, and ordering on mtime would make "the one I have been using all week" look like // the newest thing in the store while a genuinely older thread got dropped in its place. function vv_ai_chats_prune(?int $max = null): int { $max = $max ?? vv_ai_chats_max(); $list = vv_ai_chats_list(); $n = 0; foreach (array_slice($list, $max) as $old) { if (vv_ai_chat_delete($old['id'])) $n++; } return $n; } // Writes a whole conversation. An empty id mints one; a known id overwrites in place, which is // what makes a continued conversation stay one row in the list instead of breeding a new one per // turn. Written to a temp file and renamed, so a reader never sees half a transcript. function vv_ai_chat_save(string $id, string $profile, array $messages, string $scope = ''): array { if (!$messages) return ['ok' => false, 'error' => 'Nothing to save']; if ($id === '') $id = bin2hex(random_bytes(16)); $p = vv_ai_chat_path($id); if ($p === null) return ['ok' => false, 'error' => 'Invalid chat id']; // Preserve the original creation time across rewrites. Ordering the list by last activity // would be defensible, but it would also mean an old thread jumps the queue the moment it is // reopened, and the prune above is written against creation order. $prev = vv_ai_chat_read($id); $created = (int)($prev['created'] ?? time()); // Redacted here rather than at the point the message was composed, because the live turn // needs the real value to carry out what was asked. This is the boundary between "in flight" // and "on disk", and it is the last place the cleartext exists. The title is derived after, // so a credential cannot survive in the conversation list either. $messages = vv_ai_redact_messages($messages); // Scope travels with the conversation. A Scheduler thread is bound to what the operator had // open — a script, a log, a conf key — and its turns carry log excerpts chosen for that // thing. Storing the scope means reopening the thread anywhere restores the context it was // reasoned in, rather than silently continuing a troubleshooting conversation against // whatever happens to be on screen. The isolation the page enforces in memory becomes a // property of the record instead of something lost the moment it is saved. $rec = [ 'id' => $id, 'created' => $created, 'ts' => $created, 'updated' => time(), 'profile' => $profile, 'scope' => $scope, 'title' => vv_ai_chat_title($messages), 'turns' => (int)floor(count($messages) / 2), 'messages' => $messages, ]; $tmp = $p . '.tmp'; if (@file_put_contents($tmp, json_encode($rec, JSON_PRETTY_PRINT)) === false || !@rename($tmp, $p)) { @unlink($tmp); return ['ok' => false, 'error' => 'Could not write ' . $p]; } vv_ai_chats_prune(); return ['ok' => true, 'id' => $id, 'title' => $rec['title']]; } function vv_ai_job_dir(): string { if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true); return VV_AI_JOB_DIR; } // Hex-only, fixed length. The token composes a path, so the pattern is what confines it to the // job directory. function vv_ai_job_path(string $token): ?string { if (!preg_match('/^[0-9a-f]{32}$/', $token)) return null; return VV_AI_JOB_DIR . '/' . $token . '.json'; } function vv_ai_job_read(string $token): ?array { $p = vv_ai_job_path($token); if ($p === null || !file_exists($p)) return null; $d = json_decode(@file_get_contents($p) ?: '', true); return is_array($d) ? $d : null; }