diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php
index f391bdf..863917c 100644
--- a/Plugin/unraid/pages/ai.php
+++ b/Plugin/unraid/pages/ai.php
@@ -253,6 +253,22 @@ if (is_dir('/var/log/varaverk')) {
+
+
+
+
+ Proposed memory
+
+
+
+
+
+
+
@@ -774,6 +790,113 @@ vv_ai_chat_markup('vv-ai', [
loadFindings();
});
+ // ── Proposed memory ───────────────────────────────────────────────────────
+ // Accepting is the only route by which text the model wrote reaches a future prompt, so it is
+ // the one control here that has to be deliberate. Same two-press arming the findings card uses
+ // for a conf write, for the same reason.
+ let memAll = false, memRows = [];
+
+ function loadMemProposals() {
+ fetch(API + '?action=mem_proposals').then(r => r.json())
+ .then(d => { if (d.ok) renderMemProposals(d); })
+ .catch(() => {});
+ }
+
+ function renderMemProposals(d) {
+ memRows = memAll ? (d.recent || []) : (d.open || []);
+ const L = d.learned || {};
+
+ // The gate is the news when the list is empty. "Nothing proposed" and "nothing is proposing"
+ // look identical otherwise, and they are opposite states.
+ const gate = [];
+ gate.push(d.enabled ? (d.auto ? 'learning on · auto-accept on'
+ : 'learning on · proposals held for you')
+ : 'learning off — nothing is proposing');
+ if (L.max) gate.push('learned ' + (L.chars || 0) + '/' + L.max + ' chars');
+ $('vv-ai-mem-gate').innerHTML = (d.enabled && !d.auto)
+ ? esc(gate.join(' · '))
+ : `${esc(gate.join(' · '))}`;
+
+ const openN = (d.open || []).length;
+ $('vv-ai-mem-sum').innerHTML = openN
+ ? `${openN} to review`
+ : `✓ none waiting`;
+
+ if (!memRows.length) {
+ $('vv-ai-mem-list').innerHTML = memAll
+ ? '
nothing proposed yet
'
+ : '
nothing waiting on you
';
+ return;
+ }
+
+ $('vv-ai-mem-list').innerHTML = memRows.map(r => {
+ const decided = r.state !== 'open';
+ const meta = [r.id, ago(r.created), r.profile || null,
+ decided ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · ');
+ // 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 ? `
`;
+ }).join('');
+ }
+
+ $('vv-ai-mem-list').addEventListener('click', e => {
+ const btn = e.target.closest('[data-mem-act]');
+ if (!btn) return;
+
+ const act = btn.dataset.memAct, id = btn.dataset.memId;
+ const msg = btn.parentNode.querySelector('[data-msg]');
+
+ // Keep arms first. Dismiss does not: it writes nothing to any prompt and is undone by the
+ // model proposing something similar again, so a confirm there would be ceremony.
+ if (act === 'accept' && btn.dataset.armed !== '1') {
+ btn.dataset.armed = '1';
+ btn.dataset.label = btn.dataset.label || btn.textContent;
+ btn.textContent = 'Confirm keep';
+ btn.classList.add('armed');
+ if (msg) msg.textContent = 'this text joins every future prompt';
+ return;
+ }
+
+ const btns = Array.from(btn.parentNode.querySelectorAll('button'));
+ btns.forEach(b => b.disabled = true);
+ if (msg) msg.textContent = 'working…';
+
+ // Reload on success only — same rule as findings. A refused keep (learned memory full) is
+ // the case that must not vanish, and a reload would repaint the row and take the reason away.
+ const failed = text => {
+ btn.dataset.armed = '';
+ if (btn.dataset.label) btn.textContent = btn.dataset.label;
+ btn.classList.remove('armed');
+ btns.forEach(b => b.disabled = false);
+ if (msg) msg.innerHTML = `${esc(text)}`;
+ };
+
+ fetch(API, { method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
+ body: new URLSearchParams({ action: 'mem_proposal_action', id, act }) })
+ .then(r => r.json())
+ .then(d => { if (d.ok) loadMemProposals(); else failed(d.error || 'failed'); })
+ .catch(e => failed(String(e)));
+ });
+
+ $('vv-ai-mem-all').addEventListener('click', () => {
+ memAll = !memAll;
+ $('vv-ai-mem-all').textContent = memAll ? 'Waiting only' : 'Show decided';
+ loadMemProposals();
+ });
+
// ── Token accounting ────────────────────────────────────────────────────
// Fetched on load and after each completed turn, never on the 30s banner tick: the totals
// only move when a turn finishes, and the page is the thing that knows when that was.
@@ -969,6 +1092,7 @@ vv_ai_chat_markup('vv-ai', [
loadTokens();
loadBugs();
loadFindings();
+ loadMemProposals();
chatList.reload();
})();