fix(memory): allow forget to remove user managed memory (#6432)

* fix(memory): allow forget to remove user managed memory

* fix(memory): harden forget index rebuilds

* test(cli): stabilize session archive race assertion

* test(memory): cover deny precedence with ask bypass
This commit is contained in:
callmeYe 2026-07-08 17:51:36 +08:00 committed by GitHub
parent e28a6371df
commit a07fdc6042
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 672 additions and 53 deletions

View file

@ -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.

View file

@ -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({

View file

@ -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'>),
};
}

View file

@ -198,6 +198,7 @@ export interface BridgeWorkspaceMemoryForgetResult {
summary?: string;
removedEntries: BridgeWorkspaceMemoryForgetMatch[];
touchedTopics: BridgeAutoMemoryTopic[];
touchedScopes: Array<'user' | 'project'>;
}
export interface BridgeWorkspaceMemoryDreamResult {

View file

@ -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({

View file

@ -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<string, unknown>;
} catch (err) {
if (err instanceof RequestError) {

View file

@ -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 };

View file

@ -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<void>((resolve) => {
firstCloseStarted = resolve;
});
const firstCloseReleasedPromise = new Promise<void>((resolve) => {
releaseFirstClose = resolve;
});
const secondCloseStartedPromise = new Promise<void>((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]);
}

View file

@ -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',

View file

@ -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 }
: {}),

View file

@ -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('</user-content>');
});
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 });
}
});
});

View file

@ -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<string, unknown> = {
type: 'object',
properties: {
@ -80,27 +94,38 @@ async function listIndexedForgetCandidates(
abortSignal?: AbortSignal,
): Promise<IndexedForgetCandidate[]> {
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<AutoMemoryType>();
const touchedScopes = new Set<AutoMemoryStorageScope>();
// 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>,
): AutoMemoryStorageScope[] {
const unique = new Set(scopes);
return (['user', 'project'] as const).filter((scope) => unique.has(scope));
}

View file

@ -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');
});
});

View file

@ -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<MemoryScopedAgentConfigOptions>,
): PermissionDecision {
if (
opts.bypassBaseAskForScopedPaths &&
scopedDecision === 'allow' &&
baseDecision === 'ask'
) {
return 'allow';
}
const priority: Record<PermissionDecision, number> = {
deny: 4,
ask: 3,
@ -200,6 +209,7 @@ export function createMemoryScopedAgentConfig(
): Config {
const opts: Required<MemoryScopedAgentConfigOptions> = {
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<boolean> {
if (toolName === ToolNames.SHELL) {

View file

@ -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> = {},
): 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({

View file

@ -173,6 +173,7 @@ export async function runManagedRememberByAgent(params: {
hiddenConfig,
params.projectRoot,
{
bypassBaseAskForScopedPaths: true,
restrictReadsToMemoryPaths: true,
},
);

View file

@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryForgetResult {
summary?: string;
removedEntries: DaemonWorkspaceMemoryForgetMatch[];
touchedTopics: DaemonWorkspaceMemoryTopic[];
touchedScopes: Array<'user' | 'project'>;
}
export interface DaemonWorkspaceMemoryForgetTask {

View file

@ -3942,6 +3942,7 @@ describe('DaemonClient', () => {
summary: 'forgot',
removedEntries: [],
touchedTopics: ['project' as const],
touchedScopes: ['project' as const],
},
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, reply));