Skip to content

Commit 781a78e

Browse files
ndemiancclaude
andcommitted
feat(levelcode): cross-session memory M3 — the Project memory panel
The transparency + control surface the design calls non-negotiable: a Memory tab beside History in the Sessions sidebar. It lists what the project remembers — the recorded outcomes with their files and date — and lets you curate it: Edit to correct a wrong memory, Forget to drop a session's contribution (the session itself stays in History), and "open file ↗" to read/edit MEMORY.md directly. Correcting or forgetting immediately updates the digest the agent sees, so it stops repeating a wrong memory. - sessionMemory.js: latestBySession now honors a `forgotten` tombstone — a forgotten session leaves the digest, recall, and panel while the append-only journal keeps the full history (a hand-edit can restore it). - sessions.js: forget(id) (append a tombstone + re-consolidate) and memoryItems() (all current outcomes, newest-first); Edit reuses refineSummary. - extension.js: the sidebar provider serves listMemory and routes memoryAction (resume / edit via an input box / forget) through handleMemoryAction, then refreshes the panel + the welcome-back strip. - media/sessionsView.html: a History/Memory tab bar with two panes; memory cards reuse the line-2 hover-swap (meta ⇄ Edit/Forget), Forget tinted danger. - tests: +1 sessionMemory (forgotten tombstone), +1 sessions (forget drops memory, keeps History), +1 sessionsUi (panel wiring). 31 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e09f2a7 commit 781a78e

7 files changed

Lines changed: 141 additions & 5 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,27 @@ function recallSessionsTool(query) {
797797
catch (e) { return 'Recall failed.'; }
798798
}
799799

800+
// A memory-panel action (§M3): resume the source session, EDIT its recorded outcome (correct a wrong
801+
// memory), or FORGET it (drop its contribution — the session itself stays in History). Refreshes the panel
802+
// list + the welcome-back strip. Best-effort.
803+
async function handleMemoryAction(action, id, webview) {
804+
const m = sessionsManager();
805+
if (!m || !id) { return; }
806+
dbg('sessions.memoryAction', { action, id });
807+
try {
808+
if (action === 'resume') { await resumeSession(id); return; }
809+
if (action === 'forget') { m.forget(id); }
810+
else if (action === 'edit') {
811+
const cur = (m.memoryItems().find((e) => e.id === id) || {}).summary || '';
812+
const text = await vscode.window.showInputBox({ prompt: 'Edit what this session is remembered for', value: cur, validateInput: (v) => (v && v.trim() ? null : 'Enter a one-line outcome') });
813+
if (text == null || !text.trim()) { return; }
814+
m.refineSummary(id, text);
815+
} else { return; }
816+
if (webview) { try { webview.postMessage({ type: 'memoryList', items: m.memoryItems() }); } catch (e) { /* view closed */ } }
817+
postMemoryDigest();
818+
} catch (e) { dbg('sessions.memoryAction.error', { action, id, msg: String((e && e.message) || e) }); }
819+
}
820+
800821
// A card action from either surface. resume reopens the session; done/delete/rename/pin are append-only edits
801822
// (§4.9): Done archives (reversible, never deletes), Delete soft-trashes, Rename retitles, Pin toggles.
802823
// Done/Delete offer an Undo (restore) so an accidental click is recoverable. Best-effort — a History action
@@ -2025,6 +2046,10 @@ class SessionsViewProvider {
20252046
case 'listSessions': view.webview.postMessage({ type: 'sessions', entries: sessionList() }); break;
20262047
case 'newSession': newChat(); vscode.commands.executeCommand('levelcodeAi.chat.focus'); break;
20272048
case 'sessionAction': await handleSessionAction(msg.action, msg.id); break;
2049+
// Memory tab (§M3): list the recorded outcomes; act on them (resume / edit / forget); open the file.
2050+
case 'listMemory': { const mm = sessionsManager(); view.webview.postMessage({ type: 'memoryList', items: mm ? mm.memoryItems() : [] }); break; }
2051+
case 'memoryAction': await handleMemoryAction(msg.action, msg.id, view.webview); break;
2052+
case 'openMemory': await openMemory(); break;
20282053
}
20292054
});
20302055
}

‎extensions/levelcode-ai/media/sessionsView.html‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@
2424
.sessundo .ut { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2525
.sessundo .ub { flex: 0 0 auto; background: transparent; border: none; color: var(--cc-accent); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
2626
.sessundo .ub:hover { text-decoration: underline; }
27+
/* Memory tab (§M3): a second surface beside History for the recorded outcomes — edit or forget. */
28+
.svtabs { display: flex; gap: 2px; padding: 6px 10px 0; flex: 0 0 auto; }
29+
.svtab { flex: 1; background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--cc-text3); font: inherit; font-size: 12px; padding: 6px 4px 7px; cursor: pointer; }
30+
.svtab:hover { color: var(--cc-text); }
31+
.svtab.on { color: var(--cc-text); border-bottom-color: var(--cc-accent); }
32+
.svpane { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
33+
.svpane[hidden] { display: none; }
34+
.svmemhead { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 10px 12px 4px; font-size: 11px; color: var(--cc-text3); }
35+
.svmemopen { background: transparent; border: none; color: var(--cc-accent); font: inherit; font-size: 11px; cursor: pointer; padding: 0; white-space: nowrap; }
36+
.svmemopen:hover { text-decoration: underline; }
37+
.memcard { position: relative; padding: 8px 8px 9px; margin-bottom: 1px; border-radius: 6px; cursor: pointer; }
38+
.memcard:hover { background: color-mix(in srgb, var(--cc-text3) 13%, transparent); }
39+
.memcard:focus-visible { background: color-mix(in srgb, var(--cc-accent) 15%, transparent); outline: none; }
40+
.memcard .memtext { font-size: 13px; line-height: 1.45; color: var(--cc-text); }
41+
.memcard .memline2 { display: flex; align-items: center; min-height: 22px; margin-top: 3px; }
42+
.memcard .memmeta { flex: 1 1 auto; min-width: 0; font-size: 11.5px; color: var(--cc-text3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
43+
.memcard .memacts { display: none; align-items: center; gap: 4px; flex: 0 0 auto; }
44+
.memcard:hover .memmeta, .memcard:focus-within .memmeta { display: none; }
45+
.memcard:hover .memacts, .memcard:focus-within .memacts { display: flex; }
46+
.memcard .memact { background: transparent; border: none; color: var(--cc-text2); border-radius: 4px; padding: 2px 8px; font: inherit; font-size: 11px; cursor: pointer; white-space: nowrap; }
47+
.memcard .memact:hover { background: color-mix(in srgb, var(--cc-text3) 20%, transparent); color: var(--cc-text); }
48+
.memcard .memact[data-act="forget"]:hover { background: color-mix(in srgb, var(--cc-danger) 18%, transparent); color: var(--cc-danger); }
2749
.sesslist { flex: 1 1 auto; overflow-y: auto; padding: 0 10px 12px; }
2850
.sessbucket { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--cc-text3); margin: 12px 4px 7px; }
2951
.sessbucket .cnt { letter-spacing: 0; }
@@ -56,8 +78,18 @@
5678
</head>
5779
<body>
5880
<div class="sessview">
59-
<button id="svNew" class="sessnew" type="button">New Session</button>
60-
<div class="sesslist" id="svList"></div>
81+
<div class="svtabs">
82+
<button class="svtab on" data-tab="history" type="button">History</button>
83+
<button class="svtab" data-tab="memory" type="button">Memory</button>
84+
</div>
85+
<div class="svpane" id="paneHistory">
86+
<button id="svNew" class="sessnew" type="button">New Session</button>
87+
<div class="sesslist" id="svList"></div>
88+
</div>
89+
<div class="svpane" id="paneMemory" hidden>
90+
<div class="svmemhead"><span>What this project remembers</span><button class="svmemopen" id="svMemOpen" type="button">open file &#8599;</button></div>
91+
<div class="sesslist" id="svMemList"></div>
92+
</div>
6193
<div class="sessundo" id="svUndo" hidden></div>
6294
</div>
6395
<script nonce="__NONCE__">
@@ -154,6 +186,38 @@
154186
const id = card.getAttribute('data-id'); const btn = e.target.closest('[data-act]');
155187
vscode.postMessage({ type: 'sessionAction', action: btn ? btn.getAttribute('data-act') : 'resume', id: id });
156188
});
189+
// ── Memory tab (§M3): the recorded outcomes, each with Edit / Forget; click the card to resume it ──
190+
function memCardHtml(e){
191+
const files = Array.isArray(e.files) ? e.files : [];
192+
const when = e.at ? new Date(e.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
193+
const meta = (files.length ? esc(files[0]) + (files.length > 1 ? ' +' + (files.length - 1) : '') + ' · ' : '') + esc(when);
194+
return '<div class="memcard" data-id="' + escAttr(e.id || '') + '" tabindex="0" role="button" title="' + escAttr(e.summary || '') + '">'
195+
+ '<div class="memtext">' + esc(e.summary || e.title || 'a session') + '</div>'
196+
+ '<div class="memline2"><span class="memmeta">' + meta + '</span>'
197+
+ '<span class="memacts"><button type="button" class="memact" data-act="edit" title="Edit this memory">Edit</button>'
198+
+ '<button type="button" class="memact" data-act="forget" title="Forget this session">Forget</button></span></div>'
199+
+ '</div>';
200+
}
201+
function renderMemory(items){
202+
const list = document.getElementById('svMemList'); if (!list) { return; }
203+
const arr = Array.isArray(items) ? items : [];
204+
if (!arr.length){ list.innerHTML = '<div class="sessempty">Nothing remembered yet. As sessions finish, what they achieved appears here — plain text you can edit or forget.</div>'; return; }
205+
list.innerHTML = arr.map(memCardHtml).join('');
206+
}
207+
document.getElementById('svMemList').addEventListener('click', (e) => {
208+
const card = e.target.closest && e.target.closest('.memcard'); if (!card) { return; }
209+
const id = card.getAttribute('data-id'); const btn = e.target.closest('[data-act]');
210+
vscode.postMessage({ type: 'memoryAction', action: btn ? btn.getAttribute('data-act') : 'resume', id: id });
211+
});
212+
document.getElementById('svMemOpen').addEventListener('click', () => vscode.postMessage({ type: 'openMemory' }));
213+
function showTab(tab){
214+
const hist = tab === 'history';
215+
document.getElementById('paneHistory').hidden = !hist;
216+
document.getElementById('paneMemory').hidden = hist;
217+
document.querySelectorAll('.svtab').forEach((b) => b.classList.toggle('on', b.getAttribute('data-tab') === tab));
218+
if (!hist) { vscode.postMessage({ type: 'listMemory' }); }
219+
}
220+
document.querySelectorAll('.svtab').forEach((b) => b.addEventListener('click', () => showTab(b.getAttribute('data-tab'))));
157221
// The Undo toast for Done/Delete — a quick, recoverable safety net. Auto-hides after a few seconds.
158222
let sessUndoTimer = null;
159223
function showUndo(m){
@@ -168,7 +232,7 @@
168232
vscode.postMessage({ type: 'sessionAction', action: 'restore', id: b.getAttribute('data-id') });
169233
document.getElementById('svUndo').hidden = true; clearTimeout(sessUndoTimer);
170234
});
171-
window.addEventListener('message', (ev) => { const m = ev.data; if (m && m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; renderSessions(); } else if (m && m.type === 'sessionUndo'){ showUndo(m); } });
235+
window.addEventListener('message', (ev) => { const m = ev.data; if (m && m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; renderSessions(); } else if (m && m.type === 'memoryList'){ renderMemory(m.items); } else if (m && m.type === 'sessionUndo'){ showUndo(m); } });
172236
renderSessions();
173237
vscode.postMessage({ type: 'listSessions' });
174238
</script>

‎extensions/levelcode-ai/sessionMemory.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ function latestBySession(entries) {
9393
if (!e || e.id == null) { continue; }
9494
byId.set(String(e.id), e); // later lines overwrite earlier → latest wins
9595
}
96-
const out = [...byId.values()];
96+
// A `forgotten` tombstone (the newest line for an id) drops it from all memory — digest, recall, panel —
97+
// while the append-only journal.jsonl keeps the full history (a hand-edit can bring it back).
98+
const out = [...byId.values()].filter((e) => !e.forgotten);
9799
out.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
98100
return out;
99101
}

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,17 @@ function createSessions(opts) {
170170
try { return memory.recallRank(memory.latestBySession(memory.readJournal(root, slug)), query, opts); }
171171
catch (e) { return []; }
172172
}
173+
/** All current memory outcomes (one per session, newest-first) — what the memory panel lists. */
174+
function memoryItems() { try { return memory.latestBySession(memory.readJournal(root, slug)); } catch (e) { return []; } }
175+
/**
176+
* Forget a session's memory contribution: a tombstone that drops it from the digest/recall/panel while
177+
* the session itself stays in History. Append-only + re-consolidate MEMORY.md. Best-effort.
178+
*/
179+
function forget(id) {
180+
if (!id) { return false; }
181+
try { memory.appendJournal(root, slug, { v: memory.SCHEMA_V, id: String(id), at: iso(), forgotten: true }); consolidate(); return true; }
182+
catch (e) { return false; }
183+
}
173184
/** The deterministic consolidation pass: rewrite MEMORY.md from the current journal. Returns the digest. */
174185
function consolidate(opts) {
175186
const o = Object.assign({ nowMs: clock().getTime() }, opts);
@@ -207,7 +218,7 @@ function createSessions(opts) {
207218

208219
function liveId() { return live ? live.id : null; }
209220

210-
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, memoryPaths, list, liveId };
221+
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, memoryItems, forget, memoryPaths, list, liveId };
211222
}
212223

213224
module.exports = { createSessions };

‎extensions/levelcode-ai/test/sessionMemory.test.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ test('LATEST: one outcome per session — the latest supersedes, newest-first',
7070
assert.strictEqual(latest.find((e) => e.id === 'a').title, 'a-new', 'the later line supersedes the earlier');
7171
});
7272

73+
test('LATEST: a forgotten tombstone drops the session from memory', () => {
74+
const entries = [
75+
{ id: 'a', at: '2026-01-01', summary: 'alpha' },
76+
{ id: 'b', at: '2026-02-01', summary: 'beta' },
77+
{ id: 'a', at: '2026-03-01', forgotten: true } // a is forgotten (its newest line)
78+
];
79+
assert.deepStrictEqual(M.latestBySession(entries).map((e) => e.id), ['b'], 'forgotten a is gone; b remains');
80+
});
81+
7382
test('RECALL: ranks journal entries by term matches in summary/title/files, best-first', () => {
7483
const j = [
7584
{ id: 'a', at: '2026-07-01', summary: 'Made refunds idempotent with a Redis lock', title: 'refund retries', files: ['refund.rb'] },

‎extensions/levelcode-ai/test/sessions.test.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,17 @@ test('MEMORY: recall finds a past session by a query over its outcome + files',
274274
assert.deepStrictEqual(m.recall('zzz-nomatch'), [], 'no match → empty');
275275
});
276276

277+
test('MEMORY: forget drops a session from memory (digest/recall/panel) but keeps it in History', () => {
278+
const root = freshRoot(), slug = store.projectSlug('/pfg');
279+
const m = createSessions({ root, slug, projectPath: '/pfg' });
280+
m.recordTurn(turn('Add idempotency to refunds', 'refund.rb'), 'm'); m.seal('done');
281+
assert.strictEqual(m.memoryItems().length, 1, 'the outcome is in memory');
282+
const id = m.memoryItems()[0].id;
283+
assert.ok(m.forget(id));
284+
assert.deepStrictEqual(m.memoryItems(), [], 'forgotten → gone from the panel');
285+
assert.deepStrictEqual(m.digest().recently, [], 'and from the welcome-back digest');
286+
assert.deepStrictEqual(m.recall('refund'), [], 'and from recall');
287+
assert.strictEqual(m.list().length, 1, 'but the session itself stays in History (resumable)');
288+
});
289+
277290
console.log('sessions: ' + n + ' tests passed');

‎extensions/levelcode-ai/test/sessionsUi.test.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,18 @@ test('MEMORY (recall): a recall_sessions agent tool searches past-session outcom
185185
assert.ok((pkg.contributes.configuration.properties || {})['levelcode.ai.sessions.memory.recallTool'], 'the setting is contributed');
186186
});
187187

188+
test('MEMORY (panel M3): a Memory tab in the sidebar to view / edit / forget the recorded outcomes', () => {
189+
assert.match(view, /data-tab="memory"/, 'a Memory tab beside History');
190+
assert.match(view, /id="svMemList"/, 'a memory list container');
191+
assert.match(view, /function renderMemory/, 'renders the outcomes');
192+
assert.match(view, /type: 'memoryAction'/, 'posts edit/forget/resume');
193+
assert.match(view, /data-act="forget"/, 'a Forget control');
194+
assert.match(view, /type: 'listMemory'/, 'requests the list when the tab opens');
195+
assert.match(ext, /case 'listMemory'/, 'the provider serves the memory list');
196+
assert.match(ext, /async function handleMemoryAction/, 'and handles the actions');
197+
assert.match(ext, /m\.forget\(id\)/, 'forget drops the contribution (session stays in History)');
198+
});
199+
188200
test('VIEW: the sidebar view and the modal share the EXACT same render block (no drift)', () => {
189201
const grab = (s) => s.slice(s.indexOf('// [SESSIONS-PURE-START]'), s.indexOf('// [SESSIONS-PURE-END]'));
190202
assert.ok(grab(view).length > 500, 'the view carries the pure block');

0 commit comments

Comments
 (0)