diff --git a/docs/design/2026-08-09-bounded-memory-recall-candidates.md b/docs/design/2026-08-09-bounded-memory-recall-candidates.md new file mode 100644 index 00000000000..25430ec2233 --- /dev/null +++ b/docs/design/2026-08-09-bounded-memory-recall-candidates.md @@ -0,0 +1,56 @@ +# Bounded Memory Recall Candidates + +## Problem + +The project and user memory scanners enumerate, read, and parse every topic, +then return only the 200 most recent documents. Recall uses those shared scanner +APIs, so an older relevant document outside either 200-document window cannot +reach the heuristic or model selector even though the expensive scan work has +already happened. + +The same capped APIs are also used by Forget, Indexer, Status, and Extraction. +Removing their limit globally would widen unrelated behavior. + +## Decision + +Keep the existing scanner APIs and their 200-document limit unchanged. Add +explicit all-topic variants used only by recall. + +Recall ranks the combined project and user pool before model selection: + +- retain up to 180 documents with a lexical match using the existing scorer; +- fill the remaining candidate slots by recency, preserving at least 20 recent + opportunities when enough lexical matches exist; +- send at most 200 candidates to the model selector; +- append manifest entries only while their cumulative UTF-8 size remains at or + below 25,000 bytes; +- validate selector output only against documents actually present in that + bounded manifest. + +The heuristic fallback continues to score the complete recall pool and still +returns at most five documents. Existing body and prompt limits remain +unchanged. + +## Failure and compatibility boundaries + +Project scanning remains required. User scanning remains best-effort. Invalid +or unreadable files keep the existing skip behavior. Empty candidate manifests +return no model selection rather than sending an unbounded request. + +There is no public setting, persistent index, new dependency, provider API, or +Fast/Refined state machine. Recall still performs an O(n) local pass over the +already parsed documents; a persistent catalog requires separate measurement +and evidence. + +## Verification + +- A deliberately old relevant topic beyond the regular 200-document result is + recalled from a real temporary memory tree. +- The regular scanner still returns 200 documents and omits that topic. +- The model candidate set contains the lexical target and recent reserve while + remaining at 200 documents. +- A manifest built from large multibyte descriptions stays within 25,000 UTF-8 + bytes. +- The deterministic CLI E2E selects the overflow topic and exposes its unique + marker at the ToolResult delivery point after the bounded initial wait + expires. diff --git a/packages/core/src/memory/memoryLifecycle.integration.test.ts b/packages/core/src/memory/memoryLifecycle.integration.test.ts index 6318306ab68..971656e70fb 100644 --- a/packages/core/src/memory/memoryLifecycle.integration.test.ts +++ b/packages/core/src/memory/memoryLifecycle.integration.test.ts @@ -229,4 +229,65 @@ describe('managed auto-memory lifecycle integration', () => { expect(recall.prompt).toContain('user/'); expect(recall.prompt).toContain('reference/'); }); + + it('recalls a relevant topic beyond the general 200-document scan cap', async () => { + const referenceDir = path.dirname( + getAutoMemoryFilePath(projectRoot, 'reference/filler-000.md'), + ); + await fs.mkdir(referenceDir, { recursive: true }); + await Promise.all( + Array.from({ length: 200 }, (_, index) => + fs.writeFile( + path.join( + referenceDir, + `filler-${String(index).padStart(3, '0')}.md`, + ), + [ + '---', + 'type: reference', + `name: Filler ${index}`, + 'description: Unrelated historical note', + '---', + '', + 'No matching content.', + ].join('\n'), + 'utf-8', + ), + ), + ); + + const targetPath = getAutoMemoryFilePath( + projectRoot, + 'reference/overflow-target.md', + ); + await fs.writeFile( + targetPath, + [ + '---', + 'type: reference', + 'name: Overflow Zephyr Marker', + 'description: Unique recall target beyond the general scan cap', + '---', + '', + 'The saved codeword is OVERFLOW-ZEPHYR-7040.', + ].join('\n'), + 'utf-8', + ); + await fs.utimes(targetPath, new Date(0), new Date(0)); + + const cappedDocs = await scanAutoMemoryTopicDocuments(projectRoot); + expect(cappedDocs).toHaveLength(200); + expect(cappedDocs.some((doc) => doc.filePath === targetPath)).toBe(false); + + const recall = await resolveRelevantAutoMemoryPromptForQuery( + projectRoot, + 'What is the overflow zephyr codeword?', + ); + + expect(recall.strategy).toBe('heuristic'); + expect(recall.selectedDocs.map((doc) => doc.filePath)).toContain( + targetPath, + ); + expect(recall.prompt).toContain('OVERFLOW-ZEPHYR-7040'); + }); }); diff --git a/packages/core/src/memory/recall.test.ts b/packages/core/src/memory/recall.test.ts index 9b3741e4487..e741f4e0fdf 100644 --- a/packages/core/src/memory/recall.test.ts +++ b/packages/core/src/memory/recall.test.ts @@ -12,19 +12,19 @@ import { } from './recall.js'; import type { ScannedAutoMemoryDocument } from './scan.js'; import type { Config } from '../config/config.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { scanAllAutoMemoryTopicDocuments } from './scan.js'; import { selectRelevantAutoMemoryDocumentsByModel } from './relevanceSelector.js'; vi.mock('./scan.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - scanAutoMemoryTopicDocuments: vi.fn(), + scanAllAutoMemoryTopicDocuments: vi.fn(), // Explicit mock — recall now unions user-level docs into the pool, so // leaving this on the real implementation would silently fall through // to the filesystem (only "works" because the path doesn't exist and // listMarkdownFiles swallows ENOENT). Defaults to an empty pool. - scanUserAutoMemoryTopicDocuments: vi.fn().mockResolvedValue([]), + scanAllUserAutoMemoryTopicDocuments: vi.fn().mockResolvedValue([]), }; }); @@ -413,7 +413,7 @@ describe('auto-memory relevant recall', () => { }); it('uses model-driven selection when config is provided', async () => { - vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue(docs); + vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue(docs); vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockResolvedValue([ docs[0], ]); @@ -431,8 +431,63 @@ describe('auto-memory relevant recall', () => { expect(result.prompt).toContain('Reference Memory (reference.md)'); }); + it('bounds model candidates while retaining lexical and recent documents', async () => { + const lexicalDocs = Array.from({ length: 200 }, (_, index) => ({ + ...memoryDoc( + `lexical-${String(index).padStart(3, '0')}.md`, + 'reference', + `Overflow memory ${index}`, + 'Matching historical context', + '', + ), + mtimeMs: 0, + })); + const recentDocs = Array.from({ length: 20 }, (_, index) => ({ + ...memoryDoc( + `recent-${String(index).padStart(2, '0')}.md`, + 'reference', + `General memory ${index}`, + 'Unrelated recent context', + '', + ), + mtimeMs: 20 - index, + })); + const lexicalTarget = { + ...memoryDoc( + 'overflow-target.md', + 'reference', + 'Overflow Zephyr Marker', + 'Unique semantic target', + '', + ), + mtimeMs: 0, + }; + vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue([ + ...lexicalDocs, + ...recentDocs, + lexicalTarget, + ]); + vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockImplementation( + async (_config, _query, candidates) => + candidates.includes(lexicalTarget) ? [lexicalTarget] : [], + ); + + const result = await resolveRelevantAutoMemoryPromptForQuery( + '/tmp/project', + 'find the overflow zephyr marker', + { config: {} as Config }, + ); + + const modelCandidates = vi.mocked(selectRelevantAutoMemoryDocumentsByModel) + .mock.calls[0]![2]; + expect(modelCandidates).toHaveLength(200); + expect(modelCandidates[0]).toBe(lexicalTarget); + expect(modelCandidates.slice(-20)).toEqual(recentDocs); + expect(result.selectedDocs).toEqual([lexicalTarget]); + }); + it('falls back to heuristic selection when model-driven selection fails', async () => { - vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue(docs); + vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue(docs); vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockRejectedValue( new Error('selector failed'), ); @@ -456,9 +511,15 @@ describe('auto-memory relevant recall', () => { }); it('keeps active tool schemas out of heuristic fallback', async () => { - vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue(activeToolDocs); - vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockRejectedValue( - new Error('selector failed'), + vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue( + activeToolDocs, + ); + let modelCandidates: ScannedAutoMemoryDocument[] = []; + vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockImplementation( + async (_config, _query, candidates) => { + modelCandidates = candidates; + throw new Error('selector failed'); + }, ); const result = await resolveRelevantAutoMemoryPromptForQuery( @@ -470,6 +531,12 @@ describe('auto-memory relevant recall', () => { }, ); + expect(modelCandidates.map((doc) => doc.filePath)).not.toContain( + '/tmp/ata-tool.md', + ); + expect(modelCandidates.map((doc) => doc.filePath)).toContain( + '/tmp/ata-gotcha.md', + ); expect(result.strategy).toBe('heuristic'); expect(result.selectedDocs.map((doc) => doc.filePath)).not.toContain( '/tmp/ata-tool.md', diff --git a/packages/core/src/memory/recall.ts b/packages/core/src/memory/recall.ts index 094adca8436..17033486566 100644 --- a/packages/core/src/memory/recall.ts +++ b/packages/core/src/memory/recall.ts @@ -8,8 +8,8 @@ import * as path from 'node:path'; import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { - scanAutoMemoryTopicDocuments, - scanUserAutoMemoryTopicDocuments, + scanAllAutoMemoryTopicDocuments, + scanAllUserAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument, } from './scan.js'; import { memoryAge, memoryFreshnessText } from './memoryAge.js'; @@ -19,6 +19,8 @@ import { logMemoryRecall, MemoryRecallEvent } from '../telemetry/index.js'; const MAX_RELEVANT_DOCS = 5; const MAX_DOC_BODY_CHARS = 1_200; const MAX_HEURISTIC_QUERY_TOKENS = 64; +const MAX_MODEL_CANDIDATE_DOCS = 200; +const RECENT_MODEL_CANDIDATE_RESERVE = 20; const debugLogger = createDebugLogger('AUTO_MEMORY_RECALL'); const ACTIVE_TOOL_USAGE_MEMORY_MARKERS = [ @@ -221,6 +223,27 @@ export function selectRelevantAutoMemoryDocuments( .map(({ doc }) => doc); } +function selectModelCandidateDocuments( + query: string, + docs: ScannedAutoMemoryDocument[], + recentTools: readonly string[], +): ScannedAutoMemoryDocument[] { + const eligible = docs.filter( + (doc) => !isActiveToolUsageMemory(doc, recentTools), + ); + const lexical = selectRelevantAutoMemoryDocuments( + query, + eligible, + MAX_MODEL_CANDIDATE_DOCS - RECENT_MODEL_CANDIDATE_RESERVE, + ); + const selected = new Set(lexical.map((doc) => doc.filePath)); + const recent = eligible + .filter((doc) => !selected.has(doc.filePath)) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(0, MAX_MODEL_CANDIDATE_DOCS - lexical.length); + return [...lexical, ...recent]; +} + function truncateBody(body: string): string { const normalized = normalizeBody(body); if (normalized.length <= MAX_DOC_BODY_CHARS) { @@ -299,19 +322,16 @@ export async function resolveRelevantAutoMemoryPromptForQuery( // recall returns nothing at all for the rest of the session. Project- // level scan failures still bubble — they're the only mandatory side. const [projectDocs, userDocs] = await Promise.all([ - scanAutoMemoryTopicDocuments(projectRoot), - scanUserAutoMemoryTopicDocuments().catch((error: unknown) => { + scanAllAutoMemoryTopicDocuments(projectRoot), + scanAllUserAutoMemoryTopicDocuments().catch((error: unknown) => { debugLogger.warn( `User-level auto-memory scan failed; project-level recall continues: ${error instanceof Error ? error.message : String(error)}`, ); return []; }), ]); - // Project-level docs come first as a soft hint to the model-based - // selector and, in the heuristic fallback (`selectRelevantAutoMemoryDocuments`), - // as the stable-sort tie-breaker — matching the PR's "project shadows - // user" precedence. The model selector ranks by its own judgement so - // this ordering is advisory there, not enforced. + // Project-level docs come first as the stable tie-break when later ranking + // keys match. const docs = filterExcludedAutoMemoryDocuments( [...projectDocs, ...userDocs], options.excludedFilePaths, @@ -340,10 +360,15 @@ export async function resolveRelevantAutoMemoryPromptForQuery( if (options.config) { try { + const modelCandidates = selectModelCandidateDocuments( + query, + docs, + options.recentTools ?? [], + ); const selectedDocs = await selectRelevantAutoMemoryDocumentsByModel( options.config, query, - docs, + modelCandidates, limit, options.recentTools ?? [], options.abortSignal, diff --git a/packages/core/src/memory/relevanceSelector.test.ts b/packages/core/src/memory/relevanceSelector.test.ts index 0c82fd807d2..da077e9d3b0 100644 --- a/packages/core/src/memory/relevanceSelector.test.ts +++ b/packages/core/src/memory/relevanceSelector.test.ts @@ -272,4 +272,68 @@ describe('selectRelevantAutoMemoryDocumentsByModel', () => { '/qwen/memories/user/role.md', ]); }); + + it('bounds the model manifest by UTF-8 bytes', async () => { + const largeDocs = Array.from({ length: 200 }, (_, index) => ({ + ...docs[0], + filePath: `/tmp/bounded-${index}.md`, + relativePath: `bounded-${index}.md`, + filename: `bounded-${index}.md`, + description: `${'界'.repeat(511)}😀${'x'.repeat(2_000)}`, + mtimeMs: index, + })); + vi.mocked(runSideQuery).mockImplementation(async (_config, options) => { + const error = options.validate?.({ + selected_memories: ['/tmp/bounded-199.md'], + }); + if (error) { + throw new Error(error); + } + return { selected_memories: [] }; + }); + + await expect( + selectRelevantAutoMemoryDocumentsByModel( + mockConfig, + 'semantic-only request', + largeDocs, + 5, + ), + ).rejects.toThrow('Recall selector returned unknown file path'); + + const content = vi.mocked(runSideQuery).mock.calls[0]![1].contents[0]; + const text = content?.parts?.[0]?.text ?? ''; + const manifest = text.split('Available memories:\n')[1] ?? ''; + expect(manifest).toContain('/tmp/bounded-0.md'); + expect(manifest).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/); + expect(manifest).not.toContain('x'); + expect(Buffer.byteLength(manifest, 'utf8')).toBeLessThanOrEqual(25_000); + }); + + it('does not let long descriptions starve lexical-first candidates', async () => { + const longDocs = Array.from({ length: 20 }, (_, index) => ({ + ...docs[0], + filePath: `/tmp/recent-${index}.md`, + description: '界'.repeat(512), + })); + const lexicalDoc = { + ...docs[1], + filePath: '/tmp/lexical-target.md', + }; + vi.mocked(runSideQuery).mockResolvedValue({ + selected_memories: [lexicalDoc.filePath], + }); + + await expect( + selectRelevantAutoMemoryDocumentsByModel( + mockConfig, + 'find the lexical target', + [lexicalDoc, ...longDocs], + 5, + ), + ).resolves.toEqual([lexicalDoc]); + + const content = vi.mocked(runSideQuery).mock.calls[0]![1].contents[0]; + expect(content?.parts?.[0]?.text).toContain(lexicalDoc.filePath); + }); }); diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts index 01b905876b6..a89b5e7a2e9 100644 --- a/packages/core/src/memory/relevanceSelector.ts +++ b/packages/core/src/memory/relevanceSelector.ts @@ -35,6 +35,8 @@ interface RecallSelectorResponse { selected_memories: string[]; } +const MAX_MODEL_MANIFEST_BYTES = 25_000; + /** * Format memory headers as a text manifest: one line per file with * [type] filePath (ISO-timestamp): description. @@ -49,16 +51,32 @@ interface RecallSelectorResponse { * Selector sees only the header (type, path, age, description), not the * body content. */ -function formatMemoryManifest(docs: ScannedAutoMemoryDocument[]): string { - return docs - .map((doc) => { - const tag = `[${doc.type}] `; - const ts = new Date(doc.mtimeMs).toISOString(); - return doc.description - ? `- ${tag}${doc.filePath} (${ts}): ${doc.description}` - : `- ${tag}${doc.filePath} (${ts})`; - }) - .join('\n'); +function formatMemoryManifest(docs: ScannedAutoMemoryDocument[]): { + manifest: string; + includedDocs: ScannedAutoMemoryDocument[]; +} { + const lines: string[] = []; + const includedDocs: ScannedAutoMemoryDocument[] = []; + let bytes = 0; + + for (const doc of docs) { + const tag = `[${doc.type}] `; + const ts = new Date(doc.mtimeMs).toISOString(); + const line = doc.description + ? `- ${tag}${doc.filePath} (${ts}): ${doc.description.slice(0, 512).replace(/[\uD800-\uDBFF]$/, '')}` + : `- ${tag}${doc.filePath} (${ts})`; + const nextBytes = Buffer.byteLength( + `${lines.length > 0 ? '\n' : ''}${line}`, + ); + if (bytes + nextBytes > MAX_MODEL_MANIFEST_BYTES) { + continue; + } + lines.push(line); + includedDocs.push(doc); + bytes += nextBytes; + } + + return { manifest: lines.join('\n'), includedDocs }; } export async function selectRelevantAutoMemoryDocumentsByModel( @@ -73,7 +91,10 @@ export async function selectRelevantAutoMemoryDocumentsByModel( return []; } - const manifest = formatMemoryManifest(docs); + const { manifest, includedDocs } = formatMemoryManifest(docs); + if (includedDocs.length === 0) { + return []; + } // When the assistant is actively using a tool, surfacing that tool's // reference docs is noise. Pass the tool list so the selector can skip them. @@ -93,8 +114,8 @@ export async function selectRelevantAutoMemoryDocumentsByModel( }, ]; - const validFilePaths = new Set(docs.map((doc) => doc.filePath)); - const byFilePath = new Map(docs.map((doc) => [doc.filePath, doc])); + const validFilePaths = new Set(includedDocs.map((doc) => doc.filePath)); + const byFilePath = new Map(includedDocs.map((doc) => [doc.filePath, doc])); const response = await runSideQuery(config, { purpose: 'auto-memory-recall', diff --git a/packages/core/src/memory/scan.ts b/packages/core/src/memory/scan.ts index a300d7e7d3f..6ff81997d60 100644 --- a/packages/core/src/memory/scan.ts +++ b/packages/core/src/memory/scan.ts @@ -111,7 +111,7 @@ async function listMarkdownFiles(root: string): Promise { async function scanAutoMemoryDocumentsFromRoot( root: string, - opts: { deterministic?: boolean } = {}, + opts: { deterministic?: boolean; uncapped?: boolean } = {}, ): Promise { const relativePaths = await listMarkdownFiles(root); const docs = await Promise.all( @@ -159,7 +159,7 @@ async function scanAutoMemoryDocumentsFromRoot( : valid.sort( (a, b) => b.mtimeMs - a.mtimeMs || a.filename.localeCompare(b.filename), ); - return ordered.slice(0, MAX_SCANNED_MEMORY_FILES); + return opts.uncapped ? ordered : ordered.slice(0, MAX_SCANNED_MEMORY_FILES); } export async function scanAutoMemoryTopicDocuments( @@ -168,6 +168,16 @@ export async function scanAutoMemoryTopicDocuments( return scanAutoMemoryDocumentsFromRoot(getAutoMemoryRoot(projectRoot)); } +export async function scanAllAutoMemoryTopicDocuments( + projectRoot: string, +): Promise { + // ponytail: reuse the existing O(n) parsed scan; add a catalog only if + // measured topic counts make recall scanning too slow. + return scanAutoMemoryDocumentsFromRoot(getAutoMemoryRoot(projectRoot), { + uncapped: true, + }); +} + /** * Scan the user-level (cross-project) auto-memory dir. Returns an empty * array when the dir does not exist yet, so callers can union with @@ -179,6 +189,14 @@ export async function scanUserAutoMemoryTopicDocuments(): Promise< return scanAutoMemoryDocumentsFromRoot(getUserAutoMemoryRoot()); } +export async function scanAllUserAutoMemoryTopicDocuments(): Promise< + ScannedAutoMemoryDocument[] +> { + return scanAutoMemoryDocumentsFromRoot(getUserAutoMemoryRoot(), { + uncapped: true, + }); +} + /** * Scan the team (in-repo, git-tracked) auto-memory dir. Returns an empty * array when the dir does not exist yet.