diff --git a/Plugin/unraid/include/ai_memory_learn.php b/Plugin/unraid/include/ai_memory_learn.php index e15ba9f..4e4cde6 100644 --- a/Plugin/unraid/include/ai_memory_learn.php +++ b/Plugin/unraid/include/ai_memory_learn.php @@ -191,6 +191,42 @@ function vv_ai_mem_append(string $text): array { 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 — HOST2 is offline — becomes actively wrong the day it +// stops being true, with nothing to expire it. +// +// Matched on the line this file wrote: "- ()". 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) { @@ -222,8 +258,18 @@ function vv_ai_mem_write_row(string $f, array $row): bool { // 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']; + // 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']; @@ -239,19 +285,43 @@ function vv_ai_mem_action(string $id, string $act): array { // 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')]; + + // 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') { + if ($act === 'accept' || $act === 'keep') { $r = vv_ai_mem_append((string)$d['text']); if (!$r['ok']) return $r; $d['state'] = 'accepted'; $d['accepted'] = time(); - } else { + 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)) { diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 5d10026..c1c5b39 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -176,6 +176,13 @@ if (is_dir('/var/log/varaverk')) { .vv-ai-src-tag.repair { color:#d8a15a; border-color:#4a3a1e; background:#1a1408; } .vv-ai-src-tag.memory { color:#7d9be8; border-color:#26324a; background:#0d1220; } +/* What was decided, in a word. A decided row is greyed, which says it is settled but not which + way — and "kept" is the one that matters, because it means this text is in every prompt now. */ +.vv-ai-mem-state { font-size:9px; letter-spacing:.06em; text-transform:uppercase; + border-radius:3px; padding:1px 6px; border:1px solid; flex-shrink:0; } +.vv-ai-mem-state.accepted { color:#6fcf97; border-color:#264a26; background:#0c1a0c; } +.vv-ai-mem-state.dismissed { color:#6a6a6a; border-color:#2a2a2a; background:#131313; } + /* Filter pills, loosely the shape the Monitor scripts card uses: a count you can click. All is an explicit pill rather than "click the active one again to clear" — deselecting to get everything back is a gesture you have to already know. A source with nothing in it still shows its pill, so @@ -714,7 +721,15 @@ vv_ai_chat_markup('vv-ai', [ return act === 'fix' ? 'Confirm write' : act === 'dismiss' ? 'Confirm — permanent' : null; } - return act === 'accept' ? 'Confirm keep' : null; + // Keeping is armed because it is the only route by which model-written text reaches a future + // prompt. Forgetting is armed because it drops the record that stops the same line being + // proposed again, and there is no undo for a provenance you no longer have. + // + // Retract and dismiss are not: both only ever remove something, and both are one click from + // being put back. + if (act === 'accept' || act === 'keep') return 'Confirm keep'; + if (act === 'forget') return 'Confirm forget'; + return null; } let fndArmTimer = null; @@ -875,22 +890,42 @@ vv_ai_chat_markup('vv-ai', [ function qMemoryHtml(row) { const r = row.raw; - const meta = [r.id, ago(r.created), r.profile || null, - row.closed ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · '); + const meta = [r.id, ago(r.created), r.profile || null].filter(Boolean).join(' · '); + // Said as a word rather than left to be read off the greying, because "kept" is the state + // that matters — it means this text is in every prompt right now. + const stateWord = r.state === 'accepted' ? (r.auto ? 'kept automatically' : 'kept') + : r.state === 'dismissed' ? 'dismissed' : ''; + const badge = row.closed + ? `${esc(stateWord)}` : ''; // The question is the provenance. A line read three weeks from now is judged by what was // being asked when the model decided it was worth keeping. const asked = r.asked ? `
asked: ${esc(r.asked)}
` : ''; // No severity class: a proposal is not a fault, and colouring it like one would make a card // of ordinary suggestions read as a card of problems. - const acts = row.closed ? '' : - `` - + ``; + // + // A decision is not final. An accepted line joins every future prompt and nothing expires it, + // so a fact that is true today — HOST2 is offline — needs a way back out on the day it stops + // being true. Retract is that way; Keep is the same door in the other direction. + const B = (act, cls, label, tip) => + ``; + + let acts; + if (!row.closed) { + acts = B('accept', '', 'Keep', 'Add this to learned memory') + + B('dismiss', ' ghost', 'Dismiss', 'Do not keep it, and do not propose it again'); + } else if (r.state === 'accepted') { + acts = B('retract', '', 'Retract', 'Take this back out of every future prompt') + + B('forget', ' ghost', 'Forget', 'Remove it and stop tracking it — it may be proposed again'); + } else { + acts = B('keep', '', 'Keep', 'Changed your mind — add it to learned memory after all') + + B('forget', ' ghost', 'Forget', 'Stop tracking it — it may be proposed again'); + } return `
memory + ${badge} ${esc(meta)}
${esc(r.text)}
@@ -973,6 +1008,12 @@ vv_ai_chat_markup('vv-ai', [ return 'Stays closed even when it is seen again. ' + 'Use “I know” to be told if it changes. Click again to confirm.'; } + if (act === 'forget') { + return 'Drops the record, so nothing stops this being proposed again — dismissed rows are ' + + 'what suppress a repeat.' + + (row.raw.state === 'accepted' ? ' It is removed from memory first.' : '') + + ' Click again to confirm.'; + } return 'This text joins every future prompt. Click again to confirm.'; }