From 57f3bf28074f2607383b1dc2f34e315fb64e3af7 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 10 Aug 2026 21:50:35 -0400 Subject: [PATCH] Split memory into assisted and learned, and deny learned the precedence assisted has --- Deployment/master.conf.template | 28 +++++- Plugin/unraid/Tools/ai_chat_worker.php | 22 ++++- Plugin/unraid/include/ai.php | 120 ++++++++++++++++++++++--- 3 files changed, 155 insertions(+), 15 deletions(-) diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 2b8eb41..b64ce3f 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1735,8 +1735,32 @@ # The character cap is a context budget, not a style guide. At 16384 the retrieved passages, # the model's reasoning and the conversation history are already competing; memory takes its # share off the top of every single turn, so keep it short and factual. - AI_MEMORY_FILE="${AI_DATA_DIR}/ai_memory.md" - AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — truncated with a notice if exceeded +# Two slots, and which one a fact lands in decides how much authority it carries. +# +# assisted written by the operator. The prompt tells the model to prefer it over retrieved +# passages — a human asserting a fact about their own machine outranks a doc that +# may be stale. This is the file the AI tab edits. +# learned proposed by the assistant, accepted by the operator. A hint only: retrieval +# overrules it, and it is trimmed first when the budget bites. Deliberately the +# weaker seat, because memory the model writes AND the prompt ranks above the source +# code would let one wrong conclusion restate itself forever. +# +# AI_MEMORY_FILE is the pre-split name and is still honoured: while the assisted file does not +# exist, the legacy path is read instead, so an upgrade loses nothing. + AI_MEMORY_ASSISTED_FILE="${AI_DATA_DIR}/mem_assisted.md" + AI_MEMORY_LEARNED_FILE="${AI_DATA_DIR}/mem_learned.md" + AI_MEMORY_FILE="${AI_DATA_DIR}/ai_memory.md" # legacy — read only if the assisted file is absent + +# Which profiles each slot is given. "*" is all of them. Narrowing is how a General Chat question +# about bash syntax stops carrying this machine's PCIe topology and disk serials. + AI_MEMORY_ASSISTED_PROFILES="*" + AI_MEMORY_LEARNED_PROFILES="*" + + AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — the budget for both slots together +# A ceiling on the learned slot alone, well under the total. A store that grows on its own would +# otherwise end up occupying the whole budget, and the operator's own memory is what would get +# truncated away — the exact inversion of which one matters. + AI_MEMORY_LEARNED_MAX_CHARS=1200 # ━━━ AI Stored Conversations ━━━ # How many past conversations the AI tab and the Monitor tab's AI row keep. One JSON file per diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index f809438..e321fa7 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -746,13 +746,29 @@ if ($scope !== '') { . "somewhere else, answer anyway and tell them plainly where it actually is.\n\n"; } -$mem = vv_ai_memory_read(); -if ($mem['exists'] && trim($mem['text']) !== '') { +// 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['text']) . "\n\n"; + . trim($mem['assisted']) . "\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 !== '') { diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index 085df7a..bb965ec 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -602,12 +602,63 @@ function vv_ai_is_definitional(string $q): bool { // mechanism for "things I was told to remember". Living under DATA_DIR keeps it out of the // repository and therefore out of the index by construction. -function vv_ai_memory_path(): string { - $cfg = vv_ai_config(); +// Two memory slots, and the distinction between them is the whole point. +// +// assisted — written by the operator. Authoritative: the prompt tells the model to prefer it +// over retrieved passages, because a human asserting a fact about their own machine +// outranks a doc that may be stale. +// learned — proposed by the assistant and accepted by the operator. A hint only. Retrieval +// overrules it, and it is trimmed first when the budget bites. A model that can +// write memory the prompt ranks above the source code will restate its own mistakes +// forever, so it is deliberately given the weaker seat. +// +// Kept as two named slots rather than an arbitrary list: two is what the split is for, and a +// general N-file scheme would be machinery serving nobody. +function vv_ai_memory_path(string $kind = 'assisted'): string { $vars = vv_conf_vars(); - $p = trim($vars['AI_MEMORY_FILE'] ?? ''); + $key = $kind === 'learned' ? 'AI_MEMORY_LEARNED_FILE' : 'AI_MEMORY_ASSISTED_FILE'; + $p = trim($vars[$key] ?? ''); + + // Back-compat. Installs upgraded from the single-file era have AI_MEMORY_FILE set and a + // populated ai_memory.md; losing that silently would drop everything the operator had + // written. The legacy path wins only while the new one does not exist. + if ($kind === 'assisted') { + $legacy = trim($vars['AI_MEMORY_FILE'] ?? ''); + $legacy = $legacy !== '' ? $legacy : AI_DATA_DIR . '/ai_memory.md'; + $legacy = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $legacy); + $newp = $p !== '' ? str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $p) + : AI_DATA_DIR . '/mem_assisted.md'; + if (!file_exists($newp) && file_exists($legacy)) return $legacy; + return $newp; + } + $p = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $p); - return $p !== '' ? $p : AI_DATA_DIR . '/ai_memory.md'; + return $p !== '' ? $p : AI_DATA_DIR . '/mem_learned.md'; +} + +// A hard ceiling on the learned file, well under the total. Without it a store that grows on its +// own eventually occupies the whole budget and the operator's own memory is what gets truncated +// away — the exact inversion of which one matters. +function vv_ai_memory_learned_max(): int { + $vars = vv_conf_vars(); + $n = (int)($vars['AI_MEMORY_LEARNED_MAX_CHARS'] ?? 1200); + return max(0, min($n, vv_ai_memory_max())); +} + +// Which profiles a slot is given to. "*" means every profile. Narrowing it is how a General Chat +// question about bash syntax stops carrying this machine's PCIe topology. +function vv_ai_memory_applies(string $kind, string $profile): bool +{ + $vars = vv_conf_vars(); + $key = $kind === 'learned' ? 'AI_MEMORY_LEARNED_PROFILES' : 'AI_MEMORY_ASSISTED_PROFILES'; + $spec = trim($vars[$key] ?? '*'); + if ($spec === '' || $spec === '*') return true; + if ($profile === '') return true; + + foreach (explode(',', $spec) as $one) { + if (strcasecmp(trim($one), $profile) === 0) return true; + } + return false; } function vv_ai_memory_max(): int { @@ -620,9 +671,57 @@ function vv_ai_memory_max(): int { // Truncates rather than refusing: an over-long memory file should cost its own tail, not the // whole conversation, and the notice tells the model its knowledge is incomplete rather than // letting it assume it saw everything. -function vv_ai_memory_read(): array { - $p = vv_ai_memory_path(); +// Assembles what this profile actually gets, as two separately-labelled blocks so the caller can +// state a different precedence for each. Budget is applied to the pair: learned is capped by its +// own ceiling first, then trimmed against whatever the assisted file leaves — assisted is never +// shortened to make room for learned. +function vv_ai_memory_assemble(string $profile = ''): array +{ $max = vv_ai_memory_max(); + $out = ['assisted' => '', 'learned' => '', 'chars' => 0, + 'truncated' => false, 'files' => []]; + + $read = function (string $kind) use ($profile, &$out): string { + if (!vv_ai_memory_applies($kind, $profile)) return ''; + $p = vv_ai_memory_path($kind); + if (!file_exists($p)) return ''; + $t = trim((string)@file_get_contents($p)); + if ($t === '') return ''; + $out['files'][] = ['kind' => $kind, 'path' => $p, 'chars' => mb_strlen($t)]; + return $t; + }; + + $assisted = $read('assisted'); + $learned = $read('learned'); + + // The operator's file gets the budget first, in full. If it alone exceeds the cap that is a + // problem to report, not one to solve by dropping the other slot silently. + if (mb_strlen($assisted) > $max) { + $assisted = mb_substr($assisted, 0, $max) . "\n\n[assisted memory truncated at {$max} characters]"; + $out['truncated'] = true; + $learned = ''; + } else { + $room = min(vv_ai_memory_learned_max(), $max - mb_strlen($assisted)); + if ($room <= 0) { + $learned = ''; + } elseif (mb_strlen($learned) > $room) { + // Cut on a line boundary — half a sentence asserted as fact is worse than one fewer. + $learned = mb_substr($learned, 0, $room); + $cut = mb_strrpos($learned, "\n"); + if ($cut !== false && $cut > 0) $learned = mb_substr($learned, 0, $cut); + $out['truncated'] = true; + } + } + + $out['assisted'] = $assisted; + $out['learned'] = $learned; + $out['chars'] = mb_strlen($assisted) + mb_strlen($learned); + return $out; +} + +function vv_ai_memory_read(string $kind = 'assisted'): array { + $p = vv_ai_memory_path($kind); + $max = $kind === 'learned' ? vv_ai_memory_learned_max() : vv_ai_memory_max(); if (!file_exists($p)) return ['text' => '', 'chars' => 0, 'truncated' => false, 'exists' => false]; @@ -641,13 +740,14 @@ function vv_ai_memory_read(): array { // Atomic, and refuses to exceed the cap. The cap is a context budget shared with retrieval and // reasoning on every turn, so it is enforced on write rather than silently trimmed on read. -function vv_ai_memory_write(string $text): array { - $p = vv_ai_memory_path(); - $max = vv_ai_memory_max(); +function vv_ai_memory_write(string $text, string $kind = 'assisted'): array { + $p = vv_ai_memory_path($kind); + $max = $kind === 'learned' ? vv_ai_memory_learned_max() : vv_ai_memory_max(); $len = mb_strlen($text); if ($len > $max) { - return ['ok' => false, 'error' => "Memory is {$len} characters; the limit is {$max}. " + $what = $kind === 'learned' ? 'Learned memory' : 'Memory'; + return ['ok' => false, 'error' => "{$what} is {$len} characters; the limit is {$max}. " . "It is included in every prompt, so it competes with retrieval for context."]; }