<?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';
// Whether results were actually attached, as opposed to merely asked for. The chat prompt states
// flatly that this profile cannot look anything up — true of this machine, and false of the web
// the moment a search succeeds. Told both at once, the model believed the prohibition and
// deflected a question while holding six relevant sources.
$webHave = false;
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']) {
        $webHave = true;
        $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 machine itself, separate from the AI subsystem's own health above. Read from the cache the
// WebGUI already writes, so it costs a file read rather than a docker ps and a disk sweep.
if ($can('system_state')) {
    $state = vv_ai_system_state();
    if ($state !== '') {
        $attached['system'] = 'machine state';
        $diagBlock .= $state;
    }
}

// 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 cannot inspect this "
            . "machine at all: never write that you are checking, reading, fetching or looking "
            . "through its 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"
            . "WHAT THE RULE DOES NOT COVER. It is about THIS installation — its scripts, its "
            . "settings, its state. It is not about the wider world. Unraid the product, Linux, "
            . "Docker, ZFS, a game, a piece of hardware, what version of something was released: "
            . "these are ordinary questions and you answer them normally. A question that merely "
            . "mentions a technology this installation happens to use is not a question about "
            . "this installation. Deflecting one of those to the Varaverk Assistant is wrong "
            . "twice over — it is unhelpful, and that profile reads this installation's own "
            . "documents and so knows even less about the outside world than you do.\n\n"
            . ($webHave
                ? "A web search ran for this question and the results are in the passages. Use "
                . "them. They are the one thing you can look up, so the rule above about not "
                . "narrating inspections does not apply to them — it is about this machine. "
                . "Answering \"I cannot look that up\" while holding search results is the one "
                . "answer that is plainly wrong.\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"
            . "TELL THEM WHERE TO CLICK, NOT WHICH FILE TO EDIT\n"
            . "You cannot change any setting yourself in this mode, and that is the point: the "
            . "fix is theirs to make. Nearly every setting has a control in the web UI, and the "
            . "passages include a map of where each one lives — use it. Give the route the way "
            . "you would say it out loud: which tab, which card, which control.\n\n"
            . "  Daily container updates are off now. Scheduler tab → Daily Sync Maintenance → "
            . "Steps → toggle Docker Update off.\n\n"
            . "Name the conf key as well, so they can confirm they changed the right thing — but "
            . "the route comes first and the file is the footnote. Telling someone to edit "
            . "master.conf over SSH when a switch exists is worse advice: it is slower, it skips "
            . "the backup and the syntax check that the UI write does for them, and it is how a "
            . "typo takes the array down. Say a setting has no control only when the map says so.\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"
            . "diagnosis: <why you think it happens — your reading, not a quote>\n"
            . "evidence: <the log line or lines that show it, quoted verbatim>\n"
            . "[/VARAVERK-BUG]\n\n"
            // evidence is read as everything after its label, because a quote runs to several
            // lines and guessing where it ends would truncate it. Any field added later must
            // therefore be listed above it, or it is swallowed into the quote.
            . "Keep diagnosis and evidence apart. Evidence is what the log says; diagnosis is "
            . "what you make of it. They are labelled differently in the report because whoever "
            . "reads it next — a person or another model — has to be able to tell which is which, "
            . "and a guess printed beside a quote gets read back as a second quote.\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.\n"
             . "Never where a setting lives or how to reach it in the web UI — all of that is "
             . "already documented and retrievable, so remembering it gains nothing and costs "
             . "budget that a fact the documents cannot supply would have used.\n"
             . "Most exchanges warrant nothing — when in doubt, leave the line out entirely.\n"
             . "When there is nothing to remember, say nothing about it. Do not write that no "
             . "memory was needed, do not explain why, do not mention this instruction at all. "
             . "The operator never asked about any of it and is not expecting an answer about "
             . "it — your reply should read exactly as it would if this section did not exist.\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";

// The same backstop pointing the other way, and it needs to be here rather than up in the prompt
// for the same reason that one does: this is the position the model cannot talk itself out of.
//
// The rule above hands it a ready-made refusal — "you cannot see the documentation in this mode,
// the Varaverk Assistant can answer it" — and a question merely containing the word Unraid was
// enough to make it emit that sentence almost verbatim while holding six release-note sources.
// Adding a paragraph of qualification near the rule did not shift it; the refusal was simply the
// nearest thing to hand. So when the classifier says the question names no Varaverk component,
// the counter-instruction goes where the refusal cannot outshout it.
//
// Deterministic on both sides: this fires only when detection says there is nothing Varaverk in
// the question, so the cautious branch above keeps every question it would have caught.
} elseif ($profile === 'chat' && $webHave && !vv_ai_asks_about_this_system($question)) {
    // Removes a false prohibition. It does NOT classify the question, and the first version of
    // this did: it told the model that nothing named a Varaverk component so this was a question
    // about the wider world. That inference is not the detector's to make. The detector finds
    // script names, SCREAMING_CASE keys and the project's own name — it answers NO to "why is my
    // array so slow tonight", "how much space do I have left" and "is everything healthy", every
    // one of which is about this machine and none of which names anything.
    //
    // So asserting "wider world" here would have handed those a confident answer assembled from
    // whatever the web returned about Unraid in general, dressed as an answer about their box —
    // the exact failure the absolute rule exists to prevent, reintroduced by the fix for a
    // different problem. Saying only what is true — results exist, the machine is still invisible
    // — leaves the model free to answer the Unraid-release question and still deflect that one.
    $system .= "NOTE: a web search ran for this question and the results are in the passages.\n"
             . "Looking things up is therefore something you CAN do here, and the rule above "
             . "about having no tools does not cover it — that rule is about inspecting this "
             . "machine. For a question about the outside world — a product, a release, a "
             . "version, how some technology works — answer it from these results and your own "
             . "knowledge and cite what you use. Saying you cannot look it up while holding the "
             . "sources is simply wrong, and the Varaverk Assistant would be no help: it reads "
             . "this installation's documents and knows less about the outside world than you.\n"
             . "What has NOT changed: you still cannot see this installation. A web search says "
             . "nothing about this operator's array, pool, containers, logs or settings, and no "
             . "number of results makes a general page about Unraid into a fact about their "
             . "machine. If the question is about their system rather than the world, the honest "
             . "answer is still that you cannot see it in this mode.\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)));
        }
    }

    // The model narrating its decision not to remember anything — "No MEMORY needed here, this is
    // a standard Unix permission setting." It is not a marker, so the strip above leaves it, and
    // it lands in the transcript as a footnote about a mechanism the operator never asked about
    // and cannot see. Every General Chat answer carried one from the moment learning was switched
    // on.
    //
    // Narrow on purpose: the last line only, mentioning MEMORY in the capitals the marker uses,
    // and short. Prose about RAM says "memory"; only a comment about this instruction shouts it.
    $lines = explode("\n", $answer);
    $last  = trim(end($lines));
    if (count($lines) > 1 && $last !== '' && mb_strlen($last) <= 200
        && strpos($last, 'MEMORY') !== false
        && preg_match('/\b(no|not|nothing|none|skip|omit|need|worth|warrant)\b/i', $last)) {
        array_pop($lines);
        $answer = rtrim(implode("\n", $lines));
        wlog('stripped a trailing remark about the memory marker');
    }
}

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]) : '';
    };
    // Both of these run to several lines, so neither can stop at a line break the way $field()
    // does — a two-sentence diagnosis would silently lose its second half. Each runs until the
    // next known label instead, which also means the order the model writes them in stops
    // mattering. That matters more than it sounds: evidence used to take everything after its own
    // label, so a diagnosis written after it was absorbed into the quote, and a guess sitting
    // inside a verbatim block is exactly the confusion this report is shaped to prevent.
    $until = fn(string $k, string $others) =>
        preg_match('/^\s*' . $k . '\s*:\s*(.*?)(?=^\s*(?:' . $others . ')\s*:|\z)/mis', $bm[1], $m)
            ? trim($m[1]) : '';

    $ev   = $until('evidence',  'diagnosis|component|summary');
    $diag = $until('diagnosis', 'evidence|component|summary');

    $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, [
            // The model's reading, kept apart from the quote it is a reading of. Stored under its
            // own key so the report can label it as inference — mixed into the evidence it would
            // be indistinguishable from something the log actually said.
            'diagnosis' => $diag,
            '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,
    ],
]);
