Skip to content

Commit e09f2a7

Browse files
ndemiancclaude
andcommitted
feat(levelcode): memory — the recall_sessions agent tool (M1 complete)
Deep, on-demand recall over the project's own past sessions. When the user refers to earlier work — "what did we do about refunds?", "did we already fix the webhook?" — the agent calls recall_sessions and gets cited outcomes with dates instead of guessing. Read-only, searches only the local journal, and offered ONLY when memory + the setting are on (it costs nothing otherwise). - sessionMemory.js: recallRank(entries, query) — pure term-match ranking over each outcome's summary/title/files, recency breaking ties (no file reads: it searches the small journal, not every transcript). - sessions.js: recall(query) over latestBySession(journal). - agent.js: a RECALL_TOOL schema concatenated per-run only when ctx.recallSessions is wired (token accounting updated to count it), and a dispatch branch that mirrors use_skill — read-only, instant, a quiet "🧠 recalling" chip. - extension.js: recallSessionsTool formats hits as a cited, verify-first result; passed into runAgent, gated by sessions.memory.enabled + .recallTool. - package.json: levelcode.ai.sessions.memory.recallTool (default true). - tests: +1 sessionMemory (ranking), +1 sessions (recall by outcome+files), +1 sessionsUi (tool schema, host-gating, dispatch, wiring). 31 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 49ffba9 commit e09f2a7

8 files changed

Lines changed: 114 additions & 7 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ function buildSystem(menu) {
7070
}
7171
const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4);
7272

73+
// Cross-session memory recall (docs/levelcode-sessions-memory.md). Added to a run's tools ONLY when the host
74+
// wires ctx.recallSessions (memory + the recall setting on), so it costs nothing otherwise. Read-only and
75+
// instant like use_skill — it searches the project's OWN past-session outcomes, never the file system.
76+
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) and the files they touched. 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 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'] } };
77+
7378
// How much of a background command's accumulated output the sniffers re-read on each chunk. Generous
7479
// next to any single log line, so a url split across chunk boundaries is still found, yet small enough
7580
// that a noisy watcher which never prints an address costs nothing to keep scanning.
@@ -479,6 +484,14 @@ async function runTool(tu, ctx) {
479484
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🧩 using skill: ' + name }); // quiet chip — only on success (🧩 is the marker; 'sparkle' falls back cleanly)
480485
return body; // SKILL.md body → tool_result, steers the next turns
481486
}
487+
if (tu.name === 'recall_sessions') {
488+
if (typeof ctx.recallSessions !== 'function') { return 'ERROR: session recall is not available in this session.'; }
489+
const query = String(input.query || '').trim();
490+
if (!query) { return 'ERROR: pass a non-empty query describing what to recall.'; }
491+
ctx.post({ type: 'agentTool', icon: 'history', text: '🧠 recalling: ' + query });
492+
try { return String(ctx.recallSessions(query) || 'No matching past sessions in this project.'); }
493+
catch (e) { return 'ERROR: recall failed.'; }
494+
}
482495
// MCP tools (docs/MCP.md S3). An MCP name matches none of the built-in branches above, so every
483496
// MCP call necessarily arrives HERE — which is why the router is one block at one line rather
484497
// than a dispatch scattered through runTool.
@@ -706,10 +719,12 @@ async function runAgent(ctx) {
706719
// as `system`/`systemTokensEst` two lines up — built once per run, then used for every turn.
707720
const mcp = await setupMcp(ctx, wsFolders, dbg);
708721
ctx.mcpRoutes = mcp.routes; // runTool's router reads this
709-
const tools = mcp.tools.length ? TOOLS.concat(mcp.tools) : TOOLS;
710-
// Recomputed only when MCP actually contributed tools, so the no-MCP path keeps the module constant
711-
// and pays nothing for a feature it isn't using.
712-
const toolsTokensEst = mcp.tools.length ? Math.round(JSON.stringify(tools).length / 4) : TOOLS_TOKENS_EST;
722+
let tools = mcp.tools.length ? TOOLS.concat(mcp.tools) : TOOLS;
723+
if (ctx.recallSessions) { tools = tools.concat([RECALL_TOOL]); } // cross-session recall (host-gated by memory settings)
724+
const baseTools = ctx.recallSessions ? TOOLS.concat([RECALL_TOOL]) : TOOLS; // built-ins + recall; MCP is the rest
725+
// Recomputed only when MCP or recall actually contributed tools, so the plain path keeps the module
726+
// constant and pays nothing for a feature it isn't using.
727+
const toolsTokensEst = (mcp.tools.length || ctx.recallSessions) ? Math.round(JSON.stringify(tools).length / 4) : TOOLS_TOKENS_EST;
713728
// The MCP SHARE of that, reported separately so the context popover can show what these servers cost
714729
// (docs/MCP.md S5). Every tool schema rides EVERY turn, so a chatty server is a standing tax on the
715730
// window rather than a one-off — and until it has its own segment, that cost is invisible.
@@ -718,7 +733,7 @@ async function runAgent(ctx) {
718733
// the JSON delimiters between entries belong to the total, and attributing them consistently is what
719734
// keeps `tools` and `mcpTools` summing to the number the bar already draws.
720735
const mcpToolsTokensEst = mcp.tools.length
721-
? Math.max(0, toolsTokensEst - Math.round(JSON.stringify(TOOLS).length / 4))
736+
? Math.max(0, toolsTokensEst - Math.round(JSON.stringify(baseTools).length / 4))
722737
: 0;
723738

724739
const messages = ctx.messages;

‎extensions/levelcode-ai/extension.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,25 @@ function enrichMemoryAsync(id) {
778778
}).catch(() => { /* best-effort */ });
779779
}
780780

781+
// Format recall hits as a cited, verify-first tool result (the recall_sessions result the agent reads).
782+
function formatRecall(hits, query) {
783+
const arr = Array.isArray(hits) ? hits : [];
784+
if (!arr.length) { return 'No past sessions in this project match "' + query + '".'; }
785+
const lines = arr.map((e) => {
786+
const when = e.at ? String(e.at).slice(0, 10) : 'undated';
787+
const files = Array.isArray(e.files) && e.files.length ? ' — files: ' + e.files.slice(0, 4).join(', ') : '';
788+
return '- ' + (e.summary || e.title || 'a session') + files + ' (' + when + ')';
789+
});
790+
return 'Recalled from earlier sessions in this project (memory — informative but possibly stale; verify against the current code):\n' + lines.join('\n');
791+
}
792+
// The recall_sessions tool callback handed to the agent — only when memory + the recall setting are on.
793+
function recallSessionsTool(query) {
794+
const m = sessionsManager();
795+
if (!m) { return 'No project memory is available in this workspace.'; }
796+
try { return formatRecall(m.recall(String(query || ''), { limit: 6 }), String(query || '')); }
797+
catch (e) { return 'Recall failed.'; }
798+
}
799+
781800
// A card action from either surface. resume reopens the session; done/delete/rename/pin are append-only edits
782801
// (§4.9): Done archives (reversible, never deletes), Delete soft-trashes, Rename retitles, Pin toggles.
783802
// Done/Delete offer an Undo (restore) so an accidental click is recoverable. Best-effort — a History action
@@ -1371,6 +1390,8 @@ async function agentFlow(text) {
13711390
},
13721391
skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver)
13731392
projectMemory: projectMemoryMarkdown(), // cross-session memory: a verify-first digest of past sessions, injected like project rules
1393+
// The recall_sessions tool: search past-session outcomes on demand. Off → the tool isn't offered at all.
1394+
recallSessions: (aiConfig().get('sessions.memory.enabled', true) && aiConfig().get('sessions.memory.recallTool', true)) ? recallSessionsTool : undefined,
13741395

13751396
// MCP (docs/MCP.md S3). Config is read HERE and handed in, like verify/commandTimeout, so
13761397
// agent.js keeps doing the loading + connecting + naming without reaching for the editor API.

‎extensions/levelcode-ai/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,11 @@
451451
"default": true,
452452
"description": "When a substantive session finishes, make one small, fast-model call to refine its journal outcome into a concrete sentence (what was built/fixed/decided) instead of just its goal headline. Runs in the background and only summarizes outcomes and actions, never quoted code. Turn off to skip the model call and keep the plain headline (no cost)."
453453
},
454+
"levelcode.ai.sessions.memory.recallTool": {
455+
"type": "boolean",
456+
"default": true,
457+
"description": "Give the agent a recall_sessions tool so it can search this project's past-session outcomes on demand — e.g. when you ask \"what did we do about refunds?\" it looks up the earlier work instead of guessing. Read-only, searches only the local journal. Turn off to remove the tool."
458+
},
454459
"levelcode.ai.sessions.autoArchiveDays": {
455460
"type": "number",
456461
"default": 30,

‎extensions/levelcode-ai/sessionMemory.js‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,33 @@ function latestBySession(entries) {
9898
return out;
9999
}
100100

101+
// ── recall: on-demand search over the journal (design §3 — the recall tool's engine) ──────────────
102+
103+
/**
104+
* Rank journal entries against a free-text query — term matches in the outcome summary, title, and files
105+
* (recency breaks ties). Pure and cheap (no file reads): the recall tool searches the small journal, not
106+
* every transcript. Returns the top `limit` matching entries, best-first; [] for an empty/too-short query.
107+
* @param {any[]} entries journal entries (usually latestBySession)
108+
* @param {string} query
109+
* @param {{ limit?: number }} [opts]
110+
*/
111+
function recallRank(entries, query, opts) {
112+
const o = opts || {};
113+
const limit = Number.isFinite(o.limit) && o.limit > 0 ? o.limit : 6;
114+
const terms = String(query || '').toLowerCase().split(/\s+/).map((t) => t.replace(/[^a-z0-9_.-]/g, '')).filter((t) => t.length >= 2);
115+
if (!terms.length) { return []; }
116+
const scored = [];
117+
for (const e of (Array.isArray(entries) ? entries : [])) {
118+
if (!e) { continue; }
119+
const hay = ((e.summary || '') + ' ' + (e.title || '') + ' ' + (Array.isArray(e.files) ? e.files.join(' ') : '')).toLowerCase();
120+
let score = 0;
121+
for (const t of terms) { if (hay.indexOf(t) >= 0) { score++; } }
122+
if (score > 0) { scored.push({ e, score }); }
123+
}
124+
scored.sort((a, b) => b.score - a.score || String(b.e.at || '').localeCompare(String(a.e.at || '')));
125+
return scored.slice(0, limit).map((x) => x.e);
126+
}
127+
101128
// ── the always-on digest (design §3/§8) ──────────────────────────────────────────────────────────
102129

103130
/**
@@ -164,5 +191,5 @@ module.exports = {
164191
SCHEMA_V,
165192
memoryDir, journalFile, factsFile, memoryMdFile,
166193
outcomeEntry, appendJournal, readJournal, latestBySession, writeMemoryMd,
167-
buildDigest, digestSummary, digestMarkdown
194+
recallRank, buildDigest, digestSummary, digestMarkdown
168195
};

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ function createSessions(opts) {
165165
try { return memory.buildDigest(memory.readJournal(root, slug), Object.assign({ nowMs: clock().getTime() }, opts)); }
166166
catch (e) { return { recently: [], pinned: [], total: 0 }; }
167167
}
168+
/** On-demand recall: the top past-session outcomes matching a query (journal search). Empty on error. */
169+
function recall(query, opts) {
170+
try { return memory.recallRank(memory.latestBySession(memory.readJournal(root, slug)), query, opts); }
171+
catch (e) { return []; }
172+
}
168173
/** The deterministic consolidation pass: rewrite MEMORY.md from the current journal. Returns the digest. */
169174
function consolidate(opts) {
170175
const o = Object.assign({ nowMs: clock().getTime() }, opts);
@@ -202,7 +207,7 @@ function createSessions(opts) {
202207

203208
function liveId() { return live ? live.id : null; }
204209

205-
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, memoryPaths, list, liveId };
210+
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, memoryPaths, list, liveId };
206211
}
207212

208213
module.exports = { createSessions };

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ 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('RECALL: ranks journal entries by term matches in summary/title/files, best-first', () => {
74+
const j = [
75+
{ id: 'a', at: '2026-07-01', summary: 'Made refunds idempotent with a Redis lock', title: 'refund retries', files: ['refund.rb'] },
76+
{ id: 'b', at: '2026-07-10', summary: 'Fixed the flaky payment webhook test', title: 'webhook', files: ['webhook_spec.rb'] },
77+
{ id: 'c', at: '2026-07-20', summary: 'Tidied the CHANGELOG', title: 'changelog', files: ['RELEASE-NOTES.md'] }
78+
];
79+
assert.strictEqual(M.recallRank(j, 'refund redis', { limit: 5 })[0].id, 'a', 'two term hits rank first');
80+
assert.deepStrictEqual(M.recallRank(j, 'webhook_spec.rb').map((e) => e.id), ['b'], 'matches on a file path too');
81+
assert.deepStrictEqual(M.recallRank(j, 'zzz nothing'), [], 'no match → empty');
82+
assert.deepStrictEqual(M.recallRank(j, 'a'), [], 'too-short terms are ignored (no accidental match-all)');
83+
assert.ok(M.recallRank(j, 'the', { limit: 2 }).length <= 2, 'respects the limit');
84+
});
85+
7386
test('DIGEST: recent (windowed, newest-first) + pinned, built from the journal', () => {
7487
const DAY = 86400000, T0 = Date.parse('2026-08-01T12:00:00Z');
7588
const ago = (d) => new Date(T0 - d * DAY).toISOString();

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,15 @@ test('MEMORY: refineSummary supersedes the deterministic outcome (model enrichme
263263
assert.ok(!m.refineSummary(id, ' '), 'a blank refinement is refused');
264264
});
265265

266+
test('MEMORY: recall finds a past session by a query over its outcome + files', () => {
267+
const root = freshRoot(), slug = store.projectSlug('/prc');
268+
const m = createSessions({ root, slug, projectPath: '/prc' });
269+
m.recordTurn(turn('Add idempotency to refunds', 'refund.rb'), 'm'); m.seal('done');
270+
m.recordTurn(turn('Tidy the CHANGELOG', 'RELEASE-NOTES.md'), 'm'); m.seal('done');
271+
const hits = m.recall('refund');
272+
assert.strictEqual(hits.length, 1, 'only the refunds session matches');
273+
assert.match(hits[0].title, /idempotency to refunds/);
274+
assert.deepStrictEqual(m.recall('zzz-nomatch'), [], 'no match → empty');
275+
});
276+
266277
console.log('sessions: ' + n + ' tests passed');

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ test('MEMORY (enrichment): a cheap-lane model call refines the journal outcome w
175175
assert.ok((pkg.contributes.configuration.properties || {})['levelcode.ai.sessions.memory.summarize'], 'gated by a contributed setting');
176176
});
177177

178+
test('MEMORY (recall): a recall_sessions agent tool searches past-session outcomes on demand', () => {
179+
const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'agent.js'), 'utf8');
180+
assert.match(agentSrc, /name: 'recall_sessions'/, 'the tool schema exists');
181+
assert.match(agentSrc, /if \(ctx\.recallSessions\) \{ tools = tools\.concat\(\[RECALL_TOOL\]\)/, 'offered ONLY when the host wires it (host-gated)');
182+
assert.match(agentSrc, /tu\.name === 'recall_sessions'/, 'and dispatched to ctx.recallSessions');
183+
assert.match(ext, /function recallSessionsTool/, 'extension.js provides the recall callback');
184+
assert.match(ext, /recallSessions: \(aiConfig\(\)\.get\('sessions\.memory\.enabled'.*recallTool/, 'gated by enabled + recallTool');
185+
assert.ok((pkg.contributes.configuration.properties || {})['levelcode.ai.sessions.memory.recallTool'], 'the setting is contributed');
186+
});
187+
178188
test('VIEW: the sidebar view and the modal share the EXACT same render block (no drift)', () => {
179189
const grab = (s) => s.slice(s.indexOf('// [SESSIONS-PURE-START]'), s.indexOf('// [SESSIONS-PURE-END]'));
180190
assert.ok(grab(view).length > 500, 'the view carries the pure block');

0 commit comments

Comments
 (0)