Files
Varaverk/Plugin/unraid/include/ai_memory_learn.php
T
Gmer4Lfe 0447fa6f86 Remove point-in-time HOST2 status from the codebase
Comments asserting a partner is down, being rebuilt or not yet installed are true on the day they are written and wrong afterwards, and nothing prompts anyone to revisit them. Behaviour is described generically; current state belongs in operator memory, not in tracked files.
2026-08-17 15:20:25 -04:00

336 lines
17 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// ai_memory_learn.php — proposed memory: the assistant suggests, the operator keeps
// ═══════════════════════════════════════════════════════════════════════════════════════════════
//
// PURPOSE
// Lets the assistant offer facts it thinks are worth remembering, without letting it write to
// the prompt on its own. A proposal is filed; the operator accepts or dismisses it; accepted
// text lands in the learned memory slot, which the prompt explicitly ranks BELOW retrieval.
//
// WHY IT IS A PROPOSAL AND NOT A WRITE
// Memory is injected into every future prompt. A model that writes its own memory writes its
// own mistakes, and then reads them back as established fact — growing more confident on each
// turn while the actual source code says otherwise. The cost of a bad proposal has to be one
// dismissal, not a permanently poisoned prompt. This is the same two-gate shape the repair
// system uses, for the same reason: whether something should be remembered is intent, and a
// model cannot prove intent.
//
// WHY DEDUP IS NOT THE MODEL'S JOB
// "Do I already know this" is a semantic comparison, and a 14B at IQ4_XS is confidently wrong
// at it often enough to matter — with every miss costing budget permanently. So dedup here is
// deterministic: normalise, then reject on exact match or containment in either direction
// against assisted memory, learned memory, and everything previously dismissed. It will let
// through a reworded duplicate; it will never silently drop something new, and that is the
// right way round for a store the operator reviews anyway.
//
// GATES
// AI_MEMORY_LEARN_ENABLED false — nothing is proposed, and the prompt gains nothing
// AI_MEMORY_LEARN_AUTO_ACCEPT false — accepted writes happen only when the operator says so
// The second cannot outrank the first: auto-accept with proposing off does nothing at all.
//
// STORE
// data/ai/mem_proposals/<id>.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;
// Where a setting lives in the UI is documented, and documentation does not belong in memory —
// memory is for what the documents cannot tell you. The UI map made this a real problem rather
// than a theoretical one: it describes 447 settings, so without this the assistant will propose
// "X is on the Settings tab" over and over, each one costing a dismissal.
//
// Gated on WHICH document the candidate is nearest to, not on how near. A plain score threshold
// was tried first and does not separate these: a genuine fact about rsync scores 0.78 against
// rsync.sh because it is about rsync, higher than the junk proposal that started this at 0.75.
// Similarity measures topic, not novelty. But the junk lands on the UI map and nothing worth
// remembering ever has — that is a difference in kind, and kind is what the index records.
const VV_AI_MEM_UI_NEAR = 0.62;
function vv_ai_mem_is_ui_fact(string $text): bool {
// Retrieval needs the index and the embedder. If either is unavailable the answer is "not
// provably documented", which files the proposal — the operator can still dismiss it, and
// failing closed here would silently stop learning whenever Ollama was restarting.
$r = @vv_ai_retrieve($text, '', '', 1);
if (empty($r['ok']) || empty($r['results'])) return false;
$top = $r['results'][0];
return ($top['kind'] ?? '') === 'ui' && (float) ($top['score'] ?? 0) >= VV_AI_MEM_UI_NEAR;
}
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'];
if (vv_ai_mem_is_ui_fact($text)) return ['ok' => false, 'error' => 'already in the UI map'];
$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'];
}
if (!vv_ai_mem_write_row(vv_ai_mem_dir() . "/$id.json", $row)) {
return ['ok' => false, 'error' => 'could not file the proposal'];
}
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');
}
// Takes one line back out of the learned slot. The counterpart to vv_ai_mem_append(), and the
// reason a decided proposal can be changed at all: without this, "accepted" was a one-way door
// and the only way out was editing mem_learned.md by hand. An accepted line joins every future
// prompt, and a fact that is true today — a host is unreachable — becomes actively wrong the day it
// stops being true, with nothing to expire it.
//
// Matched on the line this file wrote: "- <text> (<date>)". A line the operator has since edited
// by hand will not match, and that is reported rather than swallowed — silently succeeding while
// the text stays in every prompt is the one outcome worse than failing.
function vv_ai_mem_remove(string $text): array {
$p = vv_ai_memory_path('learned');
if (!file_exists($p)) return ['ok' => false, 'error' => 'learned memory file does not exist'];
$cur = (string) @file_get_contents($p);
$lines = preg_split('/\R/', $cur);
$want = vv_ai_mem_norm($text);
$out = [];
$hit = false;
foreach ($lines as $line) {
// Compare the line's own text, stripped of the bullet and the trailing date stamp, so a
// change in date format does not strand the entry.
$bare = preg_replace('/^\s*-\s*/', '', $line);
$bare = preg_replace('/\s*\(\d{4}-\d{2}-\d{2}\)\s*$/', '', $bare);
if (!$hit && $bare !== '' && vv_ai_mem_norm($bare) === $want) { $hit = true; continue; }
$out[] = $line;
}
if (!$hit) return ['ok' => false, 'error' => 'that line is not in learned memory — it may have been edited by hand'];
$next = rtrim(implode("\n", $out)) . "\n";
// Only the header left means nothing is being remembered; write it back empty rather than
// leaving a file that looks populated.
if (trim(preg_replace('/^#.*$/m', '', $next)) === '') $next = '';
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;
}
// Temp and rename, so a concurrent list() never decodes half a record — a torn read here is
// indistinguishable from a corrupt proposal and the row silently vanishes from the card.
function vv_ai_mem_write_row(string $f, array $row): bool {
$tmp = $f . '.tmp';
if (@file_put_contents($tmp, json_encode($row, JSON_PRETTY_PRINT)) === false) {
@unlink($tmp);
return false;
}
if (!@rename($tmp, $f)) { @unlink($tmp); return false; }
return true;
}
// One lock for every decision, not one per proposal. Accept is a read-modify-write across two
// files — this proposal and the learned slot — and two of them interleaving is how the same line
// lands in memory twice, or how one of two accepted lines is lost to a temp-and-rename that began
// before the other finished. The card arms and disables the button, so this only has to hold
// against a second tab, a double submit or an impatient reload; that is exactly when it matters,
// because none of those are visible from the one that is about to lose.
function vv_ai_mem_action(string $id, string $act): array {
// open → accept | dismiss the original decision
// accepted → retract takes the line back out of every future prompt
// dismissed → keep changes your mind, puts it back
// decided → forget drops the record entirely
//
// A decision used to be final, which made "accepted" a one-way door into the prompt. The
// transitions below are the way back out; each is checked against the state it is legal from,
// so a stale tab cannot retract something that was already forgotten.
$legal = ['accept' => 'open', 'dismiss' => 'open',
'retract' => 'accepted', 'keep' => 'dismissed', 'forget' => '*'];
if (!preg_match('/^[0-9a-f]{12}$/', $id)) return ['ok' => false, 'error' => 'bad id'];
if (!isset($legal[$act])) return ['ok' => false, 'error' => 'unknown action'];
$f = vv_ai_mem_dir() . "/$id.json";
if (!file_exists($f)) return ['ok' => false, 'error' => 'no such proposal'];
$lock = @fopen(vv_ai_mem_dir() . '/.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
if ($lock !== false) fclose($lock);
return ['ok' => false, 'error' => 'could not lock the proposal store'];
}
try {
// Re-read under the lock. Whatever the card was showing when it was clicked is not
// evidence of anything — the decision may already have been made in another tab.
$d = json_decode((string)@file_get_contents($f), true);
if (!is_array($d)) return ['ok' => false, 'error' => 'unreadable proposal'];
// A tab left open overnight must not act on a choice the store has already moved past.
$state = (string) ($d['state'] ?? '');
if ($legal[$act] !== '*' && $state !== $legal[$act]) {
return ['ok' => false, 'error' => 'this is ' . ($state ?: 'in no state')
. ', so it cannot be ' . $act . 'ed'];
}
if ($act === 'accept' || $act === 'keep') {
$r = vv_ai_mem_append((string)$d['text']);
if (!$r['ok']) return $r;
$d['state'] = 'accepted';
$d['accepted'] = time();
unset($d['closed']);
} elseif ($act === 'dismiss') {
$d['state'] = 'dismissed';
$d['closed'] = time();
} elseif ($act === 'retract') {
// The line leaves memory first. If that fails the proposal keeps saying "accepted",
// which is the truth — the text is still in every prompt.
$r = vv_ai_mem_remove((string)$d['text']);
if (!$r['ok']) return $r;
$d['state'] = 'dismissed';
$d['closed'] = time();
unset($d['accepted']);
} elseif ($act === 'forget') {
// Retract first when it is live, or the record vanishes while its text stays in the
// prompt with nothing left pointing at it.
if ($state === 'accepted') {
$r = vv_ai_mem_remove((string)$d['text']);
if (!$r['ok']) return $r;
}
// Dropping the record also drops what stops it being proposed again — dedup checks
// every past proposal including dismissed ones. That is the point of forgetting
// rather than dismissing, and it is why the button says so.
if (!@unlink($f)) return ['ok' => false, 'error' => 'could not remove the proposal'];
return ['ok' => true, 'state' => 'forgotten'];
}
if (!vv_ai_mem_write_row($f, $d)) {
return ['ok' => false, 'error' => 'could not write the proposal'];
}
return ['ok' => true, 'state' => $d['state']];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}