Files
Varaverk/Plugin/unraid/Tools/ai_chat_worker.php
T
Gmer4Lfe e3ec54f213 Let each control actually govern the thing it names
Auto Scroll, the learning gate, the fold and the flash label each described a
behaviour they did not enforce.
2026-08-11 17:25:51 -04:00

1102 lines
66 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Detached worker for one AI chat turn. Retrieves grounding chunks, asks the generation
// model, and writes progress and the final answer to a job file the AI tab polls.
//
// OPERATIONAL MODEL
// Not an HTTP endpoint. Generation takes 25-76 seconds on this hardware — far past what a
// page request should hold open — so api/ai.php spawns this detached and returns a token.
// The job file is the only channel between the two, exactly as docker_pull_worker.php works
// for container updates. It lives under api/'s sibling Tools/ because it is part of that
// endpoint's implementation, not a scheduled script.
//
// Writes a terminal state on every exit path. A worker that dies without one leaves the tab
// polling forever, so the states are: retrieving -> 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
//
// EXPLAIN MODE
// ai_chat_worker.php --explain [--prompt] <question> [profile] [scope] [kind]
//
// Assembles the turn exactly as a real request would, prints which capabilities the profile
// holds, which gates fired, what was attached and what retrieval returned, then exits without
// asking the model. --prompt also dumps the assembled system prompt.
//
// Instant and identical every time, because everything it reports is decided before the model
// is involved. Use it to check a guard rather than reading an answer and inferring one, and to
// tell "the model reasoned badly" apart from "the model was never given the evidence" — which
// look the same from the answer alone.
//
// JOB FILE STATES
// {"status":"retrieving"}
// {"status":"generating","sources":[…]}
// {"status":"done","answer":…,"thinking":…,"sources":[…],"timing":{…},
// "profile":the profile that answered,"escalated":true if chat handed it up}
// {"status":"error","error":…}
// ═══════════════════════════════════════════════════════════════════════════════════════════════
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
require_once dirname(__DIR__) . '/include/ai.php';
require_once dirname(__DIR__) . '/include/ai_memory_learn.php';
// ── explain mode ─────────────────────────────────────────────────────────────────────────────
// Answers "what would this question be given, and why" without asking the model anything. Every
// decision that shapes an answer here is deterministic — which script the question names, which
// capabilities the profile holds, which gates fired, what retrieval returned — and only the prose
// is not. So the half worth testing can be tested without a model call at all: instant, free, and
// identical every time.
//
// It runs the real path rather than describing it. The report is printed from the same variables
// the request uses, immediately before the model call, so it cannot fall out of step with what
// actually happens. A separate function that reconstructed the same decisions would drift within
// a week and then be worse than nothing, because it would be believed.
$explain = ($argv[1] ?? '') === '--explain';
$showPrompt = false;
if ($explain) {
array_splice($argv, 1, 1);
if (($argv[1] ?? '') === '--prompt') { $showPrompt = true; array_splice($argv, 1, 1); }
[$question, $profile, $scope, $kind] = array_slice($argv, 1, 4) + array_fill(0, 4, '');
$jobFile = ''; $historyJson = '[]'; $think = '0';
if ($question === '') {
fwrite(STDERR, "usage: ai_chat_worker.php --explain [--prompt] <question> "
. "[profile] [scope] [kind]\n");
exit(2);
}
} else {
[$jobFile, $question, $historyJson, $kind, $think, $profile, $scope, $webArg] =
array_slice($argv, 1, 8) + array_fill(0, 8, '');
if ($jobFile === '' || $question === '') exit(1);
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
// Claim the job immediately. Retrieval can take a second or two and a profile that skips it
// goes straight to the model, so without this the first write could be a streaming flush —
// leaving a Stop pressed early with no pid to signal and nothing to show for the press.
jw($jobFile, ['status' => 'starting']);
}
// What got attached and why, recorded as it happens rather than reconstructed afterwards.
// Keyed by a stable token with the varying detail — counts, line totals — kept in the value, so a
// fixture can assert that the log tail was attached without asserting how many lines it had that
// day. An assertion that breaks whenever a log grows is an assertion nobody keeps.
$attached = [];
$profile = in_array($profile, ['varaverk', 'chat', 'code', 'troubleshoot'], true) ? $profile : 'varaverk';
$profileAsked = $profile;
// General Chat cannot answer a question about this installation — that is the whole point of it,
// and it is why it holds no capabilities. But refusing is not the same as being unable to help,
// and "the Varaverk Assistant profile can answer this" is a sentence the operator then has to act
// on: switch profile, retype the question, lose the thread. The question is already here and the
// profile that can take it is one line away.
//
// One direction only, and never the reverse. Escalating adds evidence and tightens the contract,
// so a wrong escalation costs tokens and an over-serious answer. Routing the other way — deciding
// a Varaverk question is small talk — removes the documentation and produces confident invention
// about someone's own server, which is the failure this whole design exists to prevent. There is
// no code path that moves a question down the ladder except the retrieval fallback below, which
// returns it to where it started rather than choosing a profile for it.
//
// The trigger is the same detector the deterministic backstop uses, so the two cannot disagree.
// The target is chosen by vv_ai_route_from_chat(), not fixed at varaverk. General Chat used to
// have exactly one place to escalate to, which meant "why did the daily orch fail" and "write me
// a script that prunes logs" both arrived at the documentation assistant — the first wanting a
// log it is not given by default, the second wanting code from a profile whose contract is to
// answer only from passages. Both were answered adequately and neither was answered well.
$escalated = false;
if ($profile === 'chat') {
$to = vv_ai_route_from_chat($question);
if ($to !== '' && $to !== $profile) {
$profile = $to;
$escalated = true;
wlog('handoff chat -> ' . $to . ': ' . mb_substr(vv_ai_redact($question), 0, 80));
}
}
// Every "is this profile allowed X" question in this file goes through here. Bound by reference
// rather than by value: the profile can still change after this point — the handoff above and the
// fallback below both move it — and a capability check that answered for the profile in force at
// definition time would be silently wrong for the rest of the run.
$can = function (string $cap) use (&$profile): bool { return vv_ai_profile_can($profile, $cap); };
// Explain mode has no job file and no tab waiting on one, so the state writes are dropped rather
// than special-cased at each of their call sites.
// Atomic by way of rename(2), which is what makes streaming safe. While generating, this runs
// several times a second; a plain file_put_contents would eventually be caught mid-write by a
// poll, and vv_ai_job_read() json_decodes a truncated file to null, which the endpoint reports as
// "pending" — the page would read a job halfway through generating as one that had not started.
// The temp name carries the pid so two writers can never collide on it.
function jw(string $f, array $d): void {
if ($f === '') return;
// The pid rides on every write rather than just the first, so Stop always has something to
// signal no matter which state the job is caught in — and so it cannot go missing when a
// later write forgets to carry it forward, which is exactly how the notify stamp was lost
// in the findings store.
if (!isset($d['pid'])) $d['pid'] = getmypid();
$tmp = $f . '.' . getmypid() . '.tmp';
if (@file_put_contents($tmp, json_encode($d)) === false) return;
if (!@rename($tmp, $f)) @unlink($tmp);
}
// 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;
// ── Web search ───────────────────────────────────────────────────────────────────────────────
// Asked for per turn, never decided here. The operator ticks it, and it only exists on the one
// profile that holds the capability — which is General Chat, and only because chat cannot write.
//
// Placed ahead of retrieval so its results are numbered first and the citation numbers the model
// sees match the order the page lists them in. It is mutually exclusive with retrieval in
// practice rather than by rule: no profile holds both, because the assistant's contract is that
// its answers come from this installation's own documents.
//
// A handoff has already happened by this point if it was going to — a chat question about this
// machine has become a varaverk one, which does not hold web_search, so asking about Varaverk
// never reaches the internet even with the box ticked.
$webAsked = ($webArg ?? '') === '1';
if ($webAsked && $can('web_search')) {
require_once dirname(__DIR__) . '/include/ai_web.php';
$tw = microtime(true);
$web = vv_ai_web_search($question);
wlog(sprintf('web search provider=%s ok=%s results=%d %s(%dms)',
$web['provider'] ?? '?', ($web['ok'] ?? false) ? 'yes' : 'no',
count($web['results'] ?? []), isset($web['error']) ? '(' . $web['error'] . ') ' : '',
(int)((microtime(true) - $tw) * 1000)));
if (($web['ok'] ?? false) && $web['results']) {
$context .= vv_ai_web_context($web['results'], 0);
foreach ($web['results'] as $r) {
// path carries the URL so the existing citation wiring keeps working unchanged; url
// is what tells the page to open a browser tab instead of the source viewer.
$sources[] = ['path' => $r['url'], 'url' => $r['url'], 'section' => '',
'heading' => $r['title'], 'score' => 0, 'web' => true];
}
$attached['web_search'] = count($web['results']) . ' results';
} elseif (!($web['ok'] ?? false)) {
// Told to the model rather than swallowed. An assistant that searched and got nothing
// must not answer as though it had searched and found nothing exists.
$context .= "A web search was requested but did not run: " . ($web['error'] ?? 'unknown')
. ". Say so rather than answering as though the web had been consulted.\n\n";
$attached['web_search'] = 'failed: ' . ($web['error'] ?? 'unknown');
} else {
$context .= "A web search was run and returned no results. Say so.\n\n";
$attached['web_search'] = 'no results';
}
}
if ($can('retrieve')) {
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']) {
if ($explain) { fwrite(STDERR, 'retrieval failed: ' . ($r['error'] ?? '?') . "\n"); exit(1); }
jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']);
exit(1);
}
if (!$r['results']) {
// A question that arrived here by handoff must not inherit this error. The operator asked
// in General Chat: something in the phrasing looked Varaverk-shaped, the index turned out
// to hold nothing on it, and a hard failure would be a worse answer than the polite
// deferral they would have got had the handoff never happened. Put it back where it came
// from — the only downward move in the file, and it chooses nothing, it just undoes.
if ($escalated) {
$profile = 'chat';
$escalated = false;
wlog('handoff reverted -> chat: nothing in the index for it');
} elseif (!$explain) {
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;
// Appended, and numbered from whatever is already there. No profile holds both web_search and
// retrieve, so today this offset is always zero — but assigning over $sources and numbering
// from one would silently drop the other set the moment one ever does, and a citation
// pointing at the wrong source is worse than no citation.
$offset = count($sources);
foreach ($r['results'] as $x) {
$sources[] = ['path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0];
}
foreach ($r['results'] as $i => $x) {
$label = implode(' ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
$context .= '[' . ($offset + $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.
// Which script the question names is resolved first, and carries most of the signal: naming
// something that has a log is a far stronger indicator than any turn of phrase. Wording then only
// has to separate "how did it go" from "what does it do", which is a much smaller job than
// recognising every way an operator might ask how a run went.
//
// It was a phrase list alone to begin with, and it missed "see how the run went" — one word order
// away from "went last", which it did have. Every phrase list has that failure somewhere and
// lengthening it does not end it; requiring a named target and then accepting weak evidence does.
$namedTarget = $can('run_evidence') ? vv_ai_resolve_run_target($question) : '';
$runOutcome = $namedTarget !== ''
// "What is the daily orchestrator" names a target and is not about any particular run.
&& !vv_ai_is_definitional($question)
&& (bool)preg_match(
'/\b(logs?|ran|runs?|went|go|going|gone|finish\w*|complet\w*|fail\w*|error\w*|'
. 'last night|duration|how long|rundown|summar\w*|recap|status|results?|outcome)\b/i',
$question);
// The target and its run record are resolved before the diagnostic decision rather than after,
// because whether the run failed is the single most useful input to that decision and it costs
// one small JSON read to know it.
$runTarget = ($can('scoped_log') && $scope !== '') ? $scope : '';
if ($runTarget === '' && $runOutcome) $runTarget = $namedTarget;
$rec = ($runTarget !== '' && vv_ai_scope_ok($runTarget))
? vv_ai_run_record($runTarget)
: ['ok' => false];
// A run that ended, reported ok and exited zero. Anything else — a bad exit, a missing end time,
// no record at all — is not clean and is treated as worth investigating.
$runClean = $rec['ok'] && $rec['status'] === 'ok' && $rec['exit'] === 0 && $rec['end'] !== null;
// The same detector the router uses to send a question here in the first place. Two copies of
// this regex would mean a question could be routed to Troubleshoot as diagnostic and then have
// live state withheld from it as not-diagnostic, which is the worst of both.
$kwDiagnostic = vv_ai_is_diagnostic($question);
// Permission first, need second: the capability decides whether live state may be attached at
// all, and only then does the phrasing decide whether this particular question warrants it.
// troubleshoot needs no phrasing test — the operator opened a log to get there.
//
// A run-outcome question earns live state only when the run was not clean. "How did the daily
// orch go" about a run that exited 0 was pulling in a fleet-wide health sweep and forty recent
// warning lines to answer a question the run record and the log tail already answer completely —
// roughly two thousand tokens taken from the passages to say nothing. When the run did fail, all
// of that is exactly what explains why, so it stays.
$diagnostic = $can('health') && (
$profile === 'troubleshoot'
|| $kwDiagnostic
|| ($runOutcome && !$runClean)
);
$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.
$attached['health'] = count(vv_ai_health()) . ' checks';
$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) {
$attached['warnings'] = count($logs) . ' lines';
$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.
// $runTarget and $rec were resolved above, where the diagnostic decision needed them. The record
// is emitted before the tail because it states the outcome where the tail only implies it: a log
// ending 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.
$scopedLog = null;
if ($runTarget !== '' && vv_ai_scope_ok($runTarget)) {
if ($rec['ok']) {
$attached['run_record'] = $rec['status'] . ', exit ' . var_export($rec['exit'], true);
$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']) {
$attached['log_tail'] = $scopedLog['path'] . ' (' . count($scopedLog['tail'])
. ' of ' . $scopedLog['total'] . ' lines)';
$diagBlock .= 'LOG: ' . $scopedLog['path']
. ' (' . $scopedLog['total'] . " lines total, newest last)\n"
. implode("\n", $scopedLog['tail']) . "\n\n";
} else {
$attached['log_missing'] = $runTarget . ' — ' . ($scopedLog['error'] ?? '?');
$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.
// Gated on the capability, which it was not before: this block keyed only on a scope being
// present, so General Chat opened against a script was handed the operator's own incident notes
// about it — the same leak as the log, one block further down.
if ($can('incidents') && $scope !== '') {
$past = vv_ai_incidents_for($scope, 4);
if ($past) {
$attached['incidents'] = (string)count($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 ($can('conf_lookup')) {
$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) {
$attached['conf_keys'] = (string)count($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.
//
// These stay here rather than in include/ai_profiles.php with the rest of what a profile is.
// That file exists to kill duplication, and these prompts have exactly one reader — moving them
// would relocate the most delicate text in the subsystem without removing a single copy of
// anything. The registry owns the ids; the ids branch here. The trailing else is varaverk.
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 === 'repair') {
// The only profile that can change a setting, so the prompt's job is mostly to stop it
// believing that it decides anything. It does not: the resolver picks the key, the probe
// picks the value, and the operator picks whether to write. What the model contributes is
// the explanation and the conversation — the parts where being wrong costs a sentence.
//
// Prior context first, because the failure this profile is most prone to is confidently
// re-deriving something already settled: proposing a fix that was tried and did not work,
// or reading a term the operator has already corrected once.
require_once dirname(__DIR__) . '/include/ai_repair.php';
$priorContext = vv_ai_repair_context();
if ($priorContext !== '') $attached['prior'] = 'phrasebook + closed findings';
$system = "You are helping the operator of a Varaverk server deal with a finding — something "
. "on this machine that is misconfigured or is reporting a problem.\n\n"
. "WHAT YOU DO NOT DECIDE\n"
. "You do not choose which setting is involved: that was resolved from the conf "
. "before you were asked. You do not choose what value to write: a probe either got "
. "an answer from a candidate or it did not, and only a value that answered can be "
. "written. You do not decide whether to apply anything — the operator does.\n\n"
. "So never say you have changed, set, fixed or updated anything. If a change was "
. "applied you will have been told so; otherwise it has not happened yet.\n\n"
. "TOGGLES ARE NEVER YOURS TO FLIP\n"
. "Whether something should be switched on is a decision about what the operator "
. "wants, not a fact you can discover. Offer it, explain the consequence, and wait.\n\n"
. "WHEN THE VALUE CANNOT BE WORKED OUT HERE\n"
. "Some things cannot be derived from this machine at all — an API key most of all. "
. "Say so plainly and walk the operator through getting it, step by step, naming the "
. "screen and the field. Then ask them to paste it. That is a normal outcome and not "
. "a failure; pretending to have found it is the failure.\n\n"
. "HOW TO ANSWER\n"
. "Lead with what is wrong and what it stops working. Quote the evidence on the "
. "finding. Then give the operator their choices in plain terms: apply the proposed "
. "value, acknowledge it as intended, or leave it. Keep it short — they are reading "
. "this to make one decision.\n\n"
. "Say plainly when you do not know. A wrong cause sends someone to fix the wrong "
. "thing, which is worse than saying the finding does not explain itself.\n\n"
. ($priorContext !== ''
? "WHAT HAS ALREADY HAPPENED HERE\n"
. "Use this before reasoning from scratch. If a term below was corrected, use the "
. "corrected meaning without being told again. If a similar finding was closed "
. "before, say what ended it last time.\n\n" . $priorContext . "\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.
// What it says it has is what it was actually given. This line used to assert a log tail
// unconditionally, which was safe only while the profile could be reached exclusively by
// opening one. It is now a button as well, so it is reachable with nothing attached — and
// telling a model it holds evidence it does not hold is precisely how you get a confident
// reading of a log that was never there, which is the one failure this profile exists to
// avoid. Stated from $attached, so the prompt cannot drift from the inputs.
$haveLog = isset($attached['log_tail']);
$system = "You are helping the operator work out why something on their Varaverk server did "
. "not do what they expected. "
. ($haveLog
? "You have the tail of the relevant log, live system state measured just now, "
. "and documentation passages about the scripts involved.\n\n"
: "You have NOT been given a log for this question — none was open and none was "
. "named that resolves to one. You have live system state and documentation "
. "passages only.\n\n"
. "Say that plainly before anything else, and name what would fix it: open the "
. "log on the Scheduler tab, or name the script in the question. Do not describe "
. "what a log 'would' show, do not infer an outcome from the script's name, and "
. "never write that you are reading or checking a log — you have none to read. "
. "An invented reading is worse here than anywhere else, because the operator "
. "came to this profile specifically for evidence.\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: <the script or file at fault, e.g. Arrs_Stack/arr_sync.sh>\n"
. "summary: <one line, what is wrong>\n"
. "evidence: <the log line or lines that show it, quoted verbatim>\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")
// The troubleshooting profile has had this rule from the start; the assistant never
// did, and the gap showed. Asked to look at a log, it offered a terminal command that
// re-runs the orchestrator — real flag, wrong answer, and it starts new work instead
// of reporting finished work.
. "The operator is reading this inside the Unraid WebGUI, not a terminal. Point them "
. "at the page, tab or panel that shows what they asked about, and name it. Give a "
. "shell command only when there is genuinely no control for it, and say so when you "
. "do.\n\n"
. "Never answer a question about how something WENT by telling them to run it. A "
. "dry run, a manual trigger or a test invocation reports on new work; they asked "
. "about work that already finished. If you cannot see that run, say you cannot see "
. "it and name where it is recorded.\n\n";
}
// The operator chose General Chat and is getting the Assistant. That has to be said in the answer
// itself: they picked a profile, the profile button in the tab still shows the one they picked,
// and an answer that quietly arrives under a different contract — with citations and a refusal
// rule they did not ask for — reads as the assistant ignoring them. One line, at the top.
// Names the profile that actually took it. The line used to say "the Varaverk Assistant"
// regardless, which was true while that was the only place a question could go and became a lie
// the moment the router could hand one to Troubleshoot or Code Sketcher.
if ($escalated) {
$system .= "HOW THIS QUESTION REACHED YOU\n"
. "The operator asked in General Chat, which is not shown Varaverk's documentation "
. "and holds no tools. Their message was " . match ($profile) {
'troubleshoot' => 'a question about something going wrong',
'code' => 'a request for a script to be written',
default => 'about something specific to this installation',
} . ", so it was handed to you, and you do have what it needs. Open with one short "
. "line saying so — something like \"General Chat can't do that, so I've picked this "
. "up as " . vv_ai_profile_label($profile) . "\" — then answer the question normally. "
. "One line: do not apologise for the switch, do not explain how the profiles work, "
. "and do not suggest they switch profile themselves. It has already happened.\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";
}
// Two blocks, two different standings, and the difference is stated to the model rather than
// implied. The assisted block keeps the precedence it always had. The learned block explicitly
// does not get it: those lines were proposed by a model, and a model whose own notes are ranked
// above the retrieved source would restate a wrong conclusion indefinitely, growing more
// confident each time it read its own claim back.
$mem = vv_ai_memory_assemble($profile);
if (trim($mem['assisted']) !== '') {
$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['assisted']) . "\n\n";
}
// Asking for the candidate inside the same call rather than making a second one. A follow-up
// "was anything here worth remembering" would cost another 25-75s on every question to answer
// "no" most of the time. The marker is stripped from the answer before it is shown, so the
// mechanism never appears in the transcript.
if (vv_ai_mem_learn_enabled()) {
$system .= "REMEMBERING SOMETHING\n"
. "If this exchange established a durable, non-obvious fact about THIS installation — "
. "a hardware quirk, a deliberate setting, something the operator corrected you on — "
. "then after your answer, on its own final line, write:\n"
. "MEMORY: <one short sentence>\n"
. "Rules: one line, under 200 characters, stated as fact with no hedging. Not a "
. "summary of your answer, not a restatement of the question, not anything already "
. "written above in what you know. Most exchanges warrant nothing — when in doubt, "
. "leave the line out entirely.\n\n";
}
if (trim($mem['learned']) !== '') {
$system .= "NOTES YOU WROTE EARLIER, KEPT BY THE OPERATOR\n"
. "These were proposed by you on previous turns and approved for keeping. They are "
. "hints, not facts. Anything in the passages above, and anything in the operator's "
. "own notes, outranks them — if a passage contradicts a note here, the passage is "
. "right and the note is stale. Do not cite them as numbered sources, and do not "
. "repeat one as established fact if nothing retrieved supports it.\n\n"
. trim($mem['learned']) . "\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_chat_needs_varaverk($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];
// Everything above is deterministic. Everything below asks the model. This is the line between
// them, which is why the report prints here: it describes the request that is about to be made,
// not a reconstruction of one.
if ($explain) {
$caps = vv_ai_profile_caps($profile);
$sysChars = strlen($system);
echo "QUESTION ", $question, "\n";
echo "PROFILE ", $profileAsked,
$profile !== $profileAsked ? " -> $profile (escalated)" : '',
$escalated ? '' : ($profileAsked === 'chat' && $profile === 'chat' ? ' (no handoff)' : ''), "\n";
echo "SCOPE ", $scope !== '' ? $scope : '(none)', "\n";
echo "CAPS ", $caps ? implode(', ', $caps) : '(none — answers from the model alone)', "\n\n";
echo "GATES\n";
printf(" %-14s %s\n", 'named target', $namedTarget !== '' ? $namedTarget : '(none resolved)');
printf(" %-14s %s\n", 'run outcome', $runOutcome ? 'YES' : 'no');
printf(" %-14s %s\n", 'diagnostic', $diagnostic ? 'YES' : 'no');
printf(" %-14s %s\n", 'run target', $runTarget !== '' ? $runTarget : '(none)');
printf(" %-14s %s\n", 'kind filter', $kind !== '' ? $kind : '(none)');
echo "\n";
echo "ATTACHED\n";
if (!$attached) {
echo ' (nothing', $sources ? " — no evidence beyond the passages)\n" : ")\n";
}
foreach ($attached as $k => $detail) printf(" %-12s %s\n", $k, $detail);
echo "\n";
echo 'RETRIEVED ', count($sources), " passages\n";
foreach ($sources as $i => $s) {
printf(" [%d] %.3f %s\n", $i + 1, $s['score'],
implode(' ', array_filter([$s['path'], $s['section'], $s['heading']])));
}
echo "\n";
// The number that actually constrains answer quality. num_ctx is 16384 and the passages are
// the first thing squeezed when evidence blocks grow, so a rough figure here is worth more
// than an exact one later.
printf("PROMPT %d chars, roughly %d tokens of a %d context\n",
$sysChars, (int)round($sysChars / 3.6), 16384);
if ($showPrompt) echo "\n", str_repeat('─', 92), "\n", $system;
exit(0);
}
// Streamed rather than awaited. At ~61 t/s a long answer is several seconds of a spinner, and the
// wait is the same either way — but seeing the first line lands lets the operator tell in about a
// second whether the question was understood, instead of finding out at the end. It also makes
// "generating" a state that genuinely exists: before this the worker never wrote it, so the page
// showed "starting…" for the entire run.
$payload = json_encode([
'model' => $cfg['model'],
'messages' => $messages,
'stream' => true,
'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,
]]);
$fh = @fopen($cfg['url'] . '/api/chat', 'r', false, $ctx);
if ($fh === false) {
jw($jobFile, ['status' => 'error',
'error' => 'Ollama did not respond within ' . max(30, $cfg['timeout']) . 's at ' . $cfg['url'],
'sources' => $sources]);
exit(1);
}
// Ollama streams NDJSON — one JSON object per line, each carrying a delta. The final object has
// done=true and is the only one holding the eval counters, so it is kept as $d for the ledger
// below; losing it would silently stop token accounting.
$answer = '';
$thinking = '';
$d = [];
// Throttle. The job file is on tmpfs so writes are cheap, but the page polls on its own interval
// and rewriting faster than it reads is pure waste.
$FLUSH_SEC = 0.12;
$lastFlush = 0.0;
while (($line = fgets($fh)) !== false) {
$line = trim($line);
if ($line === '') continue;
$o = json_decode($line, true);
// A non-JSON line means Ollama answered with an error body rather than a stream. Nothing to
// accumulate; the empty-answer check below reports it.
if (!is_array($o)) continue;
if (isset($o['message']['content'])) $answer .= (string)$o['message']['content'];
if (isset($o['message']['thinking'])) $thinking .= (string)$o['message']['thinking'];
if (!empty($o['done'])) { $d = $o; break; }
$now = microtime(true);
if ($now - $lastFlush >= $FLUSH_SEC) {
$lastFlush = $now;
// The reasoning rides along as well as its length. The count alone is what keeps the
// phase line honest during the ~15s before any content lands; the text is what the
// page shows when the operator has asked to watch it happen. Both are cheap here —
// the job file is tmpfs and the poll is a loopback request.
jw($jobFile, ['status' => 'generating',
'partial' => $answer,
'thinking' => $thinking,
'thinking_chars' => strlen($thinking),
'sources' => $sources]);
}
}
fclose($fh);
$answer = trim($answer);
$thinking = trim($thinking);
// Pull the candidate out before anything else looks at the answer — the code scan, the transcript
// and the token ledger all see the text without it. Matched only at the very end, so a MEMORY:
// mentioned mid-answer while explaining this feature is not mistaken for one being filed.
if (vv_ai_mem_learn_enabled() && $answer !== '') {
// No /s, and the tail is [^\n]+ rather than .+ — the marker must BE the last line, which is
// what the comment above always claimed. With /s the dot crossed newlines, so a lazy group
// anchored at $ matched the FIRST line-initial MEMORY: and captured everything after it to
// the end: asking the assistant to explain this very feature filed the rest of its own answer
// as a proposal and deleted it from the transcript.
if (preg_match('/\n[ \t]*MEMORY:[ \t]*([^\n]+?)[ \t]*$/', "\n" . $answer, $mm)) {
$candidate = trim(preg_replace('/\s+/', ' ', $mm[1]));
$answer = trim(preg_replace('/\n[ \t]*MEMORY:[ \t]*[^\n]+?[ \t]*$/', '', "\n" . $answer));
if ($candidate !== '') {
$r = vv_ai_mem_propose($candidate, [
'profile' => $profile,
'asked' => $question,
]);
wlog(sprintf('memory candidate %s: %s',
$r['ok'] ? ($r['state'] === 'accepted' ? 'auto-accepted' : 'filed')
: ('rejected (' . ($r['error'] ?? '?') . ')'),
mb_substr($candidate, 0, 80)));
}
}
}
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 ($can('code_scan') && 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)',
'/(?<!-)-delete\b/' => '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 ($can('file_bugs')
&& 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);
// profile is the one that actually answered, not the one that was asked for — they differ on a
// handoff. Reported so the page can show which contract produced the answer rather than the
// button the operator last pressed.
jw($jobFile, [
'status' => 'done',
'answer' => $answer,
'thinking' => $thinking,
'sources' => $sources,
'profile' => $profile,
'escalated' => $escalated,
'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,
],
]);