mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-16 12:14:52 +00:00
fix(core): record auto-memory index reads in FileReadCache (#7468)
* fix(core): record auto-memory index reads * fix(core): seed memory read cache from read stats
This commit is contained in:
parent
5cad7fe642
commit
fcc250beb5
4 changed files with 258 additions and 19 deletions
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import type { ConfigParameters, SandboxConfig } from './config.js';
|
||||
import {
|
||||
Config,
|
||||
|
|
@ -64,7 +64,15 @@ import {
|
|||
} from '../confirmation-bus/types.js';
|
||||
import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
||||
import type { LoadServerHierarchicalMemoryOptions } from '../utils/memoryDiscovery.js';
|
||||
import { readAutoMemoryIndex } from '../memory/store.js';
|
||||
import {
|
||||
readAutoMemoryIndexWithStats,
|
||||
readUserAutoMemoryIndexWithStats,
|
||||
} from '../memory/store.js';
|
||||
import {
|
||||
clearAutoMemoryRootCache,
|
||||
getAutoMemoryIndexPath,
|
||||
getUserAutoMemoryIndexPath,
|
||||
} from '../memory/paths.js';
|
||||
import {
|
||||
rebuildTeamAutoMemoryIndex,
|
||||
TeamMemoryRootSecurityError,
|
||||
|
|
@ -83,6 +91,8 @@ import {
|
|||
SessionWriterLease,
|
||||
} from '../services/session-writer-lease.js';
|
||||
import * as jsonl from '../utils/jsonl-utils.js';
|
||||
import { checkPriorRead } from '../tools/priorReadEnforcement.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
|
||||
function createToolMock(toolName: string) {
|
||||
const ToolMock = vi.fn();
|
||||
|
|
@ -170,8 +180,8 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('../memory/store.js', () => ({
|
||||
readAutoMemoryIndex: vi.fn().mockResolvedValue(null),
|
||||
readUserAutoMemoryIndex: vi.fn().mockResolvedValue(null),
|
||||
readAutoMemoryIndexWithStats: vi.fn().mockResolvedValue(null),
|
||||
readUserAutoMemoryIndexWithStats: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock('../memory/indexer.js', async (importActual) => ({
|
||||
// Keep the real exports (notably TeamMemoryRootSecurityError, which the sync
|
||||
|
|
@ -357,6 +367,19 @@ const MEMORY_PRESSURE_ENV_KEYS = [
|
|||
'QWEN_MEMORY_PRESSURE_CRITICAL',
|
||||
];
|
||||
|
||||
let mockAutoMemoryInode = 1;
|
||||
function mockAutoMemoryIndexRead(content: string) {
|
||||
return {
|
||||
content,
|
||||
stats: {
|
||||
dev: 1,
|
||||
ino: mockAutoMemoryInode++,
|
||||
mtimeMs: 1,
|
||||
size: Buffer.byteLength(content),
|
||||
} as fs.Stats,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../core/baseLlmClient.js');
|
||||
// Mock fireNotificationHook from toolHookTriggers
|
||||
vi.mock('../core/toolHookTriggers.js', () => ({
|
||||
|
|
@ -484,6 +507,7 @@ describe('Server Config (config.ts)', () => {
|
|||
beforeEach(() => {
|
||||
// Reset mocks if necessary
|
||||
vi.clearAllMocks();
|
||||
mockAutoMemoryInode = 1;
|
||||
for (const envName of MEMORY_PRESSURE_ENV_KEYS) {
|
||||
delete process.env[envName];
|
||||
}
|
||||
|
|
@ -3899,8 +3923,10 @@ describe('Server Config (config.ts)', () => {
|
|||
conditionalRules: [],
|
||||
projectRoot: '/tmp',
|
||||
});
|
||||
vi.mocked(readAutoMemoryIndex).mockResolvedValue(
|
||||
'# Managed Auto-Memory Index\n\n- [Project Memory](project.md)',
|
||||
vi.mocked(readAutoMemoryIndexWithStats).mockResolvedValue(
|
||||
mockAutoMemoryIndexRead(
|
||||
'# Managed Auto-Memory Index\n\n- [Project Memory](project.md)',
|
||||
),
|
||||
);
|
||||
|
||||
await config.refreshHierarchicalMemory();
|
||||
|
|
@ -3910,6 +3936,135 @@ describe('Server Config (config.ts)', () => {
|
|||
expect(config.getUserMemory()).toContain('[Project Memory](project.md)');
|
||||
});
|
||||
|
||||
it('refreshHierarchicalMemory seeds the FileReadCache for project and user MEMORY.md indexes', async () => {
|
||||
const originalMemoryBaseDir = process.env['QWEN_CODE_MEMORY_BASE_DIR'];
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'auto-memory-cache-'));
|
||||
const projectRoot = path.join(tempDir, 'project');
|
||||
const memoryBaseDir = path.join(tempDir, 'memory-base');
|
||||
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
process.env['QWEN_CODE_MEMORY_BASE_DIR'] = memoryBaseDir;
|
||||
clearAutoMemoryRootCache();
|
||||
|
||||
const managedIndexPath = getAutoMemoryIndexPath(projectRoot);
|
||||
const userIndexPath = getUserAutoMemoryIndexPath();
|
||||
|
||||
await mkdir(path.dirname(managedIndexPath), { recursive: true });
|
||||
await mkdir(path.dirname(userIndexPath), { recursive: true });
|
||||
await writeFile(managedIndexPath, '# managed memory\n', 'utf-8');
|
||||
await writeFile(userIndexPath, '# user memory\n', 'utf-8');
|
||||
|
||||
try {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
cwd: projectRoot,
|
||||
targetDir: projectRoot,
|
||||
});
|
||||
|
||||
vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({
|
||||
memoryContent: '--- Context from: QWEN.md ---\nProject rules',
|
||||
fileCount: 1,
|
||||
ruleCount: 0,
|
||||
conditionalRules: [],
|
||||
projectRoot,
|
||||
});
|
||||
vi.mocked(readAutoMemoryIndexWithStats).mockResolvedValueOnce({
|
||||
content: '# managed memory\n',
|
||||
stats: await stat(managedIndexPath),
|
||||
});
|
||||
vi.mocked(readUserAutoMemoryIndexWithStats).mockResolvedValueOnce({
|
||||
content: '# user memory\n',
|
||||
stats: await stat(userIndexPath),
|
||||
});
|
||||
|
||||
await config.refreshHierarchicalMemory();
|
||||
|
||||
await expect(
|
||||
checkPriorRead(
|
||||
config.getFileReadCache(),
|
||||
managedIndexPath,
|
||||
'overwriting',
|
||||
),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
checkPriorRead(config.getFileReadCache(), userIndexPath, 'overwriting'),
|
||||
).resolves.toEqual({ ok: true });
|
||||
} finally {
|
||||
if (originalMemoryBaseDir === undefined) {
|
||||
delete process.env['QWEN_CODE_MEMORY_BASE_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_CODE_MEMORY_BASE_DIR'] = originalMemoryBaseDir;
|
||||
}
|
||||
clearAutoMemoryRootCache();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshHierarchicalMemory records the stats captured with the auto-memory index read', async () => {
|
||||
const originalMemoryBaseDir = process.env['QWEN_CODE_MEMORY_BASE_DIR'];
|
||||
const tempDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'auto-memory-cache-race-'),
|
||||
);
|
||||
const projectRoot = path.join(tempDir, 'project');
|
||||
const memoryBaseDir = path.join(tempDir, 'memory-base');
|
||||
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
process.env['QWEN_CODE_MEMORY_BASE_DIR'] = memoryBaseDir;
|
||||
clearAutoMemoryRootCache();
|
||||
|
||||
const managedIndexPath = getAutoMemoryIndexPath(projectRoot);
|
||||
|
||||
await mkdir(path.dirname(managedIndexPath), { recursive: true });
|
||||
await writeFile(managedIndexPath, '# old managed memory\n', 'utf-8');
|
||||
const oldStats = await stat(managedIndexPath);
|
||||
await writeFile(
|
||||
managedIndexPath,
|
||||
'# newer managed memory with extra bytes\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
try {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
cwd: projectRoot,
|
||||
targetDir: projectRoot,
|
||||
});
|
||||
|
||||
vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({
|
||||
memoryContent: '--- Context from: QWEN.md ---\nProject rules',
|
||||
fileCount: 1,
|
||||
ruleCount: 0,
|
||||
conditionalRules: [],
|
||||
projectRoot,
|
||||
});
|
||||
vi.mocked(readAutoMemoryIndexWithStats).mockResolvedValueOnce({
|
||||
content: '# old managed memory\n',
|
||||
stats: oldStats,
|
||||
});
|
||||
|
||||
await config.refreshHierarchicalMemory();
|
||||
|
||||
await expect(
|
||||
checkPriorRead(
|
||||
config.getFileReadCache(),
|
||||
managedIndexPath,
|
||||
'overwriting',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
type: ToolErrorType.FILE_CHANGED_SINCE_READ,
|
||||
});
|
||||
} finally {
|
||||
if (originalMemoryBaseDir === undefined) {
|
||||
delete process.env['QWEN_CODE_MEMORY_BASE_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_CODE_MEMORY_BASE_DIR'] = originalMemoryBaseDir;
|
||||
}
|
||||
clearAutoMemoryRootCache();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshHierarchicalMemory should not load team memory from untrusted workspaces', async () => {
|
||||
const config = new Config({ ...baseParams, enableTeamMemory: true });
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(false);
|
||||
|
|
@ -4097,8 +4252,10 @@ describe('Server Config (config.ts)', () => {
|
|||
conditionalRules: [],
|
||||
projectRoot: '/tmp',
|
||||
});
|
||||
vi.mocked(readAutoMemoryIndex).mockResolvedValueOnce(
|
||||
'# Managed Auto-Memory Index\n\n' + 'remember this '.repeat(80),
|
||||
vi.mocked(readAutoMemoryIndexWithStats).mockResolvedValueOnce(
|
||||
mockAutoMemoryIndexRead(
|
||||
'# Managed Auto-Memory Index\n\n' + 'remember this '.repeat(80),
|
||||
),
|
||||
);
|
||||
|
||||
await config.refreshHierarchicalMemory();
|
||||
|
|
@ -4176,7 +4333,7 @@ describe('Server Config (config.ts)', () => {
|
|||
conditionalRules: [],
|
||||
projectRoot: '/tmp',
|
||||
});
|
||||
vi.mocked(readAutoMemoryIndex).mockResolvedValueOnce(null);
|
||||
vi.mocked(readAutoMemoryIndexWithStats).mockResolvedValueOnce(null);
|
||||
|
||||
await config.refreshHierarchicalMemory();
|
||||
|
||||
|
|
@ -4619,7 +4776,7 @@ describe('Server Config (config.ts)', () => {
|
|||
conditionalRules: [],
|
||||
projectRoot: '/tmp',
|
||||
});
|
||||
vi.mocked(readAutoMemoryIndex).mockResolvedValue(null);
|
||||
vi.mocked(readAutoMemoryIndexWithStats).mockResolvedValue(null);
|
||||
|
||||
await config.refreshHierarchicalMemory();
|
||||
|
||||
|
|
@ -4641,13 +4798,13 @@ describe('Server Config (config.ts)', () => {
|
|||
conditionalRules: [],
|
||||
projectRoot: '/tmp',
|
||||
});
|
||||
vi.mocked(readAutoMemoryIndex).mockResolvedValue(null);
|
||||
vi.mocked(readAutoMemoryIndexWithStats).mockResolvedValue(null);
|
||||
|
||||
await config.refreshHierarchicalMemory();
|
||||
|
||||
expect(config.getUserMemory()).toContain('Project rules');
|
||||
expect(config.getUserMemory()).not.toContain('# auto memory');
|
||||
expect(readAutoMemoryIndex).not.toHaveBeenCalled();
|
||||
expect(readAutoMemoryIndexWithStats).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshHierarchicalMemory should only use explicit inputs in bare mode', async () => {
|
||||
|
|
@ -4669,7 +4826,7 @@ describe('Server Config (config.ts)', () => {
|
|||
const lastCall = vi.mocked(loadServerHierarchicalMemory).mock.calls.at(-1);
|
||||
expect(lastCall?.at(-1)).toMatchObject({ explicitOnly: true });
|
||||
expect(lastCall?.[1]).toEqual([]);
|
||||
expect(readAutoMemoryIndex).not.toHaveBeenCalled();
|
||||
expect(readAutoMemoryIndexWithStats).not.toHaveBeenCalled();
|
||||
expect(config.getUserMemory()).toContain('Project rules');
|
||||
expect(config.getUserMemory()).not.toContain('# auto memory');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -212,12 +212,15 @@ import {
|
|||
} from '../utils/debugLogger.js';
|
||||
import {
|
||||
getAutoMemoryRoot,
|
||||
getAutoMemoryIndexPath,
|
||||
getTeamAutoMemoryRoot,
|
||||
getUserAutoMemoryIndexPath,
|
||||
getUserAutoMemoryRoot,
|
||||
} from '../memory/paths.js';
|
||||
import {
|
||||
readAutoMemoryIndex,
|
||||
readUserAutoMemoryIndex,
|
||||
type AutoMemoryIndexRead,
|
||||
readAutoMemoryIndexWithStats,
|
||||
readUserAutoMemoryIndexWithStats,
|
||||
} from '../memory/store.js';
|
||||
import {
|
||||
rebuildTeamAutoMemoryIndex,
|
||||
|
|
@ -3154,10 +3157,22 @@ export class Config {
|
|||
}
|
||||
}
|
||||
}
|
||||
const [managedAutoMemoryIndex, userAutoMemoryIndex] = await Promise.all([
|
||||
readAutoMemoryIndex(this.getProjectRoot()),
|
||||
readUserAutoMemoryIndex().catch(() => null),
|
||||
]);
|
||||
const [managedAutoMemoryIndexRead, userAutoMemoryIndexRead] =
|
||||
await Promise.all([
|
||||
readAutoMemoryIndexWithStats(this.getProjectRoot()),
|
||||
readUserAutoMemoryIndexWithStats().catch(() => null),
|
||||
]);
|
||||
this.recordAutoMemoryIndexRead(
|
||||
getAutoMemoryIndexPath(this.getProjectRoot()),
|
||||
managedAutoMemoryIndexRead,
|
||||
);
|
||||
this.recordAutoMemoryIndexRead(
|
||||
getUserAutoMemoryIndexPath(),
|
||||
userAutoMemoryIndexRead,
|
||||
);
|
||||
const managedAutoMemoryIndex =
|
||||
managedAutoMemoryIndexRead?.content ?? null;
|
||||
const userAutoMemoryIndex = userAutoMemoryIndexRead?.content ?? null;
|
||||
// Always surface the user-level section so the main assistant knows the
|
||||
// dir exists and can route ad-hoc "remember this cross-project" saves
|
||||
// there. When empty the prompt builder emits a "MEMORY.md is currently
|
||||
|
|
@ -3190,6 +3205,20 @@ export class Config {
|
|||
);
|
||||
}
|
||||
|
||||
private recordAutoMemoryIndexRead(
|
||||
indexPath: string,
|
||||
indexRead: AutoMemoryIndexRead | null,
|
||||
): void {
|
||||
if (indexRead === null || this.getFileReadCacheDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.getFileReadCache().recordRead(indexPath, indexRead.stats, {
|
||||
full: true,
|
||||
cacheable: true,
|
||||
});
|
||||
}
|
||||
|
||||
private buildMemoryContextWarning(memoryContent: string): string | undefined {
|
||||
const contextWindowSize =
|
||||
this.getContentGeneratorConfig()?.contextWindowSize ??
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
createDefaultAutoMemoryMetadata,
|
||||
ensureAutoMemoryScaffold,
|
||||
readAutoMemoryIndex,
|
||||
readAutoMemoryIndexWithStats,
|
||||
} from './store.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { sanitizeCwd } from '../utils/paths.js';
|
||||
|
|
@ -292,4 +293,24 @@ describe('auto-memory storage scaffold', () => {
|
|||
await ensureAutoMemoryScaffold(projectRoot);
|
||||
await expect(readAutoMemoryIndex(projectRoot)).resolves.toBe('');
|
||||
});
|
||||
|
||||
it('returns content and stats for an existing auto-memory index', async () => {
|
||||
await ensureAutoMemoryScaffold(projectRoot);
|
||||
const indexContent = '# Existing Index\n\n- keep me\n';
|
||||
await fs.writeFile(
|
||||
getAutoMemoryIndexPath(projectRoot),
|
||||
indexContent,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const result = await readAutoMemoryIndexWithStats(projectRoot);
|
||||
|
||||
expect(result?.content).toBe(indexContent);
|
||||
expect(result?.stats.size).toBe(Buffer.byteLength(indexContent));
|
||||
expect(result?.stats.mtimeMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns null when reading auto-memory index with stats before creation', async () => {
|
||||
await expect(readAutoMemoryIndexWithStats(projectRoot)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import type { Stats } from 'node:fs';
|
||||
import {
|
||||
AUTO_MEMORY_INDEX_FILENAME,
|
||||
getAutoMemoryExtractCursorPath,
|
||||
|
|
@ -43,6 +44,11 @@ export function createDefaultAutoMemoryIndex(): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
export interface AutoMemoryIndexRead {
|
||||
content: string;
|
||||
stats: Stats;
|
||||
}
|
||||
|
||||
async function writeFileIfMissing(
|
||||
filePath: string,
|
||||
content: string,
|
||||
|
|
@ -95,6 +101,28 @@ export async function readAutoMemoryIndex(
|
|||
}
|
||||
}
|
||||
|
||||
async function readMemoryIndexWithStats(
|
||||
indexPath: string,
|
||||
): Promise<AutoMemoryIndexRead | null> {
|
||||
try {
|
||||
const stats = await fs.stat(indexPath);
|
||||
const content = await fs.readFile(indexPath, 'utf-8');
|
||||
return { content, stats };
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAutoMemoryIndexWithStats(
|
||||
projectRoot: string,
|
||||
): Promise<AutoMemoryIndexRead | null> {
|
||||
return readMemoryIndexWithStats(getAutoMemoryIndexPath(projectRoot));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the user-level (cross-project) auto-memory dir + empty index exist.
|
||||
* Unlike the per-project scaffold, this does NOT seed meta.json or
|
||||
|
|
@ -120,4 +148,8 @@ export async function readUserAutoMemoryIndex(): Promise<string | null> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function readUserAutoMemoryIndexWithStats(): Promise<AutoMemoryIndexRead | null> {
|
||||
return readMemoryIndexWithStats(getUserAutoMemoryIndexPath());
|
||||
}
|
||||
|
||||
export { AUTO_MEMORY_INDEX_FILENAME };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue