Skip to content

Commit b1ae4e5

Browse files
committed
fix(memory): forget removes only what the person's words ask to remove, and says when it could not
Two review findings in forgetNoteTool: - It checked `said` against itself, which only proves the words were typed. With the goal "fix the failing date test" quoted as `said`, any query the model chose ("pnpm") removed the person's package-manager preference. Now sessionMemory.asksToForget requires the quote to be typed this run, to ask for something to go ("forget", not "don't forget"; "remove"/"delete" only beside memory/note/rule/preference, so "remove the pnpm lockfile" is not a request; "no longer true", "outdated"), and to name the memory the model picked: a word of its text, or of a credited note's quote, other than words every memory shares. Otherwise nothing is removed, and the agent is told which memory came closest and that the person can use Forget in the Memory tab. - It ignored factAction's result. On a store that refused the write it still answered `removed`, and the agent posted "forgot" while the memory stayed in effect. Now that is reported as an error.
1 parent 70269f0 commit b1ae4e5

5 files changed

Lines changed: 97 additions & 6 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ const RECALL_TOOL = { name: 'recall_sessions', description: 'Search THIS project
177177
// Memory the person asks for (docs/levelcode-sessions-memory.md §12). The host decides whether a note can be
178178
// credited to the person (sessionMemory.groundNote); the agent only proposes it, with their words.
179179
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 uses only those words, in their order, keeping every not/never; 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'] } };
180-
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'] } };
180+
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, and those words must name what to forget. Removes the best match for `query` only when the user\'s words ask for it to go and name it, 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'] } };
181181

182182
// How much of a background command's accumulated output the sniffers re-read on each chunk. Generous
183183
// next to any single log line, so a url split across chunk boundaries is still found, yet small enough

‎extensions/levelcode-ai/extension.js‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,14 +1557,25 @@ function forgetNoteTool(req) {
15571557
const m = sessionsManager();
15581558
if (!m) { return { removed: '', message: 'No project memory is available in this workspace.' }; }
15591559
const r = req || {};
1560-
const asked = sessionMemory.groundNote({ text: r.said, said: r.said, personWords: r.personWords });
1561-
if (!asked.verified) {
1560+
const said = { said: r.said, personWords: r.personWords };
1561+
// Typed this run? Checked before the lookup, so a quote nobody typed learns nothing about what is stored.
1562+
const typed = sessionMemory.asksToForget(Object.assign({ target: { text: '' } }, said));
1563+
if (typed.reason === 'not-said' || typed.reason === 'too-short') {
15621564
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.' };
15631565
}
15641566
const hit = (m.recallFacts(String(r.query || ''), { limit: 1 }) || [])[0];
15651567
if (!hit) { return { removed: '', message: 'Nothing in this project\'s memory matches "' + String(r.query || '') + '".' }; }
1566-
m.factAction(hit.key, 'remove');
1567-
dbg('sessions.memory.forget', { key: hit.key, state: hit.state });
1568+
// The person's words must ask for it to go AND name it: the model's query picked it, their words decide.
1569+
// A credited note's own quote can name it; an uncredited one's quote was never checked, so it cannot.
1570+
const asked = sessionMemory.asksToForget(Object.assign({ target: { text: hit.text, said: hit.state === 'yours' ? hit.said : '' } }, said));
1571+
dbg('sessions.memory.forget', { key: hit.key, state: hit.state, asked: asked.reason });
1572+
if (!asked.ok) {
1573+
const why = asked.reason === 'no-request' ? 'the quoted words do not ask to forget anything' : 'the quoted words do not name this memory';
1574+
return { removed: '', message: 'Not removed (' + why + '). The closest memory is "' + hit.text + '". If the user wants it gone, quote the words where they asked for it, or tell them they can use Forget on it in the Memory tab.' };
1575+
}
1576+
if (!m.factAction(hit.key, 'remove')) {
1577+
return { removed: '', message: 'ERROR: "' + hit.text + '" could not be removed: the memory store refused the write, so it is still in effect. Tell the user.' };
1578+
}
15681579
refreshMemoryViews();
15691580
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).' };
15701581
}

‎extensions/levelcode-ai/sessionMemory.js‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,46 @@ function groundNote(o) {
304304
return { verified: true, reason: 'ok', grounded };
305305
}
306306

307+
// What asks for a memory to go. "forget" on its own (not "don't forget"); "remove", "delete" and the like
308+
// only beside a word that names a memory, because "remove the pnpm lockfile" is about a file; or a phrase
309+
// that says the memory stopped being true.
310+
const FORGET_ASKS = [
311+
/(?:^|[^a-z])(?:forget|unremember)\b/,
312+
/\bstop remembering\b/,
313+
/\b(?:remove|delete|drop|erase|clear|discard|scrap)\b.{0,40}\b(?:memory|memories|note|notes|rule|rules|preference|preferences)\b/,
314+
/\b(?:memory|memories|note|notes|rule|rules|preference|preferences)\b.{0,40}\b(?:remove|delete|drop|erase|clear|discard|scrap)\b/,
315+
/\b(?:no longer|not|isnt|is not|arent|are not) (?:true|the case|valid|right)\b/,
316+
/\b(?:is|are|its|thats|it is|that is) (?:outdated|obsolete|stale|wrong)\b/,
317+
];
318+
const FORGET_NOT = /\b(?:dont|do not|never|not|didnt|did not)\s+forget\b/;
319+
// Words too general to say WHICH memory: every other note has them too.
320+
const FORGET_GENERIC = new Set(['use', 'using', 'always', 'prefer', 'keep', 'make', 'run', 'add', 'forget', 'unremember', 'remember',
321+
'remembering', 'remove', 'delete', 'drop', 'erase', 'clear', 'discard', 'scrap', 'memory', 'memories', 'note', 'notes', 'rule',
322+
'rules', 'preference', 'preferences', 'true', 'valid', 'right', 'case', 'outdated', 'obsolete', 'stale', 'wrong', 'longer']);
323+
324+
/**
325+
* May `forget` remove this memory? Pure. Three things, all checked here and none taken from the model:
326+
* 1. `said` is in what the person typed this run (as for a note);
327+
* 2. `said` asks for something to be forgotten (not "don't forget");
328+
* 3. `said` names this memory: a word of its text, or of the words it was credited on, other than the
329+
* general ones every memory shares.
330+
* The model picks the memory from its own `query`; 3 is what ties the person's request to that pick.
331+
* @param {{said:string, personWords:string[], target:{text:string, said?:string}}} o
332+
* @returns {{ok:boolean, reason:string}}
333+
*/
334+
function asksToForget(o) {
335+
const said = squash(o && o.said);
336+
const pool = (Array.isArray(o && o.personWords) ? o.personWords : []).map(squash).filter(Boolean);
337+
if (said.length < 8 || words(said).length < 2) { return { ok: false, reason: 'too-short' }; }
338+
if (!pool.some((p) => p.indexOf(said) >= 0)) { return { ok: false, reason: 'not-said' }; }
339+
if (FORGET_NOT.test(said) || !FORGET_ASKS.some((re) => re.test(said))) { return { ok: false, reason: 'no-request' }; }
340+
const asked = contentStream(o.said).map((x) => x.w).filter((w) => !FORGET_GENERIC.has(w));
341+
const t = (o && o.target) || {};
342+
const named = contentStream(String(t.text || '') + '. ' + String(t.said || '')).map((x) => x.w).filter((w) => !FORGET_GENERIC.has(w) && !SCOPE_WORDS.has(w));
343+
if (!named.some((w) => asked.some((a) => sameWord(w, a)))) { return { ok: false, reason: 'not-this-memory' }; }
344+
return { ok: true, reason: 'ok' };
345+
}
346+
307347
/**
308348
* One observation of a candidate fact (append-only), sourced + dated — the raw material foldFacts counts.
309349
* `note` marks it as one the person asked to keep (see groundNote): its kind, their words, and whether the
@@ -650,6 +690,6 @@ module.exports = {
650690
outcomeEntry, appendJournal, readJournal, latestBySession, writeMemoryMd,
651691
normalizeFactKey, factObservation, factControl, appendFacts, readFacts, foldFacts, activeFacts,
652692
redactSecrets, looksLikeInstruction,
653-
NOTE_KINDS, groundNote, ageLabel, DIGEST_MAX_NOTES, DIGEST_MAX_FACTS, supersedableFacts,
693+
NOTE_KINDS, groundNote, asksToForget, ageLabel, DIGEST_MAX_NOTES, DIGEST_MAX_FACTS, supersedableFacts,
654694
queryTerms, snippetFor, recallRank, recallFacts, buildDigest, digestSummary, digestMarkdown
655695
};

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ test('GROUND: a quote too short to mean anything is not enough', () => {
7474
assert.deepStrictEqual(mem.groundNote({ text: 'ok then', said: 'ok then', personWords: ['ok then'] }).reason, 'too-short');
7575
});
7676

77+
// ---- asksToForget: may forget remove this memory? --------------------------------------------------
78+
const pnpmNote = { text: 'Use pnpm, never npm, in this repo', said: 'always use pnpm, never npm' };
79+
const forgetOk = (said, target, pw) => mem.asksToForget({ said, personWords: pw || ['so: ' + said + '.'], target: target || pnpmNote });
80+
test('FORGET: the person\'s words must ask for something to go — a typed goal is not a removal request', () => {
81+
// The reviewer's case: the goal is real and quotable, and says nothing about forgetting.
82+
assert.deepStrictEqual(forgetOk('fix the failing date test', pnpmNote, typed), { ok: false, reason: 'no-request' });
83+
assert.strictEqual(forgetOk('don\'t forget pnpm is required').reason, 'no-request', '"don\'t forget" keeps it');
84+
assert.strictEqual(forgetOk('remove the pnpm lockfile').reason, 'no-request', 'removing a file is not removing a memory');
85+
assert.strictEqual(forgetOk('forget the pnpm rule', pnpmNote, ['something else']).reason, 'not-said');
86+
});
87+
test('FORGET: …and name the memory the model picked', () => {
88+
assert.strictEqual(forgetOk('forget about it, just fix the test').reason, 'not-this-memory');
89+
assert.strictEqual(forgetOk('forget the date test', { text: 'Never mock the database' }).reason, 'not-this-memory');
90+
for (const said of ['forget the pnpm rule', 'remove the pnpm rule from memory', 'the npm note is no longer true', 'that pnpm preference is outdated']) {
91+
assert.strictEqual(forgetOk(said).ok, true, said);
92+
}
93+
assert.strictEqual(forgetOk('forget the rebase rule', { text: 'Always rebase before merging' }).ok, true);
94+
});
95+
7796
// ---- the fold: what a note does ------------------------------------------------------------------
7897
const note = (text, said, verified, at, src) => mem.factObservation(text, src || 's1', at || iso(DAY / 2), { kind: 'preference', said, verified });
7998
test('FOLD: a credited note is active at once — even phrased as an order — and marked as theirs', () => {

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,27 @@ const host = new Function('env', [
195195
assert.match(none.message, /Nothing in this project's memory matches/);
196196
});
197197

198+
await testAsync('HOST: a quoted goal cannot remove a memory it does not ask about (the reviewer\'s case)', async () => {
199+
host.rememberNoteTool({ kind: 'preference', text: 'Use pnpm, never npm, in this repo', said: 'always use pnpm, never npm', personWords: typed });
200+
const r = host.forgetNoteTool({ query: 'pnpm', said: 'fix the failing date test', personWords: typed });
201+
assert.strictEqual(r.removed, '');
202+
assert.match(r.message, /do not ask to forget anything/);
203+
const r2 = host.forgetNoteTool({ query: 'pnpm', said: 'forget about it, just fix the test', personWords: ['forget about it, just fix the test'] });
204+
assert.strictEqual(r2.removed, '');
205+
assert.match(r2.message, /do not name this memory.*Use pnpm, never npm, in this repo/);
206+
assert.ok(sessions.factsList().some((x) => x.text === 'Use pnpm, never npm, in this repo' && x.active), 'the preference is gone');
207+
});
208+
await testAsync('HOST: a removal the store refuses is reported as not done, and the memory stays', async () => {
209+
ui.manager = Object.assign({}, sessions, { factAction: () => false });
210+
const r = host.forgetNoteTool({ query: 'pnpm', said: 'forget the pnpm rule', personWords: ['forget the pnpm rule'] });
211+
ui.manager = undefined;
212+
assert.strictEqual(r.removed, '', 'reported as removed');
213+
assert.match(r.message, /could not be removed.*still in effect/);
214+
assert.ok(sessions.factsList().some((x) => x.text === 'Use pnpm, never npm, in this repo' && x.active));
215+
const agent = await run('forget the pnpm rule', [call('forget', { query: 'pnpm', said: 'forget the pnpm rule' })], { memoryNotes: { remember: () => ({}), forget: () => r } });
216+
assert.ok(!agent.posted.some((m) => m.type === 'agentTool' && /forgot/.test(m.text)), 'a "forgot" chip for a memory that is still there');
217+
});
218+
198219
// ---- Search Sessions -------------------------------------------------------------------------------
199220
sessions.recordTurn([{ role: 'user', content: 'Fix the refund retry bug' }, { role: 'assistant', content: 'Retries use an idempotency key in Redis.' }], 'm');
200221
const refundId = sessions.liveId(); sessions.seal('done');

0 commit comments

Comments
 (0)