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:
han 2026-07-23 19:27:47 +08:00 committed by GitHub
parent 5cad7fe642
commit fcc250beb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 258 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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