mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
e28a6371df
commit
a07fdc6042
18 changed files with 672 additions and 53 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'>),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ export interface BridgeWorkspaceMemoryForgetResult {
|
|||
summary?: string;
|
||||
removedEntries: BridgeWorkspaceMemoryForgetMatch[];
|
||||
touchedTopics: BridgeAutoMemoryTopic[];
|
||||
touchedScopes: Array<'user' | 'project'>;
|
||||
}
|
||||
|
||||
export interface BridgeWorkspaceMemoryDreamResult {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export async function runManagedRememberByAgent(params: {
|
|||
hiddenConfig,
|
||||
params.projectRoot,
|
||||
{
|
||||
bypassBaseAskForScopedPaths: true,
|
||||
restrictReadsToMemoryPaths: true,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryForgetResult {
|
|||
summary?: string;
|
||||
removedEntries: DaemonWorkspaceMemoryForgetMatch[];
|
||||
touchedTopics: DaemonWorkspaceMemoryTopic[];
|
||||
touchedScopes: Array<'user' | 'project'>;
|
||||
}
|
||||
|
||||
export interface DaemonWorkspaceMemoryForgetTask {
|
||||
|
|
|
|||
|
|
@ -3942,6 +3942,7 @@ describe('DaemonClient', () => {
|
|||
summary: 'forgot',
|
||||
removedEntries: [],
|
||||
touchedTopics: ['project' as const],
|
||||
touchedScopes: ['project' as const],
|
||||
},
|
||||
};
|
||||
const { fetch, calls } = recordingFetch(() => jsonResponse(200, reply));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue