diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index be9d521e12..2cd401ce45 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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'); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 8015b3161a..862ac49c9a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 ?? diff --git a/packages/core/src/memory/store.test.ts b/packages/core/src/memory/store.test.ts index 3402b9a2a2..8d20da2368 100644 --- a/packages/core/src/memory/store.test.ts +++ b/packages/core/src/memory/store.test.ts @@ -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(); + }); }); diff --git a/packages/core/src/memory/store.ts b/packages/core/src/memory/store.ts index 4a09a96fa0..9ff0b4fe70 100644 --- a/packages/core/src/memory/store.ts +++ b/packages/core/src/memory/store.ts @@ -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 { + 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 { + 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 { } } +export async function readUserAutoMemoryIndexWithStats(): Promise { + return readMemoryIndexWithStats(getUserAutoMemoryIndexPath()); +} + export { AUTO_MEMORY_INDEX_FILENAME };