diff --git a/Orchestrators/sunday_morning_coffee_report.sh b/Orchestrators/sunday_morning_coffee_report.sh index 68d38cd..51919ad 100755 --- a/Orchestrators/sunday_morning_coffee_report.sh +++ b/Orchestrators/sunday_morning_coffee_report.sh @@ -184,6 +184,19 @@ echo "━━━━━ ☕ COFFEE REPORT SUMMARY ━━━━━" echo "🖥️ Host: $MY_ID" echo "⏱️ Done: $(date '+%Y-%m-%d %H:%M:%S')" +# Anything the Scheduler troubleshooter concluded was a Varaverk defect rather than a setting. +# Counted here because a finding filed on a Tuesday and read on a Tuesday is a finding nobody +# acts on — this report is the weekly moment the operator is actually looking. Count only: the +# detail lives on the AI tab, and a report that reprints every open bug stops being skimmable. +# Silent when there are none, and silent when AI is off, so a host without it reads the same as +# it always has. +if [[ "${AI_ENABLED:-false}" == true && -d "$DATA_DIR/ai_bugs" ]]; then + _ai_bugs_open=$(grep -l '"open": true' "$DATA_DIR"/ai_bugs/*.json 2>/dev/null | wc -l) + if [[ "${_ai_bugs_open:-0}" -gt 0 ]]; then + echo "🐞 AI bugs: $_ai_bugs_open open — see the AI tab" + fi +fi + if [[ ${#JOB_PASS[@]} -gt 0 ]]; then echo "✅ Passed: ${JOB_PASS[*]}" fi diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 8dedac8..b1ed0fb 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -278,7 +278,19 @@ if ($profile === 'chat') { . "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"; + . "and the operator is reading this inside the WebGUI. Name the control.\n\n" + . "IF IT REALLY IS A DEFECT, FILE IT\n" + . "When — and only when — the evidence shows Varaverk itself misbehaving rather than " + . "a setting, finish your reply with exactly this block so it gets recorded:\n\n" + . "[VARAVERK-BUG]\n" + . "component: \n" + . "summary: \n" + . "evidence: \n" + . "[/VARAVERK-BUG]\n\n" + . "Leave the block out entirely for anything explained by configuration, by a host " + . "being offline, or by a job simply not having run. The evidence field is not " + . "optional and must be a line you actually saw — a report nobody can check is worse " + . "than no report, because it costs someone an investigation to disprove.\n\n"; } elseif ($profile === 'code') { $system = "You are drafting a short shell script for the operator of an Unraid server, to be " @@ -461,6 +473,35 @@ if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $ } } +// Pull the bug block out of the answer and file it. Stripped from what the operator sees — the +// block is a machine contract, not prose — and replaced with a one-line confirmation so the +// filing is never silent. A malformed or evidence-free block is dropped rather than filed: +// the guard lives here, in code, not in the instruction that asked for it. A prompt is a +// request; this is the part that decides. +$bugFiled = null; +if ($profile === 'troubleshoot' + && preg_match('/\[VARAVERK-BUG\](.*?)\[\/VARAVERK-BUG\]/s', $answer, $bm)) { + $answer = trim(preg_replace('/\[VARAVERK-BUG\].*?\[\/VARAVERK-BUG\]/s', '', $answer)); + + $field = function (string $k) use ($bm): string { + return preg_match('/^\s*' . $k . '\s*:\s*(.+?)\s*$/mi', $bm[1], $m) ? trim($m[1]) : ''; + }; + // evidence may run to several lines; take everything after the label. + $ev = preg_match('/^\s*evidence\s*:\s*(.*)$/mis', $bm[1], $em) ? trim($em[1]) : ''; + + $res = vv_ai_bug_write($field('component'), $field('summary'), $ev, [ + 'asked' => mb_substr($question, 0, 300), + 'scope' => $scope, + 'log' => $scopedLog['path'] ?? null, + 'profile' => $profile, + ]); + if ($res['ok']) { + $bugFiled = $res; + $answer .= "\n\n_Filed as bug " . $res['id'] + . ($res['seen'] > 1 ? ' — seen ' . $res['seen'] . ' times' : '') . "._"; + } +} + $evalCount = (int)($d['eval_count'] ?? 0); $evalNs = (int)($d['eval_duration'] ?? 0); $tokS = $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null; diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index 441578b..7d3843a 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -187,6 +187,20 @@ if ($action === 'clear') { exit; } +// ── bugs / bug_close ────────────────────────────────────────────────────────── +// Reports the troubleshooter filed. Listing is a GET because it changes nothing; dismissing is +// a POST, like every other mutation in this plugin. +if ($action === 'bugs') { + echo json_encode(['ok' => true, 'bugs' => vv_ai_bugs_list(($_GET['all'] ?? '') !== '1')]); + exit; +} +if ($action === 'bug_close') { + if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } + $ok = vv_ai_bug_set_open(trim($_POST['id'] ?? ''), ($_POST['open'] ?? '0') === '1'); + echo json_encode(['ok' => $ok]); + exit; +} + // ── incident_add ────────────────────────────────────────────────────────────── // Appends one operator-written "this was the fix" note against a scope. POST only, and the // scope is whitelisted the same way ask's is — it is written to a file that later rides in a diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index ac1bbae..8ff7e13 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -792,6 +792,83 @@ function vv_ai_scope_ok(string $scope): bool { return true; } +// ── AI-filed bug reports ───────────────────────────────────────────────────────────────────── +// When the troubleshooter concludes the evidence shows a defect in Varaverk itself — not a +// setting — it files a report here rather than only saying so in a chat window that closes. +// +// Filed automatically, with the care put into the guard rather than into a review queue: a +// report requires a component and a quoted line of evidence, and identical findings collapse +// onto one record with a seen count. Without that, asking the same question three times files +// three bugs and the pile becomes noise within a week. +// +// Under data/ and therefore gitignored: these quote this installation's logs. +function vv_ai_bugs_dir(): string { + $d = DATA_DIR . '/ai_bugs'; + if (!is_dir($d)) @mkdir($d, 0755, true); + return $d; +} + +// Same component + same summary is the same finding. Hashing them means a recurring fault +// increments a counter instead of breeding files, and the count is itself the signal: seen 40 +// times is a different problem from seen once. +function vv_ai_bug_file(array $r): string { + return vv_ai_bugs_dir() . '/' . substr(sha1(strtolower($r['component'] . '|' . $r['summary'])), 0, 12) . '.json'; +} + +function vv_ai_bug_write(string $component, string $summary, string $evidence, array $ctx = []): array { + $component = trim($component); + $summary = trim($summary); + $evidence = trim($evidence); + // Evidence is mandatory. A report that cannot quote the line it is based on is an opinion, + // and this file exists to hold findings that can be checked. + if ($component === '' || $summary === '' || $evidence === '') return ['ok' => false]; + if (!vv_ai_scope_ok($component)) return ['ok' => false]; + + $rec = [ + 'component' => mb_substr($component, 0, 120), + 'summary' => mb_substr($summary, 0, 300), + 'evidence' => mb_substr($evidence, 0, 2000), + 'host' => vv_detect_host(), + 'first' => time(), + 'last' => time(), + 'seen' => 1, + 'open' => true, + 'context' => array_slice($ctx, 0, 12), + ]; + $p = vv_ai_bug_file($rec); + if (is_file($p)) { + $old = json_decode((string)@file_get_contents($p), true); + if (is_array($old)) { + $rec['first'] = $old['first'] ?? $rec['first']; + $rec['seen'] = (int)($old['seen'] ?? 0) + 1; + $rec['open'] = $old['open'] ?? true; // dismissing it stays dismissed + } + } + $rec['id'] = basename($p, '.json'); + if (@file_put_contents($p, json_encode($rec, JSON_PRETTY_PRINT)) === false) return ['ok' => false]; + return ['ok' => true, 'id' => $rec['id'], 'seen' => $rec['seen']]; +} + +function vv_ai_bugs_list(bool $openOnly = true): array { + $out = []; + foreach ((array)@glob(vv_ai_bugs_dir() . '/*.json') as $f) { + $r = json_decode((string)@file_get_contents($f), true); + if (!is_array($r) || ($openOnly && empty($r['open']))) continue; + $out[] = $r; + } + usort($out, fn($a, $b) => ($b['last'] ?? 0) <=> ($a['last'] ?? 0)); + return $out; +} + +function vv_ai_bug_set_open(string $id, bool $open): bool { + if (!preg_match('/^[0-9a-f]{12}$/', $id)) return false; + $p = vv_ai_bugs_dir() . '/' . $id . '.json'; + $r = json_decode((string)@file_get_contents($p), true); + if (!is_array($r)) return false; + $r['open'] = $open; + return @file_put_contents($p, json_encode($r, JSON_PRETTY_PRINT)) !== false; +} + // ── Incident journal ───────────────────────────────────────────────────────────────────────── // "We have seen this before, and here is what it was." Appended as you work through logs, and // fed back the next time the same script is being diagnosed. diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 6bc84dc..acd55fa 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -187,6 +187,18 @@ if (is_dir('/var/log/varaverk')) { .vv-ai-tok-foot { margin-top:9px; padding-top:7px; border-top:1px solid #1a1a1a; font-size:10px; color:#4a4a4a; display:flex; gap:14px; flex-wrap:wrap; } +/* ── Assistant-filed bug reports ─────────────────────────────────────────── */ +.vv-ai-bug { border-left:2px solid #5a3a2a; background:#140f0c; border-radius:0 3px 3px 0; + padding:8px 10px; margin-bottom:8px; } +.vv-ai-bug-h { display:flex; align-items:center; gap:9px; margin-bottom:4px; } +.vv-ai-bug-c { font-family:monospace; font-size:11px; color:#c8a87a; } +.vv-ai-bug-m { font-size:10px; color:#4a4a4a; font-family:monospace; margin-left:auto; } +.vv-ai-bug-s { font-size:12px; color:#b8b8b8; line-height:1.5; margin-bottom:5px; } +.vv-ai-bug-e { margin:0; padding:6px 8px; background:#0b0b0b; border:1px solid #1e1e1e; + border-radius:3px; font-size:10px; line-height:1.5; color:#8a8a8a; + white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; } +.vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; } + .vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; } .vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; } @keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} } @@ -256,6 +268,19 @@ if (is_dir('/var/log/varaverk')) { + + +
System checks
@@ -499,6 +524,47 @@ if (is_dir('/var/log/varaverk')) { .catch(() => {}); } + // ── Bug reports ───────────────────────────────────────────────────────── + // Read-only here plus dismissal. These are filed by the troubleshooter on the Scheduler tab; + // this card is where they are actually seen, since a chat window closes and a finding that + // only ever existed in one is a finding you do not have. + function loadBugs() { + fetch(API + '?action=bugs').then(r => r.json()) + .then(d => { if (d.ok) renderBugs(d.bugs || []); }) + .catch(() => {}); + } + + function renderBugs(bugs) { + const wrap = $('vv-ai-bugs-wrap'); + if (!bugs.length) { wrap.style.display = 'none'; return; } + wrap.style.display = ''; + $('vv-ai-bugs-sum').innerHTML = + `${bugs.length} open`; + $('vv-ai-bugs').innerHTML = bugs.map(b => { + const when = ago(b.last); + // The seen count is the triage signal — once is a curiosity, forty times is a pattern. + const seen = b.seen > 1 ? ` · seen ${b.seen}×` : ''; + return `
+
+ ${esc(b.component)} + ${esc(b.id)}${esc(seen)} · ${esc(when)} + +
+
${esc(b.summary)}
+
${esc(b.evidence)}
+ ${b.context && b.context.asked + ? `
asked: ${esc(b.context.asked)}
` : ''} +
`; + }).join(''); + } + + window.vvAiBugClose = function (id) { + fetch(API, { method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: new URLSearchParams({ action: 'bug_close', id, open: '0' }) }) + .then(() => loadBugs()).catch(() => {}); + }; + // ── Token accounting ──────────────────────────────────────────────────── // Fetched on load and after each completed turn, never on the 30s banner tick: the totals // only move when a turn finishes, and the page is the thing that knows when that was. @@ -919,5 +985,6 @@ if (is_dir('/var/log/varaverk')) { loadBanner(); loadTokens(); + loadBugs(); })();