Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/design/2026-08-08-native-memory-recall-reliability.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,6 @@ flatters every design; the floor is what makes the headline readable.
- Recall can see older documents outside the shared 200-document scanner cap,
but non-recall callers, including Forget, keep the existing capped scanner.
A broader manageability pass is separate from this recall-only change.
Superseded for Forget: issue #9378 moved Forget to the uncapped scanner so a
recallable entry is always removable. Indexer, Status, and Extraction remain
capped.
4 changes: 3 additions & 1 deletion docs/design/2026-08-09-bounded-memory-recall-candidates.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ is kept.

Forget, Indexer, Status, and Extraction keep the capped scanner. That preserves
their current behavior but means an older document can become recallable before
it becomes manageable by those non-recall flows.
it becomes manageable by those non-recall flows. Superseded for Forget: issue
#9378 moved Forget to the uncapped scanner, with its own bound on the
model-selection prompt. Indexer, Status, and Extraction remain capped.

## Failure and compatibility boundaries

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/memory/extractionAgentPlanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ function truncate(text: string, maxChars: number): string {
}

async function buildTopicSummaryBlock(projectRoot: string): Promise<string> {
// Deliberately capped, unlike recall (recall.ts) and forget (forget.ts):
// every doc is rendered into the extraction agent's task prompt below, so
// an uncapped scan would grow that prompt without bound. Anything past the
// cap stays reachable — the agent holds read_file/grep/glob/ls.
// User-level scan is best-effort: a read failure on `~/.qwen/memories/`
// must not deny the extraction agent its view of existing project-level
// memories (which it uses to avoid creating duplicates).
Expand Down
111 changes: 94 additions & 17 deletions packages/core/src/memory/forget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import * as path from 'node:path';
import type { Config } from '../config/config.js';
import { runSideQuery } from '../utils/sideQuery.js';
import {
scanAutoMemoryTopicDocuments,
scanUserAutoMemoryTopicDocuments,
scanAllAutoMemoryTopicDocuments,
scanAllUserAutoMemoryTopicDocuments,
} from './scan.js';
import {
forgetManagedAutoMemoryMatches,
Expand All @@ -31,9 +31,10 @@ vi.mock('../utils/sideQuery.js', () => ({
runSideQuery: vi.fn(),
}));

vi.mock('./scan.js', () => ({
scanAutoMemoryTopicDocuments: vi.fn(),
scanUserAutoMemoryTopicDocuments: vi.fn(),
vi.mock('./scan.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./scan.js')>()),
scanAllAutoMemoryTopicDocuments: vi.fn(),
scanAllUserAutoMemoryTopicDocuments: vi.fn(),
}));

describe('selectManagedAutoMemoryForgetCandidates', () => {
Expand All @@ -46,8 +47,8 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
vi.resetAllMocks();
vi.mocked(mockConfig.getModel).mockReturnValue('main-model');
vi.mocked(mockConfig.getFastModel).mockReturnValue('fast-model');
vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue([
{
type: 'user',
filePath: '/tmp/auto/user/note.md',
Expand Down Expand Up @@ -85,6 +86,82 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
);
});

it('bounds the model prompt but keeps a matching entry that ranks past the bound', async () => {
// 500 documents: the newest 499 are noise, the oldest one matches the
// query. A plain recency slice would drop it; the bound must not.
const docs = Array.from({ length: 499 }, (_, index) => ({
type: 'reference' as const,
filePath: `/tmp/project/memory/reference/noise-${index}.md`,
relativePath: `reference/noise-${index}.md`,
filename: `noise-${index}.md`,
title: `Noise ${index}`,
description: 'Unrelated',
body: 'Unrelated historical note',
mtimeMs: 1_000 + index,
}));
docs.push({
type: 'reference' as const,
filePath: '/tmp/project/memory/reference/overflow.md',
relativePath: 'reference/overflow.md',
filename: 'overflow.md',
title: 'Overflow',
description: 'Oldest',
body: 'the saved codeword is overflow-zephyr-7040',
mtimeMs: 1,
});
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue(docs);
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(runSideQuery).mockResolvedValue({ selectedCandidateIds: [] });

await selectManagedAutoMemoryForgetCandidates(
'/tmp/project',
'overflow-zephyr-7040',
{ config: mockConfig },
);

const options = vi.mocked(runSideQuery).mock.calls[0]?.[1];
const prompt = options?.contents[0]?.parts?.[0]?.text ?? '';
expect(prompt.match(/^id: /gm)).toHaveLength(400);
expect(prompt).toContain('id: project:reference/overflow.md');
});

it('falls back to the full uncapped candidate list when the model fails', async () => {
const docs = Array.from({ length: 500 }, (_, index) => ({
type: 'reference' as const,
filePath: `/tmp/project/memory/reference/noise-${index}.md`,
relativePath: `reference/noise-${index}.md`,
filename: `noise-${index}.md`,
title: `Noise ${index}`,
description: 'Unrelated',
body: 'Unrelated historical note',
mtimeMs: 1_000 + index,
}));
docs.push({
type: 'reference' as const,
filePath: '/tmp/project/memory/reference/overflow.md',
relativePath: 'reference/overflow.md',
filename: 'overflow.md',
title: 'Overflow',
description: 'Oldest',
body: 'the saved codeword is overflow-zephyr-7040',
mtimeMs: 1,
});
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue(docs);
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(runSideQuery).mockRejectedValue(new Error('side query failed'));

const result = await selectManagedAutoMemoryForgetCandidates(
'/tmp/project',
'overflow-zephyr-7040',
{ config: mockConfig },
);

expect(result.strategy).toBe('heuristic');
expect(result.matches.map((match) => match.filePath)).toContain(
'/tmp/project/memory/reference/overflow.md',
);
});

it('wraps the forget query as user data in the selector prompt', async () => {
vi.mocked(runSideQuery).mockResolvedValue({
selectedCandidateIds: [],
Expand All @@ -105,7 +182,7 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
});

it('indexes user and project candidates with scope-prefixed ids', async () => {
vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([
{
type: 'user',
filePath: '/tmp/user/memories/user/note.md',
Expand All @@ -117,7 +194,7 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
mtimeMs: 2,
},
]);
vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue([
{
type: 'project',
filePath: '/tmp/project/memory/user/note.md',
Expand Down Expand Up @@ -163,8 +240,8 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
});

it('can select user-level memories through heuristic search', async () => {
vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([
{
type: 'user',
filePath: '/tmp/user/memories/user/editor.md',
Expand Down Expand Up @@ -393,8 +470,8 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
].join('\n'),
'utf-8',
);
vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([]);

const result = await forgetManagedAutoMemoryMatches(
projectRoot,
Expand Down Expand Up @@ -474,8 +551,8 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
].join('\n'),
'utf-8',
);
vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([]);

const result = await forgetManagedAutoMemoryMatches(
projectRoot,
Expand Down Expand Up @@ -552,8 +629,8 @@ describe('selectManagedAutoMemoryForgetCandidates', () => {
'utf-8',
);
await fs.mkdir(getUserAutoMemoryIndexPath(), { recursive: true });
vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllAutoMemoryTopicDocuments).mockResolvedValue([]);
vi.mocked(scanAllUserAutoMemoryTopicDocuments).mockResolvedValue([]);

const result = await forgetManagedAutoMemoryMatches(
projectRoot,
Expand Down
51 changes: 46 additions & 5 deletions packages/core/src/memory/forget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,23 @@ import {
isUserAutoMemPath,
} from './paths.js';
import {
scanAutoMemoryTopicDocuments,
scanUserAutoMemoryTopicDocuments,
scanAllAutoMemoryTopicDocuments,
scanAllUserAutoMemoryTopicDocuments,
} from './scan.js';
import { ensureAutoMemoryScaffold } from './store.js';
import type { AutoMemoryMetadata, AutoMemoryType } from './types.js';

const debugLogger = createDebugLogger('MEMORY_FORGET');

/**
* Upper bound on the candidates interpolated into the model-selection prompt.
* The scan is uncapped, but `buildForgetSelectionPrompt` renders every
* candidate, so the prompt needs its own bound. 400 is the exposure the capped
* scan already permitted at steady state: 200 project + 200 user documents at
* the one-memory-per-file the extraction format writes.
*/
const MAX_MODEL_FORGET_CANDIDATES = 400;

export interface AutoMemoryForgetMatch {
topic: AutoMemoryType;
summary: string;
Expand Down Expand Up @@ -62,6 +71,7 @@ interface IndexedForgetCandidate extends AutoMemoryForgetMatch {
storageScope: AutoMemoryStorageScope;
why?: string;
howToApply?: string;
mtimeMs: number;
}

export type AutoMemoryStorageScope = 'user' | 'project';
Expand Down Expand Up @@ -94,9 +104,11 @@ async function listIndexedForgetCandidates(
abortSignal?: AbortSignal,
): Promise<IndexedForgetCandidate[]> {
abortSignal?.throwIfAborted();
// Uncapped, to match the recall universe (recall.ts scans uncapped): an
// entry that recall can inject must be one that forget can remove.
const [projectDocs, userDocs] = await Promise.all([
scanAutoMemoryTopicDocuments(projectRoot),
scanUserAutoMemoryTopicDocuments(),
scanAllAutoMemoryTopicDocuments(projectRoot),
scanAllUserAutoMemoryTopicDocuments(),
]);
abortSignal?.throwIfAborted();
const candidates: IndexedForgetCandidate[] = [];
Expand Down Expand Up @@ -124,6 +136,7 @@ async function listIndexedForgetCandidates(
entryIndex: i,
why: entry.why,
howToApply: entry.howToApply,
mtimeMs: doc.mtimeMs,
});
}
}
Expand Down Expand Up @@ -164,6 +177,34 @@ function buildForgetSelectionPrompt(
].join('\n');
}

/**
* Bound the model prompt without dropping anything the heuristic fallback
* would have matched: literal query matches go in first, then the most
* recently modified remainder fills the budget. A semantic-only match ranked
* past the budget can still be missed by the model path; the heuristic
* fallback below keeps scanning the full uncapped list.
*/
function selectModelForgetCandidates(
candidates: IndexedForgetCandidate[],
query: string,
): IndexedForgetCandidate[] {
if (candidates.length <= MAX_MODEL_FORGET_CANDIDATES) {
return candidates;
}
const queryLower = query.replace(/\s+/g, ' ').trim().toLowerCase();
const matched: IndexedForgetCandidate[] = [];
const rest: IndexedForgetCandidate[] = [];
for (const candidate of candidates) {
if (buildAutoMemoryEntrySearchText(candidate).includes(queryLower)) {
matched.push(candidate);
} else {
rest.push(candidate);
}
}
rest.sort((a, b) => b.mtimeMs - a.mtimeMs);
return [...matched, ...rest].slice(0, MAX_MODEL_FORGET_CANDIDATES);
}

async function selectByModel(
candidates: IndexedForgetCandidate[],
query: string,
Expand Down Expand Up @@ -271,7 +312,7 @@ export async function selectManagedAutoMemoryForgetCandidates(
if (options.config) {
try {
return await selectByModel(
candidates,
selectModelForgetCandidates(candidates, query),
query,
options.config,
limit,
Expand Down
Loading
Loading