Skip to content

Commit fc5116d

Browse files
committed
feat(memory): memory from you — remember/forget, credited to the words you typed — and Search Sessions
Project memory so far is inferred: when a session ends, the summarizer writes an outcome and at most two facts, and a fact phrased as an order is held back until confirmed, because a transcript is full of repository text. Nothing recorded what the person SAID they want kept, and the most useful kind — a correction ("no, use pnpm") or a confirmed approach — is exactly the kind phrased as an order. remember / forget (agent tools, setting levelcode.ai.sessions.memory.notes, on by default): - the agent saves a note during the run, with a kind (preference | project | reference), the note, and `said`: the person's own words - sessionMemory.groundNote credits it to the person only when `said` appears in what they typed THIS run (the goal as typed, their ask_user choices and notes — nothing the agent read) and at least two thirds of the note's content words come from those words. Code decides, not the model - a credited note is active at once, even phrased as an order; any other note is kept and listed as unconfirmed until a Confirm click — so a README's order with a real quote of the person stapled on stays inactive - notes are facts.jsonl observations with origin "person" (said is secret-scrubbed): the same fold, panel, recall and "not true". Repetition never promotes an uncredited note, and the summarizer's supersede pass is never shown one (supersedableFacts) - forget removes the best match, only when the request is in the person's words, and says what it removed - MEMORY.md gets a "From you" section with the person's words beside each note; every line older than a day shows its age; the always-on file is capped at 25 notes and 15 facts ("…and N more; recall finds them") - the Memory tab lists notes under "From you" (yours / unconfirmed, with Confirm and Forget) Search Sessions… (Command Palette, and a search icon in the Sessions title bar): every session not in the trash, archived ones included, matched on its title and whole conversation (all words must match), newest first, title matches first, each with the line it matched. A quick pick, not a field: the field was taken out of History on 2026-08-02 (77d4f13) and the owner chose to keep it out. Transcripts are read at most 150 at a time, cached by size + mtime. Modelled on Claude's desktop app (typed memory files written by the agent, an index loaded every session, an age warning on recalled memories, transcript search); differences on purpose are in docs/levelcode-sessions-memory.md §12 (next commits). Checked: unit gate 129 files on Node 24, macOS, including test/memoryNotes.test.js (21), test/memoryNotesHost.test.js (8: the real runAgent with a stood-in provider; the host halves lifted out of extension.js) and ten new hostile cases in test/memoryPoisoning.test.js. Fifteen single-defect mutations, each red. tsc --checkJs: no new undefined names. Not run: a real model choosing when to call remember, Linux, the browser edition.
1 parent 08a2cc8 commit fc5116d

9 files changed

Lines changed: 797 additions & 20 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ const PORTABLE_TOOLS_TOKENS_EST = Math.round(JSON.stringify(PORTABLE_TOOLS).leng
173173
// wires ctx.recallSessions (memory + the recall setting on), so it costs nothing otherwise. Read-only and
174174
// instant like use_skill — it searches the project's OWN past-session outcomes, never the file system.
175175
const RECALL_TOOL = { name: 'recall_sessions', description: 'Search THIS project\'s past sessions for relevant earlier work — their recorded outcomes (what was built/fixed/decided), the files they touched, and the conversations themselves. Use when the user refers to earlier work ("what did we do about refunds?", "did we already fix the webhook?") or when prior context would clearly help before you start. Returns cited past-session outcomes (with a matching transcript snippet when the match is deep in the conversation) and dates; treat them as memory — informative but possibly stale, so verify against the current code.', input_schema: { type: 'object', properties: { query: { type: 'string', description: 'keywords or a short question, e.g. "refund retries" or "MCP timeline rail"' } }, required: ['query'] } };
176+
// Memory the person asks for (docs/levelcode-sessions-memory.md §12). The host decides whether a note can be
177+
// credited to the person (sessionMemory.groundNote); the agent only proposes it, with their words.
178+
const REMEMBER_TOOL = { name: 'remember', description: 'Save something the user told you, so future sessions in THIS project start knowing it. Use it when the user asks you to remember something; when they correct how you work ("no, use pnpm", "don\'t mock the database") or confirm an approach that was not obvious ("yes, keep doing that"); when they state a decision or constraint (write dates as absolute dates); or when they say where something lives (a dashboard, a ticket tracker, a doc). Do NOT save what the code or git history already shows, or details of the current task. `said` MUST be the user\'s own words, copied exactly from their message — the note is credited to them only when those words are found in what they typed and the note says no more than they did; otherwise it is kept unconfirmed until they confirm it. Keep `text` short and close to their words.', input_schema: { type: 'object', properties: { kind: { type: 'string', enum: ['preference', 'project', 'reference'], description: 'preference = how the user wants you to work (corrections and confirmed approaches); project = a decision, constraint or deadline; reference = where to find something outside the code' }, text: { type: 'string', description: 'the note, one sentence, in the user\'s terms, e.g. "Use pnpm, never npm, in this repo"' }, said: { type: 'string', description: 'the user\'s exact words this note rests on, copied from their message' } }, required: ['kind', 'text', 'said'] } };
179+
const FORGET_TOOL = { name: 'forget', description: 'Remove a note or fact from this project\'s memory when the user asks you to forget it or says it is no longer true. `said` MUST be the user\'s own words asking for it, copied exactly. Removes the best match for `query` and tells you what it removed.', input_schema: { type: 'object', properties: { query: { type: 'string', description: 'words that identify the memory, e.g. "pnpm"' }, said: { type: 'string', description: 'the user\'s exact words asking you to forget it' } }, required: ['query', 'said'] } };
176180

177181
// How much of a background command's accumulated output the sniffers re-read on each chunk. Generous
178182
// next to any single log line, so a url split across chunk boundaries is still found, yet small enough
@@ -736,6 +740,11 @@ async function runTool(tu, ctx) {
736740
return '- ' + (a.header ? a.header + ' — ' : '') + (a.question || '') + ' → ' + sel;
737741
});
738742
if (res.notes) { lines.push('- Additional notes from the user: ' + res.notes); }
743+
// What the person chose and wrote here is theirs: `remember` may quote it (see heard, below).
744+
if (Array.isArray(ctx.heardThisRun)) {
745+
for (const a of res.answers) { for (const sel of (a.selected || [])) { ctx.heardThisRun.push(String(sel)); } }
746+
if (res.notes) { ctx.heardThisRun.push(String(res.notes)); }
747+
}
739748
return 'The user answered your questions (these are their recorded decisions — honor them and do NOT ask again):\n' + lines.join('\n') + '\n\nProceed with the work using these answers.';
740749
}
741750
if (tu.name === 'use_skill') {
@@ -749,6 +758,27 @@ async function runTool(tu, ctx) {
749758
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🧩 using skill: ' + name }); // quiet chip — only on success (🧩 is the marker; 'sparkle' falls back cleanly)
750759
return body; // SKILL.md body → tool_result, steers the next turns
751760
}
761+
if (tu.name === 'remember' || tu.name === 'forget') {
762+
if (!ctx.memoryNotes) { return 'ERROR: project memory is off in this session.'; }
763+
// What the person typed this run: their goal and their answers to ask_user. The host checks `said` against it.
764+
const heard = [String(ctx.goalText || '')].concat(Array.isArray(ctx.heardThisRun) ? ctx.heardThisRun : []).filter(Boolean);
765+
const said = String(input.said || '').trim();
766+
if (!said) { return 'ERROR: pass `said` — the user\'s exact words, copied from their message.'; }
767+
try {
768+
if (tu.name === 'remember') {
769+
const text = String(input.text || '').replace(/\s+/g, ' ').trim();
770+
if (!text) { return 'ERROR: pass `text` — the note, one sentence.'; }
771+
const r = ctx.memoryNotes.remember({ kind: String(input.kind || 'preference'), text, said, personWords: heard }) || {};
772+
ctx.post({ type: 'agentTool', icon: 'history', text: '🧠 remembered: ' + host.displaySafe(text) + (r.verified ? '' : ' (unconfirmed)') });
773+
return String(r.message || 'Saved.');
774+
}
775+
const query = String(input.query || '').trim();
776+
if (!query) { return 'ERROR: pass `query` — words that identify the memory.'; }
777+
const r = ctx.memoryNotes.forget({ query, said, personWords: heard }) || {};
778+
if (r.removed) { ctx.post({ type: 'agentTool', icon: 'history', text: '🧠 forgot: ' + host.displaySafe(r.removed) }); }
779+
return String(r.message || 'Nothing was removed.');
780+
} catch (e) { return 'ERROR: memory could not be updated.'; }
781+
}
752782
if (tu.name === 'recall_sessions') {
753783
if (typeof ctx.recallSessions !== 'function') { return 'ERROR: session recall is not available in this session.'; }
754784
const query = String(input.query || '').trim();
@@ -1004,6 +1034,8 @@ function saysDone(text) {
10041034
}
10051035

10061036
async function runAgent(ctx) {
1037+
// The person's own words this run beyond the goal (their ask_user answers) — what `remember` may quote.
1038+
if (!Array.isArray(ctx.heardThisRun)) { ctx.heardThisRun = []; }
10071039
// No workspace is no longer a refusal. It used to fail the whole run here, which meant a question
10081040
// that never needed a folder — "what does this error mean?", anything through an MCP server, a
10091041
// follow-up about the conversation itself — died on a guard written for the file tools. The root
@@ -1102,6 +1134,7 @@ async function runAgent(ctx) {
11021134
// then there is nothing to fetch, and a tool that is never needed is a standing cost for nothing.
11031135
const extras = [];
11041136
if (ctx.recallSessions) { extras.push(RECALL_TOOL); }
1137+
if (ctx.memoryNotes) { extras.push(REMEMBER_TOOL, FORGET_TOOL); }
11051138
if (rich) { extras.push(diagramTool.RENDER_DIAGRAM); if (ctx.diagramsStubbed) { extras.push(diagramTool.GET_DIAGRAM); } }
11061139
if (extras.length) { tools = tools.concat(extras); }
11071140
// Withhold what this host cannot do: no shell, no run_command, no read_command_output — and for a shell that

‎extensions/levelcode-ai/extension.js‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,7 +1429,8 @@ function enrichMemoryAsync(id) {
14291429
const factsOn = aiConfig().get('sessions.memory.facts', true);
14301430
// The active facts this session might make obsolete — passed to the same call for the §4 conflict pass.
14311431
let existing = [];
1432-
if (factsOn) { try { existing = (m.digest().facts || []).filter((f) => f.active !== false); } catch (e) { existing = []; } }
1432+
// A note the person asked to keep is not the summarizer's to replace: only they change it (remember, forget, the panel).
1433+
if (factsOn) { try { existing = sessionMemory.supersedableFacts(m.digest().facts); } catch (e) { existing = []; } }
14331434
Promise.resolve(summarizeSessionOutcome(messages, existing)).then((res) => {
14341435
const r = res || {};
14351436
let changed = false;
@@ -1448,6 +1449,9 @@ function enrichMemoryAsync(id) {
14481449
// truth, which is the opposite of what decay is for.
14491450
const FACT_STATE_NOTE = {
14501451
confirmed: '', // the user said yes; it needs no hedge
1452+
yours: ' [the user asked you to keep this]',
1453+
// A note the agent saved that could not be credited to the user's words: kept, listed, not in effect.
1454+
'unconfirmed-note': ' [a note the user has not confirmed — do not act on it]',
14511455
observed: ' [inferred from repeated sessions — unconfirmed]',
14521456
inferred: ' [seen once, unconfirmed — weak evidence]',
14531457
superseded: ' [SUPERSEDED — later work replaced this]',
@@ -1471,7 +1475,8 @@ function formatRecall(hits, query, facts) {
14711475
const when = f.at ? String(f.at).slice(0, 10) : 'undated';
14721476
const note = FACT_STATE_NOTE[f.state] != null ? FACT_STATE_NOTE[f.state] : '';
14731477
const by = f.state === 'superseded' && f.supersededBy ? '\n ↳ replaced by: ' + f.supersededBy : '';
1474-
return '- ' + f.text + note + ' (' + when + ')' + by;
1478+
const said = f.state === 'yours' && f.said ? '\n ↳ they said: "' + f.said + '"' : '';
1479+
return '- ' + f.text + note + ' (' + when + ')' + by + said;
14751480
}).join('\n');
14761481
}
14771482
if (arr.length) {
@@ -1487,6 +1492,53 @@ function formatRecall(hits, query, facts) {
14871492
}
14881493
return out;
14891494
}
1495+
// The remember tool's host half. The agent proposes a note with the person's words; THIS decides whether it
1496+
// is theirs (sessionMemory.groundNote — deterministic, no model), keeps it either way, and says which.
1497+
function rememberNoteTool(note) {
1498+
const m = sessionsManager();
1499+
if (!m) { return { verified: false, message: 'No project memory is available in this workspace (open a folder).' }; }
1500+
const n = note || {};
1501+
const kind = sessionMemory.NOTE_KINDS.includes(n.kind) ? n.kind : 'preference';
1502+
const g = sessionMemory.groundNote({ text: n.text, said: n.said, personWords: n.personWords });
1503+
// The note's provenance is this conversation's session; a first run has none on disk yet, so make it now
1504+
// (recordTurn reuses it when the turn ends).
1505+
let sourceId = null;
1506+
try { const live = m.ensure(); sourceId = live ? live.id : null; } catch (e) { sourceId = null; }
1507+
const ok = m.rememberNote(sourceId, { kind, text: n.text, said: n.said, verified: g.verified });
1508+
dbg('sessions.memory.remember', { kind, verified: g.verified, reason: g.reason, grounded: Math.round(g.grounded * 100) / 100, ok });
1509+
if (!ok) { return { verified: false, message: 'ERROR: the note could not be saved.' }; }
1510+
refreshMemoryViews();
1511+
if (g.verified) {
1512+
return { verified: true, message: 'Remembered as the user\'s ' + kind + ' note. It is in this project\'s memory now and in every future session. No need to repeat it back at length.' };
1513+
}
1514+
const why = g.reason === 'not-said' ? 'the quoted words were not found in what the user typed this run'
1515+
: g.reason === 'adds-to-what-was-said' ? 'the note says more than the quoted words do'
1516+
: 'the quoted words are too short to credit';
1517+
return { verified: false, message: 'Saved as UNCONFIRMED (' + why + '). It is listed in the Memory tab and has no effect until the user confirms it there. If the user did ask for this, quote their exact words in `said` and keep the note to what they said.' };
1518+
}
1519+
// The forget tool's host half: removes the best-matching note or fact, when the person's own words ask for it.
1520+
function forgetNoteTool(req) {
1521+
const m = sessionsManager();
1522+
if (!m) { return { removed: '', message: 'No project memory is available in this workspace.' }; }
1523+
const r = req || {};
1524+
const asked = sessionMemory.groundNote({ text: r.said, said: r.said, personWords: r.personWords });
1525+
if (!asked.verified) {
1526+
return { removed: '', message: 'Not removed: the quoted words were not found in what the user typed this run. Tell the user they can remove it from the Memory tab.' };
1527+
}
1528+
const hit = (m.recallFacts(String(r.query || ''), { limit: 1 }) || [])[0];
1529+
if (!hit) { return { removed: '', message: 'Nothing in this project\'s memory matches "' + String(r.query || '') + '".' }; }
1530+
m.factAction(hit.key, 'remove');
1531+
dbg('sessions.memory.forget', { key: hit.key, state: hit.state });
1532+
refreshMemoryViews();
1533+
return { removed: hit.text, message: 'Removed from memory: "' + hit.text + '". Tell the user what you removed, in case it was the wrong one (saying it again with remember brings it back).' };
1534+
}
1535+
// After memory changes from the chat: the Memory tab's list and the welcome-back strip.
1536+
function refreshMemoryViews() {
1537+
const m = sessionsManager();
1538+
if (m && sessionsWebview) { try { sessionsWebview.postMessage({ type: 'memoryList', items: m.memoryItems(), facts: m.factsList() }); } catch (e) { /* view closed */ } }
1539+
postMemoryDigest();
1540+
}
1541+
14901542
// The recall_sessions tool callback handed to the agent — only when memory + the recall setting are on.
14911543
function recallSessionsTool(query) {
14921544
const m = sessionsManager();
@@ -1538,6 +1590,47 @@ async function handleFactAction(action, key, webview) {
15381590
// (§4.9): Done archives (reversible, never deletes), Delete soft-trashes, Rename retitles, Pin toggles.
15391591
// Done/Delete offer an Undo (restore) so an accidental click is recoverable. Best-effort — a History action
15401592
// must never throw into the UI.
1593+
/**
1594+
* Search Sessions… — every session not in the trash, by its title and what was said in it, newest first,
1595+
* each with the line it matched. A quick pick rather than a field in History: History stays the short list
1596+
* of recent work it was made to be (77d4f13), and this reaches the sessions that list no longer shows.
1597+
*/
1598+
function searchSessionsQuickPick() {
1599+
const m = sessionsManager();
1600+
if (!m) { vscode.window.showInformationMessage('Open a folder to search its sessions.'); return; }
1601+
const qp = vscode.window.createQuickPick();
1602+
qp.placeholder = 'Search this project\'s sessions — titles and what was said';
1603+
qp.matchOnDescription = false; qp.matchOnDetail = false;
1604+
let timer = null;
1605+
const when = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''; };
1606+
const run = (q) => {
1607+
let results = [];
1608+
try { results = m.search(q, { limit: 40 }); } catch (e) { results = []; }
1609+
dbg('sessions.search', { chars: q.length, results: results.length });
1610+
// alwaysShow: these items are already the matches; the quick pick's own label filter would drop the
1611+
// ones that matched inside the conversation rather than in the title.
1612+
qp.items = results.length
1613+
? results.map((r) => ({ label: r.title || 'Untitled', description: [when(r.updatedAt), r.lifecycle === 'archived' ? 'done' : '', r.pinned ? 'pinned' : ''].filter(Boolean).join(' · '), detail: r.snippet || '', alwaysShow: true, id: r.id }))
1614+
: [{ label: 'No session mentions “' + q + '”', alwaysShow: true, id: '' }];
1615+
qp.busy = false;
1616+
};
1617+
qp.onDidChangeValue((v) => {
1618+
clearTimeout(timer);
1619+
const q = String(v || '').trim();
1620+
if (q.length < 2) { qp.items = []; qp.busy = false; return; }
1621+
qp.busy = true;
1622+
timer = setTimeout(() => run(q), 200);
1623+
});
1624+
qp.onDidAccept(async () => {
1625+
const pick = qp.selectedItems[0];
1626+
if (!pick || !pick.id) { return; }
1627+
qp.hide();
1628+
await handleSessionAction('resume', pick.id);
1629+
});
1630+
qp.onDidHide(() => { clearTimeout(timer); qp.dispose(); });
1631+
qp.show();
1632+
}
1633+
15411634
async function handleSessionAction(action, id) {
15421635
const m = sessionsManager();
15431636
if (!m || !id) { return; }
@@ -2299,6 +2392,10 @@ async function agentFlow(text, imageBlocks) {
22992392
diagramsStubbed: diagramsStubbed,
23002393
// The recall_sessions tool: search past-session outcomes on demand. Off → the tool isn't offered at all.
23012394
recallSessions: (aiConfig().get('sessions.memory.enabled', true) && aiConfig().get('sessions.memory.recallTool', true)) ? recallSessionsTool : undefined,
2395+
// Memory the person asks for: the remember/forget tools, and the goal as typed, which is what a
2396+
// note's quoted words are checked against (docs/levelcode-sessions-memory.md §12).
2397+
goalText: String(text || ''),
2398+
memoryNotes: (aiConfig().get('sessions.memory.enabled', true) && aiConfig().get('sessions.memory.notes', true)) ? { remember: rememberNoteTool, forget: forgetNoteTool } : undefined,
23022399

23032400
// MCP (docs/MCP.md S3). Config is read HERE and handed in, like verify/commandTimeout, so
23042401
// agent.js keeps doing the loading + connecting + naming without reaching for the editor API.
@@ -3682,6 +3779,7 @@ function activate(context) {
36823779
// Both land on the same panel, each on its own tab, so a button says exactly where it goes.
36833780
vscode.commands.registerCommand('levelcode.ai.sessions', () => revealSessions('history')),
36843781
vscode.commands.registerCommand('levelcode.ai.memory', () => revealSessions('memory')),
3782+
vscode.commands.registerCommand('levelcode.ai.searchSessions', () => searchSessionsQuickPick()),
36853783
// Reachable from an image tab's title bar: dropping a file onto the workbench opens it there,
36863784
// so that is where someone already is when they realise the drop did not attach it.
36873785
// Called by the CORE patch in editorDropTarget.ts when an image is dropped onto the chat

0 commit comments

Comments
 (0)