diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index b64ce3f..2d5dde7 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1762,6 +1762,23 @@ # truncated away — the exact inversion of which one matters. AI_MEMORY_LEARNED_MAX_CHARS=1200 +# ━━━ AI Learned Memory ━━━ +# Lets the assistant propose facts worth keeping. It never writes memory directly: a candidate is +# filed in data/ai/mem_proposals/ and the operator accepts or dismisses it, exactly as findings +# work. Accepted text lands in the learned slot, which the prompt ranks BELOW retrieval. +# +# The reason for the indirection: memory rides on every future prompt. A model allowed to write +# its own — and told to prefer it over the passages — would restate a wrong conclusion forever, +# reading its own claim back as evidence. A bad proposal must cost one dismissal, not that. +# +# Costs nothing while off: the instruction that asks for a candidate is only added to the prompt +# when this is true, so a disabled feature is genuinely absent rather than merely ignored. + AI_MEMORY_LEARN_ENABLED=false + +# Writes accepted candidates without asking. Cannot outrank its parent — with proposing off this +# does nothing. Leave it false until the proposals have proven good for a while. + AI_MEMORY_LEARN_AUTO_ACCEPT=false + # ━━━ AI Stored Conversations ━━━ # How many past conversations the AI tab and the Monitor tab's AI row keep. One JSON file per # conversation under DATA_DIR/ai_chats, saved automatically when a turn completes; the oldest diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index e321fa7..7db4303 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -96,6 +96,7 @@ if (PHP_SAPI !== 'cli') { } 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 @@ -761,6 +762,22 @@ if (trim($mem['assisted']) !== '') { . 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: \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 " @@ -932,6 +949,27 @@ 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 !== '') { + if (preg_match('/\n[ \t]*MEMORY:[ \t]*(.+?)[ \t]*$/s', "\n" . $answer, $mm)) { + $candidate = trim(preg_replace('/\s+/', ' ', $mm[1])); + $answer = trim(preg_replace('/\n[ \t]*MEMORY:[ \t]*.+?[ \t]*$/s', '', "\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)' : ''), diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index 169ef9f..c079998 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -207,6 +207,37 @@ if ($action === 'memory_set') { exit; } +// ── learned-memory proposals ────────────────────────────────────────────────── +// The store the assistant files candidates into. Accepting is the only path by which model-written +// text reaches a prompt, and it is a POST so the CSRF prepend covers it. +if ($action === 'mem_proposals') { + require_once __DIR__ . '/../include/ai_memory_learn.php'; + $m = vv_ai_memory_read('learned'); + echo json_encode([ + 'ok' => true, + 'enabled' => vv_ai_mem_learn_enabled(), + 'auto' => vv_ai_mem_learn_auto(), + // The list states the gate as well as the rows: an empty list means "nothing proposed" + // when learning is on and "nothing is looking" when it is off, and those are different. + 'open' => vv_ai_mem_list('open'), + 'recent' => array_slice(vv_ai_mem_list(), 0, 25), + 'learned' => ['chars' => $m['chars'], 'max' => vv_ai_memory_learned_max()], + ]); + exit; +} + +if ($action === 'mem_proposal_action') { + if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } + require_once __DIR__ . '/../include/ai_memory_learn.php'; + $id = trim($_POST['id'] ?? ''); + $act = trim($_POST['act'] ?? ''); + $r = vv_ai_mem_action($id, $act); + vv_ai_log(sprintf('mem_proposal id=%s act=%s %s', $id, $act, + $r['ok'] ? 'ok' : ('FAILED: ' . ($r['error'] ?? '?')))); + echo json_encode($r); + exit; +} + // ── stop ────────────────────────────────────────────────────────────────────── // Cancels a generation in flight. Only ever signals ONE pid, verified to be the worker for this // exact job — never a process group. Signalling a group is what took the WebGUI down on diff --git a/Plugin/unraid/include/ai_memory_learn.php b/Plugin/unraid/include/ai_memory_learn.php new file mode 100644 index 0000000..3f1bbf8 --- /dev/null +++ b/Plugin/unraid/include/ai_memory_learn.php @@ -0,0 +1,207 @@ +.json — one file per proposal, mirroring the findings store. +// States: open | accepted | dismissed. Dismissed rows are KEPT, because they are what stops +// the same suggestion arriving again every night. +// ═══════════════════════════════════════════════════════════════════════════════════════════════ + +require_once __DIR__ . '/ai.php'; + +function vv_ai_mem_learn_enabled(): bool { + $v = vv_conf_vars(); + return strtolower(trim($v['AI_MEMORY_LEARN_ENABLED'] ?? 'false')) === 'true'; +} + +// Cannot outrank its parent — the same rule autofix follows against AI_REPAIR_ENABLED. +function vv_ai_mem_learn_auto(): bool { + if (!vv_ai_mem_learn_enabled()) return false; + $v = vv_conf_vars(); + return strtolower(trim($v['AI_MEMORY_LEARN_AUTO_ACCEPT'] ?? 'false')) === 'true'; +} + +function vv_ai_mem_dir(): string { + $d = AI_DATA_DIR . '/mem_proposals'; + if (!is_dir($d)) @mkdir($d, 0755, true); + return $d; +} + +// Aggressive on purpose: case, punctuation and filler collapse away so that "Emby's ffprobe is +// broken." and "emby ffprobe is broken" compare equal. Only used for comparison — never stored. +function vv_ai_mem_norm(string $s): string { + $s = mb_strtolower(trim($s)); + $s = preg_replace('/^[\s\-\*\d\.\)]+/u', '', $s); // list markers + $s = preg_replace('/[^a-z0-9]+/u', ' ', $s); + // Stray single LETTERS go too. "Emby's" becomes "emby s", and that orphaned s is enough to + // stop "emby bundled ffprobe is broken" matching "emby's bundled ffprobe is broken" — a + // reworded duplicate slipping through on an apostrophe. Digits are kept, so a PCIe address + // like 0000:02:02.0 still normalises to something that compares against itself. + $s = preg_replace('/\b[a-z]\b/u', ' ', $s); + return trim(preg_replace('/\s+/', ' ', $s)); +} + +// Everything the operator has already seen, in normalised form: both memory slots line by line, +// plus every proposal ever filed. Dismissed ones count — re-offering something that was turned +// down is the fastest way to make the whole feature feel broken. +function vv_ai_mem_known(): array { + $known = []; + + foreach (['assisted', 'learned'] as $kind) { + $p = vv_ai_memory_path($kind); + if (!file_exists($p)) continue; + foreach (preg_split('/\R/', (string)@file_get_contents($p)) as $line) { + $n = vv_ai_mem_norm($line); + if ($n !== '' && mb_strlen($n) > 8) $known[] = $n; + } + } + + foreach (glob(vv_ai_mem_dir() . '/*.json') ?: [] as $f) { + $d = json_decode((string)@file_get_contents($f), true); + if (!is_array($d)) continue; + $n = vv_ai_mem_norm((string)($d['text'] ?? '')); + if ($n !== '') $known[] = $n; + } + + return $known; +} + +// Containment both ways, because a proposal is usually a longer or shorter phrasing of something +// already held rather than a character-identical repeat. +function vv_ai_mem_is_dup(string $text, ?array $known = null): bool { + $n = vv_ai_mem_norm($text); + if ($n === '') return true; + foreach ($known ?? vv_ai_mem_known() as $k) { + if ($k === $n) return true; + if (mb_strlen($k) > 12 && mb_strpos($n, $k) !== false) return true; + if (mb_strlen($n) > 12 && mb_strpos($k, $n) !== false) return true; + } + return false; +} + +// A proposal is a single sentence. Anything longer is the model summarising the conversation +// rather than stating a fact, and it would eat the budget it is competing for. +const VV_AI_MEM_MAX_LINE = 220; + +function vv_ai_mem_propose(string $text, array $meta = []): array { + if (!vv_ai_mem_learn_enabled()) return ['ok' => false, 'error' => 'learning disabled']; + + $text = trim(preg_replace('/\s+/', ' ', $text)); + if ($text === '') return ['ok' => false, 'error' => 'empty']; + if (mb_strlen($text) > VV_AI_MEM_MAX_LINE) return ['ok' => false, 'error' => 'too long']; + if (vv_ai_mem_is_dup($text)) return ['ok' => false, 'error' => 'duplicate']; + + $id = substr(hash('sha256', $text . microtime(true)), 0, 12); + $row = [ + 'id' => $id, + 'text' => $text, + 'state' => 'open', + 'created' => time(), + 'profile' => (string)($meta['profile'] ?? ''), + // The question that produced it, trimmed. Without this a line read three weeks later has + // no way to be judged — the same reason findings carry their evidence. + 'asked' => mb_substr(trim((string)($meta['asked'] ?? '')), 0, 160), + ]; + + if (vv_ai_mem_learn_auto()) { + $r = vv_ai_mem_append($text); + $row['state'] = $r['ok'] ? 'accepted' : 'open'; + $row['auto'] = true; + $row['accepted'] = $r['ok'] ? time() : null; + if (!$r['ok']) $row['error'] = $r['error']; + } + + @file_put_contents(vv_ai_mem_dir() . "/$id.json", json_encode($row, JSON_PRETTY_PRINT)); + return ['ok' => true, 'id' => $id, 'state' => $row['state']]; +} + +// Appends one line to the learned slot, respecting its own ceiling. Refuses rather than trims: +// silently dropping half a fact is worse than declining to add it. +function vv_ai_mem_append(string $text): array { + $p = vv_ai_memory_path('learned'); + $max = vv_ai_memory_learned_max(); + if ($max <= 0) return ['ok' => false, 'error' => 'learned memory is disabled (cap is 0)']; + + $cur = file_exists($p) ? rtrim((string)@file_get_contents($p)) : ''; + if ($cur === '') { + $cur = "# Learned — proposed by the assistant, kept by the operator.\n" + . "# Hints only: anything retrieved from the repo outranks these.\n"; + } + $line = '- ' . $text . ' (' . date('Y-m-d') . ')'; + $next = $cur . "\n" . $line . "\n"; + + if (mb_strlen($next) > $max) { + return ['ok' => false, + 'error' => 'learned memory is full (' . mb_strlen($cur) . '/' . $max + . ' chars) — remove a line before adding another']; + } + return vv_ai_memory_write($next, 'learned'); +} + +function vv_ai_mem_list(string $state = ''): array { + $out = []; + foreach (glob(vv_ai_mem_dir() . '/*.json') ?: [] as $f) { + $d = json_decode((string)@file_get_contents($f), true); + if (!is_array($d)) continue; + if ($state !== '' && ($d['state'] ?? '') !== $state) continue; + $out[] = $d; + } + usort($out, fn($a, $b) => ($b['created'] ?? 0) <=> ($a['created'] ?? 0)); + return $out; +} + +function vv_ai_mem_action(string $id, string $act): array { + if (!preg_match('/^[0-9a-f]{12}$/', $id)) return ['ok' => false, 'error' => 'bad id']; + $f = vv_ai_mem_dir() . "/$id.json"; + if (!file_exists($f)) return ['ok' => false, 'error' => 'no such proposal']; + + $d = json_decode((string)@file_get_contents($f), true); + if (!is_array($d)) return ['ok' => false, 'error' => 'unreadable proposal']; + if (($d['state'] ?? '') !== 'open') { + // A tab left open overnight must not act on a choice the store has already moved past. + return ['ok' => false, 'error' => 'already ' . ($d['state'] ?? 'closed')]; + } + + if ($act === 'accept') { + $r = vv_ai_mem_append((string)$d['text']); + if (!$r['ok']) return $r; + $d['state'] = 'accepted'; + $d['accepted'] = time(); + } elseif ($act === 'dismiss') { + $d['state'] = 'dismissed'; + $d['closed'] = time(); + } else { + return ['ok' => false, 'error' => 'unknown action']; + } + + @file_put_contents($f, json_encode($d, JSON_PRETTY_PRINT)); + return ['ok' => true, 'state' => $d['state']]; +}