Let the assistant propose what to remember, and the operator decide what is kept
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: <one short sentence>\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)' : ''),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?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'];
|
||||
}
|
||||
|
||||
@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']];
|
||||
}
|
||||
Reference in New Issue
Block a user