Skip to content

Commit 226d27b

Browse files
ndemiancclaude
andcommitted
feat(levelcode): memory — transcript-deep recall (search inside sessions)
recall_sessions searched only the one-line outcomes; now it also scans the conversations themselves, so a match that never made it into the summary — a library, a decision, an error mentioned mid-session — is still found and comes back with a cited snippet from the actual transcript. - sessionMemory.js: queryTerms (shared normalization) + snippetFor (pure — find the first matching turn, return a short role-prefixed citation). - sessions.js: recall now scores by journal metadata (summary/title/files, all sessions) AND a bounded deep scan of recent transcripts (scanMax, default 40), merging so a transcript-only hit still surfaces with its snippet; a metadata hit outranks a body-only hit. - extension.js: the recall result cites the snippet under each outcome. - agent.js: RECALL_TOOL description notes it searches conversations + returns snippets. - tests: +1 sessionMemory (snippet extraction), +1 sessions (deep scan finds a transcript-only match with a snippet). 31 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 781a78e commit 226d27b

6 files changed

Lines changed: 93 additions & 7 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4);
7373
// Cross-session memory recall (docs/levelcode-sessions-memory.md). Added to a run's tools ONLY when the host
7474
// wires ctx.recallSessions (memory + the recall setting on), so it costs nothing otherwise. Read-only and
7575
// 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'] } };
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), 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'] } };
7777

7878
// How much of a background command's accumulated output the sniffers re-read on each chunk. Generous
7979
// 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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,9 @@ function formatRecall(hits, query) {
785785
const lines = arr.map((e) => {
786786
const when = e.at ? String(e.at).slice(0, 10) : 'undated';
787787
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 + ')';
788+
let line = '- ' + (e.summary || e.title || 'a session') + files + ' (' + when + ')';
789+
if (e.snippet) { line += '\n ↳ ' + e.snippet; } // a cited line from the actual transcript (deep recall)
790+
return line;
789791
});
790792
return 'Recalled from earlier sessions in this project (memory — informative but possibly stale; verify against the current code):\n' + lines.join('\n');
791793
}

‎extensions/levelcode-ai/sessionMemory.js‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,40 @@ function latestBySession(entries) {
110110
* @param {string} query
111111
* @param {{ limit?: number }} [opts]
112112
*/
113+
/** Normalize a query into searchable terms (lowercased, punctuation-trimmed, ≥2 chars). Shared by recall. */
114+
function queryTerms(query) {
115+
return String(query || '').toLowerCase().split(/\s+/).map((t) => t.replace(/[^a-z0-9_.-]/g, '')).filter((t) => t.length >= 2);
116+
}
117+
118+
/**
119+
* Find the first place a query term appears in a session's readable transcript and return a short, cited
120+
* snippet around it (role-prefixed). Pure — the caller feeds display turns; this never reads files. Empty
121+
* when nothing matches. `terms` may be a pre-split array or a raw query string.
122+
*/
123+
function snippetFor(displayTurns, terms) {
124+
const ts = Array.isArray(terms) ? terms : queryTerms(terms);
125+
if (!ts.length) { return ''; }
126+
for (const turn of (Array.isArray(displayTurns) ? displayTurns : [])) {
127+
const text = String((turn && turn.text) || '');
128+
const low = text.toLowerCase();
129+
for (const term of ts) {
130+
const i = low.indexOf(term);
131+
if (i >= 0) {
132+
const start = Math.max(0, i - 40);
133+
let snip = text.slice(start, i + term.length + 90).replace(/\s+/g, ' ').trim();
134+
if (start > 0) { snip = '…' + snip; }
135+
if (i + term.length + 90 < text.length) { snip += '…'; }
136+
return (turn.role === 'user' ? 'you: ' : 'ai: ') + snip;
137+
}
138+
}
139+
}
140+
return '';
141+
}
142+
113143
function recallRank(entries, query, opts) {
114144
const o = opts || {};
115145
const limit = Number.isFinite(o.limit) && o.limit > 0 ? o.limit : 6;
116-
const terms = String(query || '').toLowerCase().split(/\s+/).map((t) => t.replace(/[^a-z0-9_.-]/g, '')).filter((t) => t.length >= 2);
146+
const terms = queryTerms(query);
117147
if (!terms.length) { return []; }
118148
const scored = [];
119149
for (const e of (Array.isArray(entries) ? entries : [])) {
@@ -193,5 +223,5 @@ module.exports = {
193223
SCHEMA_V,
194224
memoryDir, journalFile, factsFile, memoryMdFile,
195225
outcomeEntry, appendJournal, readJournal, latestBySession, writeMemoryMd,
196-
recallRank, buildDigest, digestSummary, digestMarkdown
226+
queryTerms, snippetFor, recallRank, buildDigest, digestSummary, digestMarkdown
197227
};

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,37 @@ 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. */
168+
/**
169+
* On-demand recall: past-session outcomes matching a query — ranked by journal metadata (summary/title/
170+
* files, all sessions) AND a bounded DEEP scan of recent transcripts, so a match that lives only in the
171+
* conversation (never the one-line summary) is still found and returns a cited snippet. Empty on error.
172+
*/
169173
function recall(query, opts) {
170-
try { return memory.recallRank(memory.latestBySession(memory.readJournal(root, slug)), query, opts); }
171-
catch (e) { return []; }
174+
const o = opts || {};
175+
const limit = Number.isFinite(o.limit) && o.limit > 0 ? o.limit : 6;
176+
const scanMax = Number.isFinite(o.scanMax) ? o.scanMax : 40; // bound the deep transcript reads
177+
try {
178+
const entries = memory.latestBySession(memory.readJournal(root, slug)); // newest-first
179+
const terms = memory.queryTerms(query);
180+
if (!terms.length) { return []; }
181+
const metaIds = new Set(memory.recallRank(entries, query, { limit: entries.length }).map((e) => e.id));
182+
const hits = [];
183+
let scanned = 0;
184+
for (const e of entries) {
185+
let snippet = '';
186+
if (scanned < scanMax) {
187+
scanned++;
188+
try {
189+
const turns = events.toDisplayTurns(events.eventsToMessages(store.readSession(store.sessionFile(root, slug, e.id)).events));
190+
snippet = memory.snippetFor(turns, terms);
191+
} catch (err) { /* a gone/corrupt session just yields no snippet */ }
192+
}
193+
const score = (metaIds.has(e.id) ? 2 : 0) + (snippet ? 1 : 0); // a metadata hit weighs more than a body hit
194+
if (score > 0) { hits.push(Object.assign({}, e, { score, snippet })); }
195+
}
196+
hits.sort((a, b) => b.score - a.score || String(b.at || '').localeCompare(String(a.at || '')));
197+
return hits.slice(0, limit);
198+
} catch (e) { return []; }
172199
}
173200
/** All current memory outcomes (one per session, newest-first) — what the memory panel lists. */
174201
function memoryItems() { try { return memory.latestBySession(memory.readJournal(root, slug)); } catch (e) { return []; } }

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ test('RECALL: ranks journal entries by term matches in summary/title/files, best
9292
assert.ok(M.recallRank(j, 'the', { limit: 2 }).length <= 2, 'respects the limit');
9393
});
9494

95+
test('SNIPPET: finds the first matching turn and returns a short, role-prefixed citation', () => {
96+
const turns = [
97+
{ role: 'user', text: 'Add idempotency to refunds' },
98+
{ role: 'assistant', text: 'I used a Redis SETNX lock keyed on the idempotency-key header to dedupe retries.' }
99+
];
100+
const s = M.snippetFor(turns, 'redis');
101+
assert.match(s, /^ai: /, 'role-prefixed');
102+
assert.match(s, /Redis SETNX/, 'quotes around the match');
103+
assert.strictEqual(M.snippetFor(turns, 'zzz'), '', 'no match → empty');
104+
assert.strictEqual(M.snippetFor(turns, 'a'), '', 'too-short term → empty');
105+
});
106+
95107
test('DIGEST: recent (windowed, newest-first) + pinned, built from the journal', () => {
96108
const DAY = 86400000, T0 = Date.parse('2026-08-01T12:00:00Z');
97109
const ago = (d) => new Date(T0 - d * DAY).toISOString();

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,21 @@ 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: recall DEEP-scans transcripts — finds a match that lives only in the conversation, with a snippet', () => {
278+
const root = freshRoot(), slug = store.projectSlug('/pdeep');
279+
const m = createSessions({ root, slug, projectPath: '/pdeep' });
280+
// "kubernetes" appears in the conversation but NOT in the title / summary / files
281+
m.recordTurn([
282+
{ role: 'user', content: 'Set up the deploy' },
283+
{ role: 'assistant', content: 'I configured the Kubernetes ingress and a health probe.' }
284+
], 'm');
285+
m.seal('done');
286+
assert.strictEqual(m.recall('deploy').length, 1, 'metadata match (title) still works');
287+
const deep = m.recall('kubernetes');
288+
assert.strictEqual(deep.length, 1, 'found by the deep transcript scan, not the metadata');
289+
assert.match(deep[0].snippet, /Kubernetes ingress/, 'and returns a cited snippet from the conversation');
290+
});
291+
277292
test('MEMORY: forget drops a session from memory (digest/recall/panel) but keeps it in History', () => {
278293
const root = freshRoot(), slug = store.projectSlug('/pfg');
279294
const m = createSessions({ root, slug, projectPath: '/pfg' });

0 commit comments

Comments
 (0)