Files
Varaverk/Plugin/unraid/include/ai_memory_learn.php
T
Gmer4Lfe e3ec54f213 Let each control actually govern the thing it names
Auto Scroll, the learning gate, the fold and the flash label each described a
behaviour they did not enforce.
2026-08-11 17:25:51 -04:00

243 lines
12 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;
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'];
}
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');
}
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 {
if (!preg_match('/^[0-9a-f]{12}$/', $id)) return ['ok' => false, 'error' => 'bad id'];
if ($act !== 'accept' && $act !== 'dismiss') 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'];
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();
} else {
$d['state'] = 'dismissed';
$d['closed'] = time();
}
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);
}
}