It could describe a conf edit but not the switch that makes it, and knew nothing about containers, pools or the array it was reasoning about.
1857 lines
92 KiB
PHP
1857 lines
92 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// The AI tab's library. Resolves AI configuration, reports the health of the retrieval
|
||
// subsystem for the status banner, runs retrieval against the index, and owns the job-file
|
||
// contract the chat worker and the polling endpoint share.
|
||
//
|
||
// OPERATIONAL MODEL
|
||
// Retrieval is delegated, not reimplemented. AI/lib/search.js already embeds the query,
|
||
// scans the index and applies intent routing; this file shells to that CLI with --json. A
|
||
// second PHP implementation of vector search would be a second set of scoring bugs, and the
|
||
// two would drift the first time the chunker changed.
|
||
//
|
||
// Generation is not performed here. It takes 25-76 seconds and belongs in a detached worker;
|
||
// this file only describes where that worker writes and what the states mean.
|
||
//
|
||
// DESIGN PRINCIPLES
|
||
// Every reported statistic distinguishes "unknown" from "bad".
|
||
// A banner that shows a healthy default when it cannot reach Ollama is worse than one
|
||
// that shows nothing, because the operator stops checking. Each probe returns null when
|
||
// it could not determine the answer.
|
||
//
|
||
// Offload percentage is a first-class metric.
|
||
// size_vram against size is the difference between 74 tok/s and 19 on this hardware, and
|
||
// nothing else on the host surfaces it. It is computed here so the banner and any future
|
||
// watchdog read the same number.
|
||
//
|
||
// Staleness is measured against tracked files, not the filesystem.
|
||
// The index only ever contains git-tracked content, so comparing to an untracked scratch
|
||
// file would report permanent staleness. git ls-files is the same source the indexer uses.
|
||
//
|
||
// Health checks compare configuration against reality, and carry their remedy.
|
||
// "Retrieval failed" is not actionable; "HOST1_OLLAMA_MODEL names a tag that is not
|
||
// installed" is. Every check names the setting to change, because the failures this
|
||
// subsystem actually has are configuration drift — a model pulled, wired into conf, then
|
||
// removed — rather than software faults.
|
||
//
|
||
// Logs are read live, never indexed.
|
||
// They churn constantly, would dominate a 3341-chunk corpus, and embedding similarity
|
||
// retrieves log lines poorly compared with recency and a severity filter. Indexing them
|
||
// would also mean re-embedding every few minutes for content that is stale immediately.
|
||
//
|
||
// OPERATIONAL SAFEGUARDS
|
||
// Read-only. Nothing here writes to the index, the conf, or the job files — it reads state
|
||
// and runs a retrieval query. The worker owns every write.
|
||
//
|
||
// Every external call is time-boxed.
|
||
// Retrieval and the Ollama probes carry explicit timeouts, because this library is
|
||
// loaded by a page render and by a polling endpoint; a hung Ollama must degrade the
|
||
// banner, not hang the tab.
|
||
//
|
||
// Retrieval arguments are validated before they reach the shell.
|
||
// kind is checked against the five real values and k is clamped, then every value is
|
||
// escapeshellarg'd. An unknown kind would match zero rows in SQL and read as "the index
|
||
// has no answer", which is the most misleading failure this subsystem can produce.
|
||
//
|
||
// Tokens are validated as hex before they compose a path.
|
||
// Job files live in a fixed directory and are named from a caller-supplied token; the
|
||
// pattern is what keeps that token from escaping the directory.
|
||
//
|
||
// A missing index is reported, never treated as empty.
|
||
// Empty results and an absent index look identical to a caller that only counts rows,
|
||
// and they need completely different responses from the user.
|
||
//
|
||
// EXPORTS
|
||
// Config vv_ai_enabled(), vv_ai_config()
|
||
// Status vv_ai_stats(), vv_ai_index_stats(), vv_ai_runtime_stats(), vv_ai_index_meta()
|
||
// Models vv_ai_models_available(), vv_ai_models_loaded()
|
||
// Diagnosis vv_ai_health(), vv_ai_recent_logs()
|
||
// Memory vv_ai_memory_path(), vv_ai_memory_read(), vv_ai_memory_write()
|
||
// Retrieval vv_ai_retrieve()
|
||
// Jobs vv_ai_job_dir(), vv_ai_job_path(), vv_ai_job_read()
|
||
//
|
||
// CONFIGURATION
|
||
// master.conf AI_ENABLED, AI_INDEX_DB, AI_SEARCH_K, AI_SEARCH_PER_FILE,
|
||
// AI_REQUEST_TIMEOUT, AI_CONNECT_TIMEOUT
|
||
// host*.conf HOST*_OLLAMA_URL, HOST*_OLLAMA_MODEL, HOST*_OLLAMA_EMBED_MODEL
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
|
||
require_once __DIR__ . '/config.php';
|
||
// vv_conf_key_is_secret() — the same definition of "this key holds a credential" that decides
|
||
// what the conf audit log redacts. One list, so the two cannot disagree about what a secret is.
|
||
require_once __DIR__ . '/confform.php';
|
||
|
||
// VV_AI_JOB_DIR and VV_AI_TOKEN_CACHE_DIR are defined in config.php with every other cache path,
|
||
// read from master.conf so this layer and load_config.sh cannot disagree about where they are.
|
||
// Must match what AI/lib/chunk.js actually assigns, and what the AI tab's retrieval-source
|
||
// dropdown offers. All three drifted: the indexer has always produced 'ui' for
|
||
// Plugin/unraid/pages/readme/*.md and the dropdown has always offered it, but this whitelist
|
||
// omitted it — so choosing "WebUI — how the pages work" and asking anything returned
|
||
// "Unknown kind: ui" and no answer at all. The page help was indexed and unreachable through
|
||
// the one filter built to reach it.
|
||
const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc', 'ui'];
|
||
|
||
// Profiles — what each one is, and what it is allowed to see and do. One table, in one file,
|
||
// read by everything: this endpoint, the worker, and the shared chat include every page renders.
|
||
// vv_ai_profile_can() and friends come from there.
|
||
require_once __DIR__ . '/ai_profiles.php';
|
||
|
||
// Whether a General Chat message is really about this installation. Shared by the deterministic
|
||
// backstop and the handoff, so both agree by construction: a question the backstop would have
|
||
// refused is exactly the one the handoff should escalate, and drift between the two would leave
|
||
// a class of question that is neither answered nor deferred.
|
||
function vv_ai_chat_needs_varaverk(string $question): bool {
|
||
return vv_ai_mentions_varaverk($question)
|
||
|| (bool)preg_match('/\b[\w.-]+\.sh\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+)+\b/', $question)
|
||
// Naming something that has a log is as Varaverk-specific as naming a script file, and
|
||
// this is how an operator actually refers to them: "the daily orch", not
|
||
// "daily_sync_maintenance.sh". Without this, "how did the daily orch go" asked in General
|
||
// Chat was refused correctly and then went nowhere — the refusal is right, but the
|
||
// question was answerable one profile up and the handoff could not see that.
|
||
|| vv_ai_resolve_run_target($question) !== '';
|
||
}
|
||
|
||
// Is the operator asking about THEIR OWN machine, without naming anything on it?
|
||
//
|
||
// A different axis from vv_ai_chat_needs_varaverk(), and the reason that one cannot be extended
|
||
// to cover this. That detector recognises Varaverk-shaped tokens — a .sh file, a SCREAMING_CASE
|
||
// key, a run target — so its complement is every question that names none, which is unbounded.
|
||
// "why is my array so slow tonight", "how much space do I have left" and "is everything healthy"
|
||
// are all about this machine and contain nothing to detect. Enumerating that class topic by topic
|
||
// is endless; the operator referring to their own system is not, because it is grammar rather
|
||
// than subject matter. Possessives, this-machine deictics, state questions and questions about
|
||
// what happened here cover most of it in about a dozen patterns.
|
||
//
|
||
// Used to decide when NOT to relax the chat profile's caution. Wrong in the safe direction: a
|
||
// false positive costs a redirect to a profile that can actually see the machine, a false
|
||
// negative risks a general web page answered as though it described this installation.
|
||
function vv_ai_asks_about_this_system(string $question): bool {
|
||
$q = ' ' . mb_strtolower(trim($question)) . ' ';
|
||
|
||
$builtin = [
|
||
// Theirs, or the box in front of them.
|
||
'/\b(my|our|mine)\b/',
|
||
'/\bthis (box|server|machine|system|install(ation)?|setup|rig|host|array|pool|cache|node)\b/',
|
||
// State of something here. "is everything healthy", "are the containers up".
|
||
'/\b(is|are|was|were)\b[^.?!]{0,40}\b(health(y|ier)?|ok|okay|fine|up|down|running|stopped|'
|
||
. 'slow|fast|full|empty|broken|failing|stuck|degraded|offline|online)\b/',
|
||
// What happened here, and when.
|
||
'/\bwhat (happened|went wrong|is (going on|happening))\b/',
|
||
'/\b(last night|this morning|yesterday|today|tonight|right now|currently|at the moment)\b/',
|
||
// Capacity and load, which are only ever about the machine being asked from.
|
||
'/\bhow (much|many)\b[^.?!]{0,30}\b(space|room|ram|memory|disk|storage|free|left|used)\b/',
|
||
'/\bdo i have\b/',
|
||
// Ownership of running things.
|
||
'/\b(my|the) (containers?|dockers?|shares?|disks?|drives?|pools?|arrays?)\b/',
|
||
];
|
||
foreach ($builtin as $re) if (preg_match($re, $q)) return true;
|
||
|
||
// Operator-supplied phrases, for the ones grammar does not reach — a nickname for the box, a
|
||
// share name, whatever turns up in practice. Pipe-separated rather than a conf array because
|
||
// vv_conf_vars() reads scalars and hands back "(" for an array, and this needs to be readable
|
||
// at question time from a settings field the operator can edit.
|
||
//
|
||
// Matched as literal text, never as patterns. The field is editable from the settings UI, and
|
||
// a regex arriving from there would run against every question asked.
|
||
$extra = trim((string) (vv_conf_vars()['AI_CHAT_MY_SYSTEM_PHRASES'] ?? ''));
|
||
if ($extra !== '') {
|
||
foreach (explode('|', $extra) as $phrase) {
|
||
$phrase = mb_strtolower(trim($phrase));
|
||
if ($phrase !== '' && mb_strpos($q, $phrase) !== false) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Asking for a script to be written, as opposed to asking about one that exists. The verb is
|
||
// required: "what does arr_sync.sh do" names a script and wants documentation, "write me a script
|
||
// that does X" names none and wants code. Getting that backwards in either direction is the whole
|
||
// risk here, so the pattern is anchored on the request rather than on the word "script".
|
||
function vv_ai_wants_code(string $question): bool {
|
||
return (bool)preg_match(
|
||
'/\b(write|create|make|draft|generate|build|give\s+me|need|want)\b[^.?!]{0,40}'
|
||
. '\b(a\s+|an\s+|me\s+a\s+)?(bash\s+|shell\s+|sh\s+)?'
|
||
. '(script|one[- ]?liner|cron\s?job|command|snippet)\b/i',
|
||
$question
|
||
);
|
||
}
|
||
|
||
// Diagnostic phrasing — "why did X fail", "X is broken", "it never runs". Shared with the
|
||
// worker's own gate so the router and the gate cannot disagree about what counts as a diagnosis.
|
||
function vv_ai_is_diagnostic(string $question): bool {
|
||
return (bool)preg_match(
|
||
'/\b(why|fail(ed|ing|ure)?|error|broken?|not work|isn.t work|wrong|stuck|hang|'
|
||
. 'never runs?|didn.t|won.t|debug|troubleshoot|diagnos)/i',
|
||
$question
|
||
);
|
||
}
|
||
|
||
// Which profile should answer a General Chat question, or '' to leave it in chat.
|
||
//
|
||
// One router, ordered most specific first, because these overlap on purpose: "why did the script
|
||
// I asked you to write fail" is diagnostic AND mentions a script, and the operator means the
|
||
// first. The order is the policy.
|
||
//
|
||
// code asked for something to be written. Checked first — it is the only intent that
|
||
// is about a thing that does not exist yet, so nothing else can claim it.
|
||
// troubleshoot something is wrong. Requires diagnostic phrasing AND something Varaverk-shaped
|
||
// to diagnose; "why is the sky blue" is diagnostic phrasing about nothing here.
|
||
// varaverk about this installation, but not a fault and not a request for code.
|
||
//
|
||
// Deliberately conservative: an unrecognised question stays in chat, which is the profile that
|
||
// cannot invent claims about this system. Escalation adds capability, so a wrong escalation costs
|
||
// more than a missed one — and the worker reverts to chat anyway if retrieval comes back empty.
|
||
function vv_ai_route_from_chat(string $question): string {
|
||
if (vv_ai_wants_code($question)) return 'code';
|
||
if (vv_ai_is_diagnostic($question) && vv_ai_chat_needs_varaverk($question)) return 'troubleshoot';
|
||
if (vv_ai_chat_needs_varaverk($question)) return 'varaverk';
|
||
return '';
|
||
}
|
||
|
||
function vv_ai_config(): array {
|
||
static $cfg = null;
|
||
if ($cfg !== null) return $cfg;
|
||
|
||
$vars = vv_conf_vars();
|
||
$host = strtoupper(vv_detect_host());
|
||
|
||
$cfg = [
|
||
'enabled' => 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;
|
||
}
|
||
|
||
// The machine as it is right now — hardware, containers, storage — summarised small enough to
|
||
// sit in a prompt.
|
||
//
|
||
// Read from the cache the WebGUI already writes every minute rather than measured here. A
|
||
// question must not cost a docker ps across fifty containers plus a disk sweep; the numbers move
|
||
// on their own schedule and one minute stale is the same answer. The age is stated so the model
|
||
// can say "a minute ago" rather than implying it looked just now.
|
||
//
|
||
// Strictly read-only, and there is no counterpart that changes any of it. Knowing a container is
|
||
// down is what lets an explanation be about this machine instead of about Unraid in general;
|
||
// restarting it is a decision that belongs to a person looking at the screen.
|
||
function vv_ai_system_state(): string {
|
||
$p = '/tmp/varaverk/api/monitor.json';
|
||
if (!is_readable($p)) return '';
|
||
$d = json_decode((string) @file_get_contents($p), true);
|
||
if (!is_array($d)) return '';
|
||
|
||
$age = time() - (int) @filemtime($p);
|
||
$s = "LIVE MACHINE STATE (read-only, measured " . ($age < 90 ? 'under a minute' : round($age / 60) . ' minutes')
|
||
. " ago — you cannot change any of it)\n";
|
||
|
||
$sys = $d['system'] ?? [];
|
||
if ($sys) {
|
||
$s .= '- host: ' . ($sys['cpu_model'] ?? '?') . ', ' . ($sys['cpu_threads'] ?? '?') . " threads\n";
|
||
}
|
||
if (!empty($d['cpu']['overall'])) $s .= '- cpu: ' . $d['cpu']['overall'] . "% overall\n";
|
||
|
||
$r = $d['resources'] ?? [];
|
||
if (!empty($r['ram_total_mb'])) {
|
||
$used = (int) $r['ram_total_mb'] - (int) ($r['ram_free_mb'] ?? 0);
|
||
$s .= sprintf("- memory: %d of %d MB used\n", $used, (int) $r['ram_total_mb']);
|
||
}
|
||
|
||
foreach ((array) ($d['gpus'] ?? []) as $g) {
|
||
$s .= sprintf("- gpu %d: %s, %d/%d MB, %d%% util, %d°C\n", $g['index'] ?? 0, $g['name'] ?? '?',
|
||
$g['memory_used'] ?? 0, $g['memory_total'] ?? 0, $g['utilization'] ?? 0,
|
||
$g['temperature'] ?? 0);
|
||
}
|
||
|
||
// Named individually only when something is wrong with them. Fifty healthy container names is
|
||
// the bulk of this block and none of it is ever the answer; the handful that are not are.
|
||
//
|
||
// Taken from the payload's own `stopped` list rather than derived. There is no state field on
|
||
// a container row — only `status`, which reads "Up 15 hours (healthy)" — so an earlier version
|
||
// of this looked for "run", found it nowhere, and reported all fifty as down. A block that
|
||
// confidently lists healthy containers as stopped is worse than no block: it is exactly the
|
||
// sort of thing the model would build a whole wrong explanation on.
|
||
$ctrs = (array) ($d['containers'] ?? []);
|
||
$stopped = (array) ($d['stopped'] ?? []);
|
||
$down = array_values(array_filter(array_map(
|
||
fn($c) => trim((string) (is_array($c) ? ($c['name'] ?? '') : $c)), $stopped)));
|
||
|
||
// Up, but failing its own healthcheck. Running is not the same as working, and this is the
|
||
// state that produces "it is started, so why is nothing happening".
|
||
$sick = [];
|
||
foreach ($ctrs as $c) {
|
||
if (stripos((string) ($c['status'] ?? ''), 'unhealthy') !== false) $sick[] = (string) ($c['name'] ?? '?');
|
||
}
|
||
|
||
$s .= '- containers: ' . count($ctrs) . ' running';
|
||
$s .= $down ? ', STOPPED: ' . implode(', ', array_slice($down, 0, 12)) : ', none stopped';
|
||
$s .= $sick ? '; UNHEALTHY: ' . implode(', ', array_slice($sick, 0, 8)) : '';
|
||
$s .= "\n";
|
||
|
||
foreach ((array) ($d['storage'] ?? []) as $pool) {
|
||
if (empty($pool['name'])) continue;
|
||
$s .= sprintf("- pool %s: %s%% used\n", $pool['name'], $pool['used_pct'] ?? $pool['pct'] ?? '?');
|
||
}
|
||
|
||
$par = $d['parity'] ?? [];
|
||
if ($par) {
|
||
$s .= '- array: ' . (($par['num_missing'] ?? 0) ? $par['num_missing'] . ' disks MISSING' : 'no missing disks')
|
||
. (($par['num_disabled'] ?? 0) ? ', ' . $par['num_disabled'] . ' disabled' : '')
|
||
. (!empty($par['in_progress']) ? ', parity operation in progress' : '') . "\n";
|
||
}
|
||
if (!empty($d['ups']['available'])) {
|
||
$s .= '- ups: ' . ($d['ups']['status'] ?? '?') . "\n";
|
||
}
|
||
return $s . "\n";
|
||
}
|
||
|
||
// 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 <new value>" 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 <value>", "password is <value>" — 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;
|
||
}
|