generating -> done | error. // // Retrieval happens here rather than in the endpoint so the tab gets a token immediately. // Embedding a query is fast but not free, and it is the first thing that would make the // "send" button feel slow. // // DESIGN PRINCIPLES // Context is assembled here, not by the model's own tooling. // The retrieved chunks go into a system message with explicit citation and refusal // instructions. That instruction is the difference between a grounded answer and the // model filling a gap from training data it does not have for a private project. // // History arrives already trimmed. // The endpoint caps turns before spawning. The worker does not re-derive the policy, // so there is one place that decides how much context history may consume. // // Thinking is captured separately, never discarded. // qwen3 emits reasoning that is often more useful than the answer for judgement calls. // It is stored in its own field so the page can collapse it rather than lose it. // // OPERATIONAL SAFEGUARDS // Refuses to run under a web server. // PHP_SAPI is checked first. Over HTTP there is no $argv, so every argument below would // be undefined — and this process talks to Ollama and writes job files. // // The job file path is validated as hex before anything is written. // It is supplied on the command line; the pattern is what keeps writes inside the job // directory even if the caller is ever wrong. // // The Ollama request is time-boxed. // AI_REQUEST_TIMEOUT bounds it, and a timeout is written as an error state rather than // leaving the job file at "generating" forever. // // Retrieval failure ends the turn. // An empty or failed retrieval writes an error instead of asking the model anyway. A // generated answer with no grounding is exactly the confident hallucination this whole // subsystem exists to prevent. // // Live state is attached only to diagnostic questions. // Failing health checks and recent log warnings are several thousand tokens. On a // 16384 context that is budget taken directly from the retrieved passages, so it is // spent only when the question is asking why something broke. Log lines are marked as // evidence rather than citable sources, so the model cannot cite a log line as though // it were documentation. // // Every failure path writes the job file. // Including the ones that would otherwise be silent — unreachable Ollama, unparseable // response, empty content — so the tab always converges on a state it can render. // // ARGUMENTS // 1 jobFile absolute path, hex-named, written by api/ai.php // 2 question the user's message // 3 history JSON array of {role, content}, already trimmed by the endpoint // 4 kind optional retrieval filter (header|readme|manual|template|doc) // 5 think "1" to allow the model's reasoning, "0" to suppress it // 6 profile varaverk|chat|code|troubleshoot — decides the contract and the inputs // 7 scope what the operator has open, e.g. Orchestrators/daily_sync_maintenance // // JOB FILE STATES // {"status":"retrieving"} // {"status":"generating","sources":[…]} // {"status":"done","answer":…,"thinking":…,"sources":[…],"timing":{…}} // {"status":"error","error":…} // ═══════════════════════════════════════════════════════════════════════════════════════════════ if (PHP_SAPI !== 'cli') { http_response_code(404); exit(1); } require_once dirname(__DIR__) . '/include/ai.php'; [$jobFile, $question, $historyJson, $kind, $think, $profile, $scope] = array_slice($argv, 1, 7) + array_fill(0, 7, ''); if ($jobFile === '' || $question === '') exit(1); if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); $profile = in_array($profile, ['varaverk', 'chat', 'code', 'troubleshoot'], true) ? $profile : 'varaverk'; function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); } // Same log the endpoint writes to, tagged so the two are tellable apart. Defined here because // vv_ai_log() belongs to api/ai.php and this runs detached, with no endpoint in the process. function wlog(string $msg): void { if (!is_dir('/var/log/varaverk')) return; @file_put_contents('/var/log/varaverk/ai.log', date('Y-m-d H:i:s') . ' worker ' . $msg . "\n", FILE_APPEND | LOCK_EX); } $cfg = vv_ai_config(); $t0 = microtime(true); // Only the Varaverk profile retrieves. The other two answer from memory and the model's own // knowledge, so passages would be noise competing for context — documentation cannot help write // a folder-copy script, and it has nothing to say about how someone's day is going. $sources = []; $context = ''; $tRetrieve = 0.0; if ($profile === 'varaverk' || $profile === 'troubleshoot') { jw($jobFile, ['status' => 'retrieving']); // A definitional question with no explicit filter goes to the narrative docs. Left alone, // intent routing boosts PURPOSE and returns every script's one-line purpose, so the model // reports that the context does not define Varaverk while README.md sits in the index // unread. An explicit --kind from the user always wins. if ($kind === '' && vv_ai_is_definitional($question)) $kind = 'readme'; $r = vv_ai_retrieve($question, $kind); if (!$r['ok']) { jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']); exit(1); } if (!$r['results']) { jw($jobFile, ['status' => 'error', 'error' => 'No relevant documentation found. Try rephrasing, use the readme filter ' . 'for questions about what something is, or switch to General Chat if this ' . 'is not a Varaverk question.']); exit(0); } $tRetrieve = microtime(true) - $t0; $sources = array_map(fn($x) => [ 'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '', 'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0, ], $r['results']); foreach ($r['results'] as $i => $x) { $label = implode(' › ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? ''])); $context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n"; } } // Diagnostic questions get live state as well as documentation. The docs say what a script is // supposed to do; only the logs and the current config say what it actually did. Attached only // when the question is asking why something failed — otherwise it is a few thousand tokens of // noise competing with the retrieved passages for a context budget that is already tight. // The troubleshooting profile is diagnostic by definition — the operator opened a log and asked // about it, which is a clearer signal than any phrasing test. For the assistant it stays a // keyword gate, since most of its questions are not about failures. // "How did the daily orch go last night" is diagnostic too, and matched none of the words below // — nothing had failed, so nothing in the question said failure. It retrieved the documentation // on where logs live and answered with directions to a page the operator already had open. The // question is about a run that happened, so the run itself has to be in context. // // Restricted to the two profiles that are allowed Varaverk's own material. chat is promised, as // its first and most absolute rule, that it has not been shown this installation — attaching a // log tail to it because the phrasing matched would break exactly the guarantee that stops it // inventing confident answers about the operator's system. $runOutcome = ($profile === 'varaverk' || $profile === 'troubleshoot') && (bool)preg_match( '/\b(how did|how.d|did .{0,24}\b(run|go|finish|complete)|last run|latest run|last night|' . 'go last|went last|how long did|run record|rundown|summar(y|ise|ize)|recap)\b/i', $question ); $diagnostic = $profile === 'troubleshoot' || $runOutcome || ($profile === 'varaverk' && (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 )); $diagBlock = ''; if ($diagnostic) { // Every check, not only the failing ones. Passing checks are what tell the model the // current value of a setting — without them it has no live signal for anything healthy, // and a documented default fills the gap. That produced a confidently wrong answer: // asked what was wrong, it reported "AI_ENABLED=false" read from the conf *template* // while the live value was true. Documentation records defaults; only this block records // what is actually set. $diagBlock .= "LIVE SYSTEM STATE (authoritative — measured just now)\n"; foreach (vv_ai_health() as $c) { $mark = ['ok' => 'OK', 'warn' => 'WARNING', 'bad' => 'PROBLEM'][$c['state']] ?? '?'; $diagBlock .= '- [' . $mark . '] ' . $c['label'] . ': ' . $c['detail'] . ($c['state'] !== 'ok' && $c['fix'] !== '' ? ' — FIX: ' . $c['fix'] : '') . "\n"; } $diagBlock .= "\n"; $logs = vv_ai_recent_logs(40); if ($logs) { $diagBlock .= "RECENT WARNINGS AND ERRORS (newest last)\n" . implode("\n", $logs) . "\n\n"; } } // The troubleshooting profile gets the actual tail of the one log the operator is looking at, // warnings and ordinary lines alike. The fleet-wide WARN/ERROR sweep above cannot answer "why // did this one stop" — the last line a script printed before dying is usually not labelled. // The target is whatever the operator has open, and failing that whatever they named in the // question. The second half is what makes a run-outcome question work from any view: asking how // the daily orchestrator went while looking at the suggestions list is the ordinary case, not an // edge one, and requiring them to open the log first is asking them to do the lookup themselves. $runTarget = ($profile === 'troubleshoot' && $scope !== '') ? $scope : ''; if ($runTarget === '' && $runOutcome) $runTarget = vv_ai_resolve_run_target($question); $scopedLog = null; if ($runTarget !== '' && vv_ai_scope_ok($runTarget)) { // The record first: it states the outcome, where the tail only implies it. A log that ends // on a tidy summary block looks identical whether the script exited 0 or was killed on the // next line, and the difference is the whole answer. $rec = vv_ai_run_record($runTarget); if ($rec['ok']) { $diagBlock .= 'RUN RECORD for ' . $runTarget . " (authoritative — how the last run ended)\n" . '- status: ' . $rec['status'] . ($rec['exit'] !== null ? ' (exit ' . $rec['exit'] . ')' : '') . "\n" . '- started: ' . date('Y-m-d H:i:s', $rec['start']) . "\n" . '- ended: ' . ($rec['end'] ? date('Y-m-d H:i:s', $rec['end']) : 'no end recorded — ' . 'it did not finish, or is still running') . "\n" . ($rec['duration'] !== null ? '- duration: ' . floor($rec['duration'] / 60) . 'm' . ($rec['duration'] % 60) . "s\n" : '') . "\n"; } $scopedLog = vv_ai_scoped_log($runTarget, 120); if ($scopedLog['ok']) { $diagBlock .= 'LOG: ' . $scopedLog['path'] . ' (' . $scopedLog['total'] . " lines total, newest last)\n" . implode("\n", $scopedLog['tail']) . "\n\n"; } else { $diagBlock .= "LOG: none found for " . $runTarget . " — it may never have run.\n\n"; } } // What has gone wrong with this same thing before, and what actually fixed it. Operator-written, // so it outranks anything the model would infer from the log — it is the only input here that // records a confirmed outcome rather than a reading of evidence. if ($scope !== '') { $past = vv_ai_incidents_for($scope, 4); if ($past) { $diagBlock .= "PREVIOUSLY ON THIS, WRITTEN BY THE OPERATOR AFTER IT WAS RESOLVED\n" . "Confirmed outcomes, not guesses. If the current symptom matches one of " . "these, say so and lead with it. If it clearly does not, ignore them " . "rather than forcing a fit.\n\n" . implode("\n", $past) . "\n\n"; } } // Where a named conf key really lives, resolved before the model sees the question. Deterministic // so the answer cannot be a guess: the operator may be certain a setting is in master.conf when // it is in the host conf, and the useful reply names the file and line rather than not finding it. if ($profile === 'varaverk' || $profile === 'troubleshoot') { $seen = []; if (preg_match_all('/\b([A-Z][A-Z0-9_]{4,})\b/', $question, $km)) { foreach (array_slice(array_unique($km[1]), 0, 4) as $k) { $hit = vv_ai_find_conf_key($k); if (!$hit['ok']) continue; $seen[] = '- ' . $k . ' is set in ' . $hit['file'] . ' at line ' . $hit['line'] . ($hit['commented'] ? ' (commented out, so it is NOT active)' : '') . ': ' . $hit['text']; } } if ($seen) { $diagBlock .= "WHERE THESE SETTINGS ACTUALLY LIVE (looked up just now, authoritative)\n" . implode("\n", $seen) . "\n" . "If this is a different file from the one they have open, say so plainly.\n\n"; } } // One prompt per profile. Explicit profiles rather than an automatic router: misclassifying a // Varaverk question as chat produces a confident invention about the user's system, which is // precisely what retrieval exists to prevent. The user always knows which contract is in force, // and the strict profile is the default. if ($profile === 'chat') { // A polite instruction is not a guard. Asked "what does mover_stop.sh do in my setup", the // model invented an answer and dressed it in real memory facts so it read as authoritative. // The rule therefore leads, is stated absolutely, and is reinforced by a deterministic // check below when the question names something Varaverk-shaped. $system = "You are the Varaverk assistant, talking with the operator of a private two-server " . "Unraid media ecosystem called Varaverk. This is ordinary conversation.\n\n" . "ABSOLUTE RULE, BEFORE ANYTHING ELSE: in this mode you have NOT been given " . "Varaverk's documentation. You therefore do not know what any Varaverk script, " . "config variable, orchestrator or safeguard actually does. If asked, you must say " . "you cannot see the documentation in this mode and that the Varaverk Assistant " . "profile can answer it — then stop. Do not describe what a script 'typically' or " . "'probably' does. Do not reason from its name. Do not combine facts from the memory " . "section into an explanation of a component you were not told about. A confident " . "wrong answer about their own system is the single worst thing you can do here; " . "sending them one click away costs nothing.\n\n" . "THE SAME RULE COVERS RUNTIME STATE, NOT JUST DOCUMENTATION. You have no logs, no " . "run records, no health checks and no way to inspect anything. Asked how a run " . "went, whether something is working, when it last ran, or what an error means, the " . "only honest answer is that you cannot see it in this mode. You have no tools and " . "cannot look anything up: never write that you are checking, reading, fetching or " . "looking through logs, and never narrate an inspection you are not performing. " . "Guessing an outcome is worse here than anywhere else, because a run that 'went " . "fine' is exactly the answer that stops someone looking — and you would be right " . "by luck or wrong invisibly, with no way for them to tell which.\n\n" . "Everything else is ordinary conversation. Talk like a knowledgeable colleague, be " . "warm and direct, follow a tangent if it is interesting, and use your general " . "knowledge freely — Linux, scripting, hardware, whatever comes up. The restriction " . "is only about the specifics of THIS installation.\n\n"; } elseif ($profile === 'troubleshoot') { // Different inputs and a different refusal rule from the assistant, which is what earns it a // profile of its own. The assistant's contract is "answer only from the passages, refuse if // absent" — exactly wrong here, where the evidence is a log that is not in the index and // never should be. This one reasons from the log first and the documentation second. $system = "You are helping the operator work out why something on their Varaverk server did " . "not do what they expected. You have the tail of the relevant log, live system " . "state measured just now, and documentation passages about the scripts involved.\n\n" . "HOW TO ANSWER\n" . "Lead with what the log actually shows. Quote the line that matters. Then say what " . "it means, using the documentation to explain what the script was trying to do.\n\n" . "Say plainly when the log does not explain it. \"The log ends at X with no error, " . "so it was killed or is still running\" is a real and useful answer. Do not " . "manufacture a cause to have one — a wrong cause sends someone to fix the wrong " . "thing, which is worse than no answer.\n\n" . "Distinguish what you observed from what you inferred. The log is evidence; " . "anything beyond it is a hypothesis and should be labelled as one.\n\n" . "START FROM CONFIGURATION, NOT FROM A DEFECT\n" . "You are talking to someone running this software, not maintaining it. Varaverk " . "itself is in daily use and mostly works, so the overwhelmingly likely cause of " . "'it did not do what I expected' is a setting: a toggle left false, an array that " . "is empty, a path that does not exist on this host, an entry commented out, a " . "gate higher up the tier that is off. Check those before anything else, and use " . "the live settings and health state you were given rather than assuming defaults.\n\n" . "Only call something a bug when the evidence actually shows one, and then say " . "which line shows it. 'This looks like a Varaverk problem' with nothing behind it " . "sends someone hunting through code they did not write and cannot fix, which is " . "the least useful place you can send them.\n\n" . "Do not suggest editing conf files by hand. Settings on this page have controls, " . "and the operator is reading this inside the WebGUI. Name the control.\n\n" . "IF IT REALLY IS A DEFECT, FILE IT\n" . "When — and only when — the evidence shows Varaverk itself misbehaving rather than " . "a setting, finish your reply with exactly this block so it gets recorded:\n\n" . "[VARAVERK-BUG]\n" . "component: \n" . "summary: \n" . "evidence: \n" . "[/VARAVERK-BUG]\n\n" . "Leave the block out entirely for anything explained by configuration, by a host " . "being offline, or by a job simply not having run. The evidence field is not " . "optional and must be a line you actually saw — a report nobody can check is worse " . "than no report, because it costs someone an investigation to disprove.\n\n"; } elseif ($profile === 'code') { $system = "You are drafting a short shell script for the operator of an Unraid server, to be " . "saved as a Varaverk Custom Script. Assume bash on Unraid: GNU coreutils, paths " . "under /mnt/user, no systemd, and no packages beyond what Unraid ships.\n\n" . "Write the smallest script that does the job. Include a shebang, quote variables, " . "check that inputs exist, and exit non-zero on failure. Prefer plain coreutils and " . "rsync over anything exotic.\n\n" . "This is the important part: you sometimes invent command-line flags that sound " . "plausible but do not exist. After the script, list every flag or command you are " . "not completely certain about, and say how to check it — 'verify with rsync --help' " . "or similar. A stated assumption the operator can check beats a confident mistake " . "they run at 3am. If you are unsure a flag exists, say so rather than picking the " . "one that sounds right.\n\n" . "Treat this as a first draft to be tested, and say so.\n\n" . "These scripts run as root, on a schedule, unattended. So the danger is not " . "complicated logic — it is a simple script aimed at the wrong path. If your script " . "deletes, moves, overwrites or truncates anything (rm, mv, find -delete, " . "rsync --delete, truncation with >, chown -R, chmod -R), then before the script " . "say in one line exactly what it will destroy and under what conditions. Where the " . "tool supports it, give the dry-run form first (rsync --dry-run, find without " . "-delete) and tell them to run that and read the output before the real one. " . "Guard every destructive path against an unset or empty variable — \"rm -rf " . "\$DEST/\" with DEST unset is the mistake that actually happens.\n\n" . "If the operator comes back with an error showing that something you suggested does " . "not exist — an unknown option, a command not found — say plainly that you got it " . "wrong and invented it. Do NOT explain it away as a version difference, a " . "distribution quirk, or a newer/older release, unless you can name the specific " . "version that added or removed it. Guessing at a cause is a second mistake on top " . "of the first, and it sends them chasing an upgrade that does not exist. 'I made " . "that flag up, the correct one is X' is the whole answer.\n\n"; } else { $system = "You are Varaverk's documentation assistant. Varaverk is this user's private " . "two-server Unraid media ecosystem; it is not in your training data, so the material " . "below is the only thing you know about it.\n\n" // The citation rule has to be stated here, in the contract itself, when there is run // evidence in play. Said later as an amendment it simply lost: the model kept citing // and hung [1] on a duration it had read out of a log, which points the operator at a // passage that does not contain it and cannot be checked. . ($runOutcome && $runTarget !== '' ? "Answer from this material and from the run evidence you have been given. Cite " . "documentation passages inline as [1], [2]. The run record and the log tail are " . "NOT passages and take no citation marker at all — state what they show plainly. " . "Where neither contains the answer, say so rather than filling the gap from " . "general knowledge. Prefer the user's own terminology.\n\n" : "Answer only from this material and cite the passages inline as [1], [2]. If it " . "does not contain the answer, say so plainly and name what is missing — do not " . "fill the gap from general knowledge. Prefer the user's own terminology.\n\n"); } // A run-outcome question arrives with the run attached, and the assistant's standing contract — // answer only from the retrieved passages — is wrong for it: the run record and the log are not // in the index and never will be. Without this the honest reading of its own rules is to fall // back on the documentation, which is how "how did the daily orch go" got answered with // directions to the Recent Activity panel instead of the run it was asked about. if ($runTarget !== '' && $runOutcome) { $system .= "THIS IS A QUESTION ABOUT A RUN THAT ALREADY HAPPENED\n" . "You have been given the run record and the tail of the log for " . $runTarget . ". They are evidence and they outrank the documentation.\n\n" . "Answer it as a rundown of that run, in this order: whether it succeeded, when it " . "ran and how long it took, what it actually did, and anything that failed, was " . "skipped or looks off. Use the real figures — counts, durations, script names — " . "rather than describing them in general terms. Summary blocks near the end of the " . "log usually hold the totals worth leading with.\n\n" . "Do NOT explain how to find the log, which panel shows recent runs, or how to read " . "it in the WebGUI. They asked you to read it and you have it in front of you; " . "telling them where to click is answering a question they did not ask.\n\n" . "If the record and the log disagree — a clean summary under a non-zero exit, or a " . "record with no end time — say so and lead with it. That contradiction is the most " . "useful thing on the page.\n\n"; } // Memory first, before the passages. It is the standing context — who the operator is and what // has already been decided — so it should frame everything that follows rather than read as one // more retrieved document. Marked as operator-authored so the model treats it as fact about the // installation rather than as a source to cite. // Where the operator is standing in the WebGUI. Sent by the scheduler page, absent from the AI // tab, which has no location to speak of. It is what lets "what does this do" resolve — without // it the model has to guess which of 120 settings "this" means, and it will guess confidently. // // Stated as context, never as an instruction to trust: the file named here is where to look // first, not proof that the answer is in it. That distinction is the whole point of the // fallback — a setting the operator expects in master.conf may actually live in the host conf, // and saying so is more useful than failing to find it. if ($scope !== '') { $system .= "WHERE THE OPERATOR IS RIGHT NOW\n" . "They have " . $scope . " open in the WebGUI. Read bare references — \"this " . "setting\", \"this script\", \"here\" — as meaning that unless they clearly mean " . "something else. Look there first. If what they are asking about is genuinely " . "somewhere else, answer anyway and tell them plainly where it actually is.\n\n"; } $mem = vv_ai_memory_read(); if ($mem['exists'] && trim($mem['text']) !== '') { $system .= "WHAT YOU ALREADY KNOW ABOUT THIS OPERATOR AND INSTALLATION\n" . "Written by the operator, not retrieved. Treat it as established fact about this " . "system, prefer it over anything in the passages that contradicts it, and do not " . "cite it as a numbered source.\n\n" . trim($mem['text']) . "\n\n"; } if ($diagBlock !== '') { $system .= "This is a diagnostic question, so live system state is included alongside the " . "documentation.\n\n" . "CRITICAL: where the live state contradicts anything in the passages, the live " . "state is correct. The passages include conf templates and design notes that " . "record DEFAULT values, not this system's current ones — a template showing " . "AI_ENABLED=false says what a fresh install ships with, never what is set here. " . "Never report a documented default as the current value.\n\n" . "Use the passages to explain how a thing is supposed to work, and the live state " . "to say what is actually happening. When a problem is listed, name the specific " . "setting and the file it lives in. Log lines are evidence, not citations — cite " . "only the numbered passages.\n\n" . $diagBlock; } // Deterministic backstop for the chat profile. The prompt asks the model to defer on Varaverk // internals; this does not rely on it complying. If the question names something that can only // be a Varaverk component — a shell script, a SCREAMING_CASE conf key, or the project itself — // the instruction is repeated immediately before the user's message, where it is hardest to // ignore. Detection only ever makes the model MORE cautious, so a false positive costs a // redirect rather than a wrong answer. if ($profile === 'chat' && (vv_ai_mentions_varaverk($question) || preg_match('/\b[\w.-]+\.sh\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+)+\b/', $question))) { $system .= "NOTE: the operator's message appears to name a specific Varaverk component. " . "You cannot see the documentation in this mode, so you do not know what it does. " . "Say that plainly, point them at the Varaverk Assistant profile, and do not " . "speculate about its behaviour — not even a hedged guess.\n\n"; } if ($context !== '') $system .= "PASSAGES\n" . $context; $messages = [['role' => 'system', 'content' => $system]]; $hist = json_decode($historyJson ?: '[]', true); if (is_array($hist)) { foreach ($hist as $m) { $role = $m['role'] ?? ''; $text = trim((string)($m['content'] ?? '')); if (in_array($role, ['user', 'assistant'], true) && $text !== '') { $messages[] = ['role' => $role, 'content' => $text]; } } } $messages[] = ['role' => 'user', 'content' => $question]; $payload = json_encode([ 'model' => $cfg['model'], 'messages' => $messages, 'stream' => false, 'think' => $think === '1', 'options' => ['num_ctx' => 16384], ]); $t1 = microtime(true); $ctx = stream_context_create(['http' => [ 'method' => 'POST', 'header' => "Content-Type: application/json\r\n", 'content' => $payload, 'timeout' => max(30, $cfg['timeout']), 'ignore_errors' => true, ]]); $raw = @file_get_contents($cfg['url'] . '/api/chat', false, $ctx); if ($raw === false) { jw($jobFile, ['status' => 'error', 'error' => 'Ollama did not respond within ' . max(30, $cfg['timeout']) . 's at ' . $cfg['url'], 'sources' => $sources]); exit(1); } $d = json_decode($raw, true); if (!is_array($d) || !isset($d['message'])) { jw($jobFile, ['status' => 'error', 'error' => 'Unparseable response from Ollama', 'sources' => $sources]); exit(1); } $answer = trim((string)($d['message']['content'] ?? '')); $thinking = trim((string)($d['message']['thinking'] ?? '')); if ($answer === '') { jw($jobFile, ['status' => 'error', 'error' => 'The model returned no answer' . ($thinking !== '' ? ' (only reasoning)' : ''), 'thinking' => $thinking, 'sources' => $sources]); exit(1); } // Destructive-operation scan of what was actually generated. The prompt asks the model to warn; // this does not depend on it having done so. These scripts run as root on a schedule, so the // expensive mistake is not tangled logic — it is a simple script pointed one directory too high. // Scans only fenced code, so prose mentioning "rm" does not trip it. $warnings = []; if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $blocks)) { $code = implode("\n", $blocks[1]); $checks = [ '/(^|[;&|\s])rm\s+(-[a-zA-Z]*\s+)*/m' => 'deletes files (rm)', '/(^|[;&|\s])mv\s/m' => 'moves files (mv)', '/(? 'deletes files (find -delete)', '/--delete\b/' => 'deletes at the destination (rsync --delete)', '/(^|[;&|\s])(shred|truncate)\s/m' => 'destroys file contents', '/(^|[;&|\s])(dd|mkfs\.\w+|mkfs)\s/m' => 'writes raw to a device', '/(^|[;&|\s])ch(own|mod)\s+-[a-zA-Z]*R/m' => 'recursively changes ownership or permissions', ]; foreach ($checks as $re => $label) { if (preg_match($re, $code)) $warnings[] = $label; } // An unquoted or unguarded path variable is what turns any of the above into a disaster. if ($warnings && preg_match('/(rm|mv|rsync)[^\n]*\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?![\w"])/', $code)) { $warnings[] = 'uses a path variable in a destructive command — confirm it can never be empty'; } } // Pull the bug block out of the answer and file it. Stripped from what the operator sees — the // block is a machine contract, not prose — and replaced with a one-line confirmation so the // filing is never silent. A malformed or evidence-free block is dropped rather than filed: // the guard lives here, in code, not in the instruction that asked for it. A prompt is a // request; this is the part that decides. $bugFiled = null; if ($profile === 'troubleshoot' && preg_match('/\[VARAVERK-BUG\](.*?)\[\/VARAVERK-BUG\]/s', $answer, $bm)) { $answer = trim(preg_replace('/\[VARAVERK-BUG\].*?\[\/VARAVERK-BUG\]/s', '', $answer)); $field = function (string $k) use ($bm): string { return preg_match('/^\s*' . $k . '\s*:\s*(.+?)\s*$/mi', $bm[1], $m) ? trim($m[1]) : ''; }; // evidence may run to several lines; take everything after the label. $ev = preg_match('/^\s*evidence\s*:\s*(.*)$/mis', $bm[1], $em) ? trim($em[1]) : ''; $component = $field('component'); // Guard one: the component must be a real file here. A report against a path that does not // exist is the model naming something plausible rather than something it saw. // // Guard two: the quoted evidence must actually appear in the log it was given. This is what // keeps "misconfigured" out of the bug list — a genuine defect is visible in the log, while // a setting being false produces no such line, so an invented quote fails here instead of // becoming a report someone has to disprove. // // When no log was available there is nothing to check against; the report is still filed, // because live health state is real evidence too, but it is marked unverified and says so // on the card. Refusals are logged rather than swallowed — how often the model tries to file // junk is worth knowing, and silence would hide it. $verified = $scopedLog && !empty($scopedLog['ok']) ? vv_ai_evidence_in_log($ev, $scopedLog['tail'] ?? []) : null; if (!vv_ai_component_exists($component)) { wlog('bug refused: component not a real file — ' . mb_substr($component, 0, 80)); $res = ['ok' => false]; } elseif ($verified === false) { wlog('bug refused: evidence not found in ' . ($scopedLog['path'] ?? '?') . ' — ' . mb_substr(preg_replace('/\s+/', ' ', $ev), 0, 100)); $res = ['ok' => false]; } else { $res = vv_ai_bug_write($component, $field('summary'), $ev, [ 'asked' => mb_substr($question, 0, 300), 'scope' => $scope, 'log' => $scopedLog['path'] ?? null, 'verified' => $verified, 'profile' => $profile, ]); } if ($res['ok']) { $bugFiled = $res; // Say what actually happened, not what sounds reassuring. This is written to a file on // this host — it is not transmitted anywhere, and telling a user their report has been // "sent" when it is sitting on their own disk is the exact species of confident wrong // answer the rest of this profile is built to avoid. When a transport exists, this line // changes with it. $answer .= "\n\n_Recorded on this host as bug " . $res['id'] . ($res['seen'] > 1 ? ', seen ' . $res['seen'] . ' times' : '') . ". It is listed on the AI tab and counted in the Sunday report._"; } } $evalCount = (int)($d['eval_count'] ?? 0); $evalNs = (int)($d['eval_duration'] ?? 0); $tokS = $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null; // Accounting before the job file is written, so a turn is counted even if the tab has already // been closed and nobody ever reads the result. Best-effort by contract — it cannot throw. vv_ai_token_record($profile, 'webgui', (int)($d['prompt_eval_count'] ?? 0), $evalCount, $tokS); jw($jobFile, [ 'status' => 'done', 'answer' => $answer, 'thinking' => $thinking, 'sources' => $sources, 'warnings' => array_values(array_unique($warnings)), 'timing' => [ 'retrieve_ms' => (int)round($tRetrieve * 1000), 'generate_ms' => (int)round((microtime(true) - $t1) * 1000), 'tokens' => $evalCount, 'tok_s' => $tokS, ], ]);