diff --git a/docs/design/daemon-workspace-remember.md b/docs/design/daemon-workspace-remember.md index c57f099d09..47d1fd6e80 100644 --- a/docs/design/daemon-workspace-remember.md +++ b/docs/design/daemon-workspace-remember.md @@ -213,7 +213,8 @@ The initial response is `202 Accepted` with a `forget-...` task id. Poll "filePath": "/path/to/memory.md" } ], - "touchedTopics": ["project"] + "touchedTopics": ["project"], + "touchedScopes": ["project"] } ``` @@ -401,7 +402,7 @@ to the per-session event stream receive this notification. | `scope` | `"managed"` | Discriminates from file-based `memory_changed` events | | `source` | `string` | `"workspace_memory_remember"`, `"workspace_memory_forget"`, or `"workspace_memory_dream"` | | `taskId` | `string` | Correlates with the task returned by POST | -| `touchedScopes` | `string[]` | Which memory scopes were written: `"user"`, `"project"` | +| `touchedScopes` | `string[]` | Which managed memory scopes changed: `"user"`, `"project"` | The `originatorClientId` (if provided at POST time) is attached to the event envelope so the event bus can route it to the originating client. diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 7b0c93afaa..a2849340e0 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -589,6 +589,7 @@ describe('createAcpSessionBridge', () => { expect(handles[0]?.agent.newSessionCalls).toHaveLength(0); expect(handles[0]?.agent.loadSessionCalls).toHaveLength(0); expect(handles[0]?.agent.resumeSessionCalls).toHaveLength(0); + expect(handles[0]?.agent.promptCalls).toHaveLength(0); expect(bridge.listWorkspaceSessions(WS_A)).toEqual([]); await bridge.shutdown(); @@ -644,6 +645,7 @@ describe('createAcpSessionBridge', () => { }, ], touchedTopics: ['project'], + touchedScopes: ['project'], querySeen: params['query'], }; } @@ -662,6 +664,7 @@ describe('createAcpSessionBridge', () => { expect(result).toMatchObject({ summary: 'forgot', touchedTopics: ['project'], + touchedScopes: ['project'], removedEntries: [ { topic: 'project', @@ -679,11 +682,45 @@ describe('createAcpSessionBridge', () => { expect(handles[0]?.agent.newSessionCalls).toHaveLength(0); expect(handles[0]?.agent.loadSessionCalls).toHaveLength(0); expect(handles[0]?.agent.resumeSessionCalls).toHaveLength(0); + expect(handles[0]?.agent.promptCalls).toHaveLength(0); expect(bridge.listWorkspaceSessions(WS_A)).toEqual([]); await bridge.shutdown(); }); + it('derives workspace memory forget scopes for legacy responses', async () => { + const handles: ChannelHandle[] = []; + const bridge = makeBridge({ + channelFactory: async () => { + const h = makeChannel({ + extMethodImpl: (method) => { + if (method === 'qwen/control/workspace/memory/forget') { + return { + summary: 'forgot', + removedEntries: [], + touchedTopics: ['feedback', 'project'], + }; + } + throw new Error(`unexpected extMethod ${method}`); + }, + }); + handles.push(h); + return h.channel; + }, + }); + + const result = await bridge.runWorkspaceMemoryForget({ + query: 'old preference', + }); + + expect(result).toMatchObject({ + touchedTopics: ['feedback', 'project'], + touchedScopes: ['user', 'project'], + }); + + await bridge.shutdown(); + }); + it('rejects malformed workspace memory forget responses', async () => { const handles: ChannelHandle[] = []; const bridge = makeBridge({ diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index b18ddd8c7e..e4d586de7a 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -637,6 +637,20 @@ function isBridgeAutoMemoryTopic( ); } +function touchedScopesFromTopics( + topics: BridgeAutoMemoryTopic[], +): Array<'user' | 'project'> { + const scopes = new Set<'user' | 'project'>(); + for (const topic of topics) { + scopes.add(topic === 'user' || topic === 'feedback' ? 'user' : 'project'); + } + return (['user', 'project'] as const).filter((scope) => scopes.has(scope)); +} + +function isBridgeMemoryScope(value: unknown): value is 'user' | 'project' { + return value === 'user' || value === 'project'; +} + function parseWorkspaceMemoryForgetMatch( value: unknown, ): BridgeWorkspaceMemoryForgetMatch | null { @@ -672,6 +686,7 @@ function parseWorkspaceMemoryForgetResult( const summary = record['summary']; const removedEntries = record['removedEntries']; const touchedTopics = record['touchedTopics']; + const touchedScopes = record['touchedScopes']; const parsedRemovedEntries = Array.isArray(removedEntries) ? removedEntries.map(parseWorkspaceMemoryForgetMatch) : []; @@ -680,14 +695,22 @@ function parseWorkspaceMemoryForgetResult( !Array.isArray(removedEntries) || parsedRemovedEntries.some((entry) => entry === null) || !Array.isArray(touchedTopics) || - !touchedTopics.every(isBridgeAutoMemoryTopic) + !touchedTopics.every(isBridgeAutoMemoryTopic) || + (touchedScopes !== undefined && + (!Array.isArray(touchedScopes) || + !touchedScopes.every(isBridgeMemoryScope))) ) { throw new Error('Malformed workspace memory forget response'); } + const parsedTouchedTopics = touchedTopics as BridgeAutoMemoryTopic[]; return { ...(summary === undefined ? {} : { summary }), removedEntries: parsedRemovedEntries as BridgeWorkspaceMemoryForgetMatch[], - touchedTopics: touchedTopics as BridgeAutoMemoryTopic[], + touchedTopics: parsedTouchedTopics, + touchedScopes: + touchedScopes === undefined + ? touchedScopesFromTopics(parsedTouchedTopics) + : (touchedScopes as Array<'user' | 'project'>), }; } diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index f34397ccae..3ddd052caa 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -198,6 +198,7 @@ export interface BridgeWorkspaceMemoryForgetResult { summary?: string; removedEntries: BridgeWorkspaceMemoryForgetMatch[]; touchedTopics: BridgeAutoMemoryTopic[]; + touchedScopes: Array<'user' | 'project'>; } export interface BridgeWorkspaceMemoryDreamResult { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 47e9dc0e27..611106a4a6 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -3441,6 +3441,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ], touchedTopics: ['project'], + touchedScopes: ['project'], }); Object.assign(mockConfig, { isManagedMemoryAvailable: vi.fn().mockReturnValue(true), @@ -3478,6 +3479,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ], touchedTopics: ['project'], + touchedScopes: ['project'], }); expect(forget).toHaveBeenCalledWith('/workspace', 'old preference', { config: expect.objectContaining({ diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index f95b761612..11b89e0e70 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -5890,6 +5890,7 @@ class QwenAgent implements Agent { formatWorkspaceMemoryForgetSummary(result.removedEntries.length), removedEntries: result.removedEntries, touchedTopics: result.touchedTopics, + touchedScopes: result.touchedScopes, } as unknown as Record; } catch (err) { if (err instanceof RequestError) { diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 33cab840f2..5aff09abeb 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -488,7 +488,12 @@ class FakeBridge { return { summary: 'remembered', filesTouched: [], touchedScopes: [] }; } async runWorkspaceMemoryForget() { - return { summary: 'forgot', removedEntries: [], touchedTopics: [] }; + return { + summary: 'forgot', + removedEntries: [], + touchedTopics: [], + touchedScopes: [], + }; } async runWorkspaceMemoryDream() { return { summary: 'dreamed', touchedTopics: [], dedupedEntries: 0 }; diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a89baabad6..9346cdc413 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -596,6 +596,7 @@ interface FakeBridgeOpts { filePath: string; }>; touchedTopics: Array<'user' | 'feedback' | 'project' | 'reference'>; + touchedScopes: Array<'user' | 'project'>; }>; workspaceMemoryDreamImpl?: () => Promise<{ summary?: string; @@ -883,6 +884,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { summary: 'forgot', removedEntries: [], touchedTopics: [], + touchedScopes: [], })); const workspaceMemoryDreamImpl = opts.workspaceMemoryDreamImpl ?? @@ -10654,16 +10656,12 @@ describe('createServeApp', () => { await writeSession(sid); let firstCloseStarted!: () => void; let releaseFirstClose!: () => void; - let secondCloseStarted!: () => void; const firstCloseStartedPromise = new Promise((resolve) => { firstCloseStarted = resolve; }); const firstCloseReleasedPromise = new Promise((resolve) => { releaseFirstClose = resolve; }); - const secondCloseStartedPromise = new Promise((resolve) => { - secondCloseStarted = resolve; - }); let closeCount = 0; const bridge = fakeBridge({ closeImpl: async () => { @@ -10671,8 +10669,6 @@ describe('createServeApp', () => { if (closeCount === 1) { firstCloseStarted(); await firstCloseReleasedPromise; - } else { - secondCloseStarted(); } }, }); @@ -10694,23 +10690,19 @@ describe('createServeApp', () => { .send({ sessionIds: [sid] }) .then((res) => res); - const raceResult = await Promise.race([ - secondCloseStartedPromise.then(() => 'archive-started'), - new Promise((resolve) => setTimeout(() => resolve('blocked'), 25)), - ]); + const archiveRes = await archivePromise; releaseFirstClose(); try { - expect(raceResult).toBe('blocked'); - const deleteRes = await deletePromise; - expect(deleteRes.status).toBe(200); - expect(deleteRes.body.removed).toEqual([sid]); - const archiveRes = await archivePromise; + expect(closeCount).toBe(1); expect(archiveRes.status).toBe(409); expect(archiveRes.body).toMatchObject({ code: 'session_archiving', sessionId: sid, }); + const deleteRes = await deletePromise; + expect(deleteRes.status).toBe(200); + expect(deleteRes.body.removed).toEqual([sid]); } finally { await Promise.allSettled([deletePromise, archivePromise]); } diff --git a/packages/cli/src/serve/workspace-remember.test.ts b/packages/cli/src/serve/workspace-remember.test.ts index c259eaa69a..b0350641ea 100644 --- a/packages/cli/src/serve/workspace-remember.test.ts +++ b/packages/cli/src/serve/workspace-remember.test.ts @@ -104,6 +104,7 @@ function buildBridgeStub(opts: { }, ], touchedTopics: ['project'], + touchedScopes: ['project'], })); const dreamImpl = opts.dreamImpl ?? @@ -305,6 +306,7 @@ describe('workspace memory remember routes', () => { }, ], touchedTopics: ['user', 'reference'], + touchedScopes: ['user'], }), ), }); @@ -331,6 +333,7 @@ describe('workspace memory remember routes', () => { result: { summary: 'forgot', touchedTopics: ['user', 'reference'], + touchedScopes: ['user'], removedEntries: [ { topic: 'user', @@ -348,7 +351,7 @@ describe('workspace memory remember routes', () => { scope: 'managed', source: 'workspace_memory_forget', taskId, - touchedScopes: ['user', 'project'], + touchedScopes: ['user'], }, }); }); @@ -603,6 +606,7 @@ describe('workspace memory remember routes', () => { pendingForget.resolve({ removedEntries: [], touchedTopics: [], + touchedScopes: [], }); }); @@ -719,7 +723,11 @@ describe('workspace memory remember routes', () => { remember.resolve({ filesTouched: [], touchedScopes: [] }); await waitFor(() => starts.length === 2); expect(starts).toEqual(['remember', 'forget']); - forget.resolve({ removedEntries: [], touchedTopics: [] }); + forget.resolve({ + removedEntries: [], + touchedTopics: [], + touchedScopes: [], + }); await waitFor(() => starts.length === 3); expect(starts).toEqual(['remember', 'forget', 'dream']); dream.resolve({ touchedTopics: [], dedupedEntries: 0 }); @@ -754,6 +762,7 @@ describe('workspace memory remember routes', () => { summary: 'nothing matched', removedEntries: [], touchedTopics: [], + touchedScopes: [], })), dreamImpl: vi.fn(async () => ({ summary: 'nothing changed', diff --git a/packages/cli/src/serve/workspace-remember.ts b/packages/cli/src/serve/workspace-remember.ts index e990930b49..7f837aa6e9 100644 --- a/packages/cli/src/serve/workspace-remember.ts +++ b/packages/cli/src/serve/workspace-remember.ts @@ -132,6 +132,7 @@ function cloneTask( ...entry, })), touchedTopics: [...task.result.touchedTopics], + touchedScopes: [...task.result.touchedScopes], } : undefined, }; @@ -392,6 +393,7 @@ export class WorkspaceRememberTaskLane { formatWorkspaceMemoryForgetSummary(result.removedEntries.length), removedEntries: result.removedEntries, touchedTopics: result.touchedTopics, + touchedScopes: result.touchedScopes, }; task.updatedAt = nowIso(); } catch (err) { @@ -413,7 +415,7 @@ export class WorkspaceRememberTaskLane { this.publishManagedMemoryChanged({ source: 'workspace_memory_forget', taskId: task.taskId, - touchedScopes: touchedScopesFromTopics(task.result.touchedTopics), + touchedScopes: task.result.touchedScopes, ...(params.originatorClientId ? { originatorClientId: params.originatorClientId } : {}), diff --git a/packages/core/src/memory/forget.test.ts b/packages/core/src/memory/forget.test.ts index eb9858c359..61c5acec60 100644 --- a/packages/core/src/memory/forget.test.ts +++ b/packages/core/src/memory/forget.test.ts @@ -10,11 +10,22 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { Config } from '../config/config.js'; import { runSideQuery } from '../utils/sideQuery.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { + scanAutoMemoryTopicDocuments, + scanUserAutoMemoryTopicDocuments, +} from './scan.js'; import { forgetManagedAutoMemoryMatches, selectManagedAutoMemoryForgetCandidates, } from './forget.js'; +import { + clearAutoMemoryRootCache, + getAutoMemoryIndexPath, + getAutoMemoryMetadataPath, + getAutoMemoryRoot, + getUserAutoMemoryIndexPath, + getUserAutoMemoryRoot, +} from './paths.js'; vi.mock('../utils/sideQuery.js', () => ({ runSideQuery: vi.fn(), @@ -22,6 +33,7 @@ vi.mock('../utils/sideQuery.js', () => ({ vi.mock('./scan.js', () => ({ scanAutoMemoryTopicDocuments: vi.fn(), + scanUserAutoMemoryTopicDocuments: vi.fn(), })); describe('selectManagedAutoMemoryForgetCandidates', () => { @@ -34,6 +46,7 @@ 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([ { type: 'user', @@ -91,6 +104,97 @@ describe('selectManagedAutoMemoryForgetCandidates', () => { expect(prompt).toContain(''); }); + it('indexes user and project candidates with scope-prefixed ids', async () => { + vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([ + { + type: 'user', + filePath: '/tmp/user/memories/user/note.md', + relativePath: 'user/note.md', + filename: 'note.md', + title: 'User note', + description: 'User note', + body: 'User duplicate path preference', + mtimeMs: 2, + }, + ]); + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([ + { + type: 'project', + filePath: '/tmp/project/memory/user/note.md', + relativePath: 'user/note.md', + filename: 'note.md', + title: 'Project note', + description: 'Project note', + body: 'Project duplicate path preference', + mtimeMs: 1, + }, + ]); + vi.mocked(runSideQuery).mockImplementation(async (_config, options) => { + const prompt = options.contents[0]?.parts?.[0]?.text ?? ''; + expect(prompt).toContain('id: user:user/note.md'); + expect(prompt).toContain('scope: user'); + expect(prompt).toContain('id: project:user/note.md'); + expect(prompt).toContain('scope: project'); + return { + selectedCandidateIds: ['user:user/note.md', 'project:user/note.md'], + }; + }); + + const result = await selectManagedAutoMemoryForgetCandidates( + '/tmp/project', + 'duplicate path preference', + { config: mockConfig }, + ); + + expect(result.matches).toEqual([ + { + topic: 'user', + summary: 'User duplicate path preference', + filePath: '/tmp/user/memories/user/note.md', + entryIndex: 0, + }, + { + topic: 'project', + summary: 'Project duplicate path preference', + filePath: '/tmp/project/memory/user/note.md', + entryIndex: 0, + }, + ]); + }); + + it('can select user-level memories through heuristic search', async () => { + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]); + vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([ + { + type: 'user', + filePath: '/tmp/user/memories/user/editor.md', + relativePath: 'user/editor.md', + filename: 'editor.md', + title: 'Editor', + description: 'Editor preference', + body: 'Prefers compact editor output', + mtimeMs: 1, + }, + ]); + + const result = await selectManagedAutoMemoryForgetCandidates( + '/tmp/project', + 'compact editor output', + ); + + expect(result).toEqual({ + strategy: 'heuristic', + matches: [ + { + topic: 'user', + summary: 'Prefers compact editor output', + filePath: '/tmp/user/memories/user/editor.md', + entryIndex: 0, + }, + ], + }); + }); + it('forwards caller abort signal to the model selector', async () => { const callerController = new AbortController(); let capturedSignal: AbortSignal | undefined; @@ -197,6 +301,7 @@ describe('selectManagedAutoMemoryForgetCandidates', () => { entryIndex: 1, }, ]); + expect(result.touchedScopes).toEqual(['project']); const updated = await fs.readFile(memoryFile, 'utf-8'); expect(updated).toContain('Duplicate summary'); expect(updated).toContain('first reason'); @@ -253,6 +358,7 @@ describe('selectManagedAutoMemoryForgetCandidates', () => { entryIndex: 0, }, ]); + expect(result.touchedScopes).toEqual(['project']); const updated = await fs.readFile(memoryFile, 'utf-8'); expect(updated).toContain('Other summary'); expect(updated).toContain('should stay'); @@ -262,4 +368,219 @@ describe('selectManagedAutoMemoryForgetCandidates', () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it('deletes user-level memory and rebuilds only the user index', async () => { + const originalMemoryBase = process.env['QWEN_CODE_MEMORY_BASE_DIR']; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'forget-user-')); + try { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = path.join(tempDir, 'memory'); + clearAutoMemoryRootCache(); + const projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + const userRoot = getUserAutoMemoryRoot(); + await fs.mkdir(userRoot, { recursive: true }); + const userFile = path.join(userRoot, 'user.md'); + await fs.writeFile( + userFile, + [ + '---', + 'type: user', + 'title: User memory', + '---', + '', + 'Forget this user-level preference', + '', + ].join('\n'), + 'utf-8', + ); + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]); + vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([]); + + const result = await forgetManagedAutoMemoryMatches( + projectRoot, + [ + { + topic: 'user', + summary: 'Forget this user-level preference', + filePath: userFile, + entryIndex: 0, + }, + ], + new Date('2026-07-03T00:00:00.000Z'), + ); + + expect(result.touchedTopics).toEqual(['user']); + expect(result.touchedScopes).toEqual(['user']); + await expect(fs.stat(userFile)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect( + fs.readFile(getUserAutoMemoryIndexPath(), 'utf-8'), + ).resolves.toBe(''); + await expect( + fs.stat(getAutoMemoryMetadataPath(projectRoot)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + if (originalMemoryBase === undefined) { + delete process.env['QWEN_CODE_MEMORY_BASE_DIR']; + } else { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = originalMemoryBase; + } + clearAutoMemoryRootCache(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('deletes duplicate project and user paths without scope collisions', async () => { + const originalMemoryBase = process.env['QWEN_CODE_MEMORY_BASE_DIR']; + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'forget-mixed-scopes-'), + ); + try { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = path.join(tempDir, 'memory'); + clearAutoMemoryRootCache(); + const projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + const projectRootMemory = getAutoMemoryRoot(projectRoot); + const userRoot = getUserAutoMemoryRoot(); + const relativePath = path.join('shared', 'note.md'); + const projectFile = path.join(projectRootMemory, relativePath); + const userFile = path.join(userRoot, relativePath); + await fs.mkdir(path.dirname(projectFile), { recursive: true }); + await fs.mkdir(path.dirname(userFile), { recursive: true }); + await fs.writeFile( + projectFile, + [ + '---', + 'type: project', + 'title: Shared memory', + '---', + '', + 'Forget this project memory', + '', + ].join('\n'), + 'utf-8', + ); + await fs.writeFile( + userFile, + [ + '---', + 'type: user', + 'title: Shared memory', + '---', + '', + 'Forget this user memory', + '', + ].join('\n'), + 'utf-8', + ); + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]); + vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([]); + + const result = await forgetManagedAutoMemoryMatches( + projectRoot, + [ + { + topic: 'project', + summary: 'Forget this project memory', + filePath: projectFile, + entryIndex: 0, + }, + { + topic: 'user', + summary: 'Forget this user memory', + filePath: userFile, + entryIndex: 0, + }, + ], + new Date('2026-07-03T00:00:00.000Z'), + ); + + expect(result.removedEntries).toHaveLength(2); + expect(result.touchedScopes).toEqual(['user', 'project']); + await expect(fs.stat(projectFile)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect(fs.stat(userFile)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect( + fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'), + ).resolves.toBe(''); + await expect( + fs.readFile(getUserAutoMemoryIndexPath(), 'utf-8'), + ).resolves.toBe(''); + const metadata = JSON.parse( + await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8'), + ) as { updatedAt?: string }; + expect(metadata.updatedAt).toBe('2026-07-03T00:00:00.000Z'); + } finally { + if (originalMemoryBase === undefined) { + delete process.env['QWEN_CODE_MEMORY_BASE_DIR']; + } else { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = originalMemoryBase; + } + clearAutoMemoryRootCache(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('keeps successful user deletions when index rebuild fails', async () => { + const originalMemoryBase = process.env['QWEN_CODE_MEMORY_BASE_DIR']; + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'forget-rebuild-failure-'), + ); + try { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = path.join(tempDir, 'memory'); + clearAutoMemoryRootCache(); + const projectRoot = path.join(tempDir, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + const userRoot = getUserAutoMemoryRoot(); + await fs.mkdir(userRoot, { recursive: true }); + const userFile = path.join(userRoot, 'user.md'); + await fs.writeFile( + userFile, + [ + '---', + 'type: user', + 'title: User memory', + '---', + '', + 'Forget this user-level preference', + '', + ].join('\n'), + 'utf-8', + ); + await fs.mkdir(getUserAutoMemoryIndexPath(), { recursive: true }); + vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([]); + vi.mocked(scanUserAutoMemoryTopicDocuments).mockResolvedValue([]); + + const result = await forgetManagedAutoMemoryMatches( + projectRoot, + [ + { + topic: 'user', + summary: 'Forget this user-level preference', + filePath: userFile, + entryIndex: 0, + }, + ], + new Date('2026-07-03T00:00:00.000Z'), + ); + + expect(result.removedEntries).toHaveLength(1); + expect(result.touchedScopes).toEqual(['user']); + await expect(fs.stat(userFile)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + if (originalMemoryBase === undefined) { + delete process.env['QWEN_CODE_MEMORY_BASE_DIR']; + } else { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = originalMemoryBase; + } + clearAutoMemoryRootCache(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/memory/forget.ts b/packages/core/src/memory/forget.ts index 206a5df2f0..1647f74b64 100644 --- a/packages/core/src/memory/forget.ts +++ b/packages/core/src/memory/forget.ts @@ -17,9 +17,19 @@ import { parseAutoMemoryEntries, renderAutoMemoryBody, } from './entries.js'; -import { rebuildManagedAutoMemoryIndex } from './indexer.js'; -import { getAutoMemoryMetadataPath } from './paths.js'; -import { scanAutoMemoryTopicDocuments } from './scan.js'; +import { + rebuildManagedAutoMemoryIndex, + rebuildUserAutoMemoryIndex, +} from './indexer.js'; +import { + getAutoMemoryMetadataPath, + isAutoMemPath, + isUserAutoMemPath, +} from './paths.js'; +import { + scanAutoMemoryTopicDocuments, + scanUserAutoMemoryTopicDocuments, +} from './scan.js'; import { ensureAutoMemoryScaffold } from './store.js'; import type { AutoMemoryMetadata, AutoMemoryType } from './types.js'; @@ -36,6 +46,7 @@ export interface AutoMemoryForgetResult { query: string; removedEntries: AutoMemoryForgetMatch[]; touchedTopics: AutoMemoryType[]; + touchedScopes: AutoMemoryStorageScope[]; systemMessage?: string; } @@ -48,10 +59,13 @@ export interface AutoMemoryForgetSelectionResult { interface IndexedForgetCandidate extends AutoMemoryForgetMatch { id: string; entryIndex: number; + storageScope: AutoMemoryStorageScope; why?: string; howToApply?: string; } +export type AutoMemoryStorageScope = 'user' | 'project'; + const FORGET_SELECTION_RESPONSE_SCHEMA: Record = { type: 'object', properties: { @@ -80,27 +94,38 @@ async function listIndexedForgetCandidates( abortSignal?: AbortSignal, ): Promise { abortSignal?.throwIfAborted(); - const docs = await scanAutoMemoryTopicDocuments(projectRoot); + const [projectDocs, userDocs] = await Promise.all([ + scanAutoMemoryTopicDocuments(projectRoot), + scanUserAutoMemoryTopicDocuments(), + ]); abortSignal?.throwIfAborted(); const candidates: IndexedForgetCandidate[] = []; - for (const doc of docs) { + for (const { docs, storageScope } of [ + { docs: userDocs, storageScope: 'user' as const }, + { docs: projectDocs, storageScope: 'project' as const }, + ]) { abortSignal?.throwIfAborted(); - const entries = parseAutoMemoryEntries(doc.body); - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - candidates.push({ - // Use a stable per-entry ID so the model can target individual entries - // in multi-entry files without accidentally removing siblings. - id: - entries.length === 1 ? doc.relativePath : `${doc.relativePath}:${i}`, - topic: doc.type, - summary: entry.summary, - filePath: doc.filePath, - entryIndex: i, - why: entry.why, - howToApply: entry.howToApply, - }); + for (const doc of docs) { + abortSignal?.throwIfAborted(); + const entries = parseAutoMemoryEntries(doc.body); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const entryId = + entries.length === 1 ? doc.relativePath : `${doc.relativePath}:${i}`; + candidates.push({ + // Prefix the storage scope so same relative paths in user/project + // memory never collide in model-selected ids. + id: `${storageScope}:${entryId}`, + storageScope, + topic: doc.type, + summary: entry.summary, + filePath: doc.filePath, + entryIndex: i, + why: entry.why, + howToApply: entry.howToApply, + }); + } } } @@ -129,6 +154,7 @@ function buildForgetSelectionPrompt( [ `Candidate ${index + 1}`, `id: ${candidate.id}`, + `scope: ${candidate.storageScope}`, `topic: ${candidate.topic}`, `summary: ${candidate.summary}`, `why: ${candidate.why ?? '(none)'}`, @@ -294,14 +320,22 @@ export async function forgetManagedAutoMemoryMatches( query: '', removedEntries: [], touchedTopics: [], + touchedScopes: [], systemMessage: undefined, }; } - await ensureAutoMemoryScaffold(projectRoot, now); + if ( + matches.some( + (match) => classifyMemoryScope(match.filePath, projectRoot) === 'project', + ) + ) { + await ensureAutoMemoryScaffold(projectRoot, now); + } options.abortSignal?.throwIfAborted(); const removedEntries: AutoMemoryForgetMatch[] = []; const touchedTopics = new Set(); + const touchedScopes = new Set(); // Group matches by file so we can do per-entry removal rather than // blindly deleting entire files (which would destroy unrelated entries in @@ -326,6 +360,7 @@ export async function forgetManagedAutoMemoryMatches( await fs.unlink(filePath); removedEntries.push(...fileMatches); for (const m of fileMatches) touchedTopics.add(m.topic); + touchedScopes.add(classifyMemoryScope(filePath, projectRoot)); continue; } @@ -390,6 +425,7 @@ export async function forgetManagedAutoMemoryMatches( for (const m of removedFileEntries) { touchedTopics.add(m.topic); } + touchedScopes.add(classifyMemoryScope(filePath, projectRoot)); } catch (err) { if (options.abortSignal?.aborted) throw err; debugLogger.warn( @@ -400,17 +436,38 @@ export async function forgetManagedAutoMemoryMatches( } } - if (touchedTopics.size > 0) { - options.abortSignal?.throwIfAborted(); - await bumpMetadata(projectRoot, now); - options.abortSignal?.throwIfAborted(); - await rebuildManagedAutoMemoryIndex(projectRoot); + if (touchedScopes.has('project')) { + try { + options.abortSignal?.throwIfAborted(); + await bumpMetadata(projectRoot, now); + options.abortSignal?.throwIfAborted(); + await rebuildManagedAutoMemoryIndex(projectRoot); + } catch (err) { + if (options.abortSignal?.aborted) throw err; + debugLogger.warn( + 'Managed auto-memory forget failed to rebuild project index:', + err, + ); + } + } + if (touchedScopes.has('user')) { + try { + options.abortSignal?.throwIfAborted(); + await rebuildUserAutoMemoryIndex(); + } catch (err) { + if (options.abortSignal?.aborted) throw err; + debugLogger.warn( + 'Managed auto-memory forget failed to rebuild user index:', + err, + ); + } } return { query: '', removedEntries, touchedTopics: [...touchedTopics], + touchedScopes: sortTouchedScopes(touchedScopes), systemMessage: removedEntries.length > 0 ? `Managed auto-memory forgot ${removedEntries.length} entr${removedEntries.length === 1 ? 'y' : 'ies'} from: ${[...touchedTopics].map((topic) => `${topic}/`).join(', ')}` @@ -427,7 +484,12 @@ export async function forgetManagedAutoMemoryEntries( options.abortSignal?.throwIfAborted(); const trimmedQuery = query.trim(); if (!trimmedQuery) { - return { query: trimmedQuery, removedEntries: [], touchedTopics: [] }; + return { + query: trimmedQuery, + removedEntries: [], + touchedTopics: [], + touchedScopes: [], + }; } const selection = await selectManagedAutoMemoryForgetCandidates( @@ -443,3 +505,25 @@ export async function forgetManagedAutoMemoryEntries( ); return { ...result, query: trimmedQuery }; } + +function classifyMemoryScope( + filePath: string, + projectRoot: string, +): AutoMemoryStorageScope { + if (isUserAutoMemPath(filePath)) { + return 'user'; + } + if (isAutoMemPath(filePath, projectRoot)) { + return 'project'; + } + // Direct callers historically supplied project-memory matches without going + // through the scanner. Preserve that behavior for compatibility. + return 'project'; +} + +function sortTouchedScopes( + scopes: Iterable, +): AutoMemoryStorageScope[] { + const unique = new Set(scopes); + return (['user', 'project'] as const).filter((scope) => unique.has(scope)); +} diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index d8cdf6b2ff..6de0d76ab9 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -271,4 +271,76 @@ describe('createMemoryScopedAgentConfig', () => { }), ).resolves.toBe('deny'); }); + + it('can bypass base ask rules for scoped memory writes only', async () => { + const basePm: Pick< + PermissionManager, + | 'evaluate' + | 'findMatchingDenyRule' + | 'hasMatchingAskRule' + | 'hasRelevantRules' + | 'isToolEnabled' + > = { + hasRelevantRules: vi.fn().mockReturnValue(true), + hasMatchingAskRule: vi.fn().mockReturnValue(true), + findMatchingDenyRule: vi.fn().mockReturnValue(undefined), + evaluate: vi.fn().mockResolvedValue('ask'), + isToolEnabled: vi.fn().mockResolvedValue(true), + }; + const pm = permissionManager( + createMemoryScopedAgentConfig( + { + getPermissionManager: () => basePm as PermissionManager, + } as Config, + projectRoot, + { bypassBaseAskForScopedPaths: true }, + ), + ); + + await expect( + pm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(getUserAutoMemoryRoot(), 'user', 'a.md'), + }), + ).resolves.toBe('allow'); + await expect( + pm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(projectRoot, 'README.md'), + }), + ).resolves.toBe('deny'); + }); + + it('does not bypass base deny rules for scoped memory writes', async () => { + const basePm: Pick< + PermissionManager, + | 'evaluate' + | 'findMatchingDenyRule' + | 'hasMatchingAskRule' + | 'hasRelevantRules' + | 'isToolEnabled' + > = { + hasRelevantRules: vi.fn().mockReturnValue(true), + hasMatchingAskRule: vi.fn().mockReturnValue(false), + findMatchingDenyRule: vi.fn().mockReturnValue('base deny'), + evaluate: vi.fn().mockResolvedValue('deny'), + isToolEnabled: vi.fn().mockResolvedValue(true), + }; + const pm = permissionManager( + createMemoryScopedAgentConfig( + { + getPermissionManager: () => basePm as PermissionManager, + } as Config, + projectRoot, + { bypassBaseAskForScopedPaths: true }, + ), + ); + + await expect( + pm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(getUserAutoMemoryRoot(), 'user', 'a.md'), + }), + ).resolves.toBe('deny'); + }); }); diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index 72c51f470a..ca627b075e 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -28,6 +28,7 @@ type MemoryScopedPermissionManager = Pick< export interface MemoryScopedAgentConfigOptions { allowShell?: boolean; + bypassBaseAskForScopedPaths?: boolean; includeUserMemory?: boolean; restrictReadsToMemoryPaths?: boolean; } @@ -50,7 +51,15 @@ function isScopedTool( function mergePermissionDecision( scopedDecision: PermissionDecision, baseDecision: PermissionDecision, + opts: Required, ): PermissionDecision { + if ( + opts.bypassBaseAskForScopedPaths && + scopedDecision === 'allow' && + baseDecision === 'ask' + ) { + return 'allow'; + } const priority: Record = { deny: 4, ask: 3, @@ -200,6 +209,7 @@ export function createMemoryScopedAgentConfig( ): Config { const opts: Required = { allowShell: options.allowShell ?? false, + bypassBaseAskForScopedPaths: options.bypassBaseAskForScopedPaths ?? false, includeUserMemory: options.includeUserMemory ?? true, restrictReadsToMemoryPaths: options.restrictReadsToMemoryPaths ?? false, }; @@ -232,7 +242,7 @@ export function createMemoryScopedAgentConfig( const baseDecision = basePm.hasRelevantRules(ctx) ? await basePm.evaluate(ctx) : 'default'; - return mergePermissionDecision(scopedDecision, baseDecision); + return mergePermissionDecision(scopedDecision, baseDecision, opts); }, async isToolEnabled(toolName: string): Promise { if (toolName === ToolNames.SHELL) { diff --git a/packages/core/src/memory/remember.test.ts b/packages/core/src/memory/remember.test.ts index 615c454d33..180dff2942 100644 --- a/packages/core/src/memory/remember.test.ts +++ b/packages/core/src/memory/remember.test.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import type { PermissionManager } from '../permissions/permission-manager.js'; +import { ToolNames } from '../tools/tool-names.js'; import type { ForkedAgentResult } from '../utils/forkedAgent.js'; import { runForkedAgent } from '../utils/forkedAgent.js'; import { @@ -36,11 +37,16 @@ vi.mock('./indexer.js', () => ({ rebuildUserAutoMemoryIndex: vi.fn(), })); -function createConfig(projectRoot: string, managed = true): Config { +function createConfig( + projectRoot: string, + managed = true, + overrides: Partial = {}, +): Config { return { isManagedMemoryAvailable: vi.fn().mockReturnValue(managed), getProjectRoot: vi.fn().mockReturnValue(projectRoot), getUserMemory: vi.fn().mockReturnValue('QWEN/AGENTS guidance'), + ...overrides, } as unknown as Config; } @@ -172,6 +178,56 @@ describe('remember memory helper', () => { expect(rebuildManagedAutoMemoryIndex).toHaveBeenCalledWith(projectRoot); }); + it('lets managed-memory writes bypass base ask rules', async () => { + const touched = path.join(getUserAutoMemoryRoot(), 'user.md'); + const basePm: Pick< + PermissionManager, + | 'evaluate' + | 'findMatchingDenyRule' + | 'hasMatchingAskRule' + | 'hasRelevantRules' + | 'isToolEnabled' + > = { + hasRelevantRules: vi.fn().mockReturnValue(true), + hasMatchingAskRule: vi.fn().mockReturnValue(true), + findMatchingDenyRule: vi.fn().mockReturnValue(undefined), + evaluate: vi.fn().mockResolvedValue('ask'), + isToolEnabled: vi.fn().mockResolvedValue(true), + }; + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'completed', + finalText: 'Saved user memory.', + filesTouched: [touched], + filesWritten: [touched], + } satisfies ForkedAgentResult); + + await runManagedRememberByAgent({ + config: createConfig(projectRoot, true, { + getPermissionManager: () => basePm as PermissionManager, + }), + projectRoot, + content: 'Remember the user prefers quiet output.', + contextMode: 'clean', + }); + + const params = vi.mocked(runForkedAgent).mock.calls[0]?.[0] as { + config: Config; + }; + const pm = params.config.getPermissionManager() as PermissionManager; + await expect( + pm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: touched, + }), + ).resolves.toBe('allow'); + await expect( + pm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(projectRoot, 'README.md'), + }), + ).resolves.toBe('deny'); + }); + it('classifies only successful memory writes', async () => { const projectFile = path.join(getAutoMemoryRoot(projectRoot), 'project.md'); vi.mocked(runForkedAgent).mockResolvedValue({ diff --git a/packages/core/src/memory/remember.ts b/packages/core/src/memory/remember.ts index 686fe1b04a..7108358ed7 100644 --- a/packages/core/src/memory/remember.ts +++ b/packages/core/src/memory/remember.ts @@ -173,6 +173,7 @@ export async function runManagedRememberByAgent(params: { hiddenConfig, params.projectRoot, { + bypassBaseAskForScopedPaths: true, restrictReadsToMemoryPaths: true, }, ); diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 3ab4b2c0f7..791a962f3f 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryForgetResult { summary?: string; removedEntries: DaemonWorkspaceMemoryForgetMatch[]; touchedTopics: DaemonWorkspaceMemoryTopic[]; + touchedScopes: Array<'user' | 'project'>; } export interface DaemonWorkspaceMemoryForgetTask { diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index f99cfea9e1..555132521c 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -3942,6 +3942,7 @@ describe('DaemonClient', () => { summary: 'forgot', removedEntries: [], touchedTopics: ['project' as const], + touchedScopes: ['project' as const], }, }; const { fetch, calls } = recordingFetch(() => jsonResponse(200, reply));