diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index 2bc8d4c813..2438ec078f 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { Storage } from '../config/storage.js'; -import { QWEN_DIR, sanitizeCwd } from '../utils/paths.js'; +import { QWEN_DIR, resolvePath, sanitizeCwd } from '../utils/paths.js'; import type { AutoMemoryType } from './types.js'; export const AUTO_MEMORY_DIRNAME = 'memory'; @@ -81,38 +81,42 @@ function findCanonicalGitRoot(startPath: string): string | null { /** * Returns the base directory for all auto-memory storage. - * Defaults to the global qwen dir (`~/.qwen` or `$QWEN_HOME`); + * Defaults to the runtime output dir (`runtimeOutputDir`, `QWEN_RUNTIME_DIR`, + * or the global qwen dir); * overridable via QWEN_CODE_MEMORY_BASE_DIR for tests. */ export function getMemoryBaseDir(): string { if (process.env['QWEN_CODE_MEMORY_BASE_DIR']) { - return process.env['QWEN_CODE_MEMORY_BASE_DIR']; + return resolvePath(undefined, process.env['QWEN_CODE_MEMORY_BASE_DIR']); } - return Storage.getGlobalQwenDir(); + return Storage.getRuntimeBaseDir(); } -// Memoize by projectRoot — findCanonicalGitRoot() walks the file system (existsSync -// per directory) and is called from hot-path code such as schedulers and scanners. +// Memoize by projectRoot plus the runtime-specific base dir. In daemon mode, +// different sessions can share a project root while writing to different output dirs. const _autoMemoryRootCache = new Map(); export function getAutoMemoryRoot(projectRoot: string): string { - const cached = _autoMemoryRootCache.get(projectRoot); + const useLocalMemory = process.env['QWEN_CODE_MEMORY_LOCAL'] === '1'; + const memoryBaseDir = useLocalMemory ? '' : getMemoryBaseDir(); + const cacheKey = `${useLocalMemory ? 'local' : memoryBaseDir}\0${projectRoot}`; + const cached = _autoMemoryRootCache.get(cacheKey); if (cached !== undefined) return cached; let result: string; - if (process.env['QWEN_CODE_MEMORY_LOCAL'] === '1') { + if (useLocalMemory) { result = path.join(projectRoot, QWEN_DIR, AUTO_MEMORY_DIRNAME); } else { const canonicalRoot = findCanonicalGitRoot(projectRoot) ?? path.resolve(projectRoot); result = path.join( - getMemoryBaseDir(), + memoryBaseDir, 'projects', sanitizeCwd(canonicalRoot), AUTO_MEMORY_DIRNAME, ); } - _autoMemoryRootCache.set(projectRoot, result); + _autoMemoryRootCache.set(cacheKey, result); return result; } diff --git a/packages/core/src/memory/store.test.ts b/packages/core/src/memory/store.test.ts index 0d02cf865d..1ec8a7d5b9 100644 --- a/packages/core/src/memory/store.test.ts +++ b/packages/core/src/memory/store.test.ts @@ -15,6 +15,7 @@ import { getAutoMemoryMetadataPath, getAutoMemoryRoot, getAutoMemoryTopicPath, + clearAutoMemoryRootCache, } from './paths.js'; import { createDefaultAutoMemoryIndex, @@ -22,18 +23,59 @@ import { ensureAutoMemoryScaffold, readAutoMemoryIndex, } from './store.js'; +import { Storage } from '../config/storage.js'; +import { sanitizeCwd } from '../utils/paths.js'; + +const originalMemoryLocal = process.env['QWEN_CODE_MEMORY_LOCAL']; +const originalMemoryBaseDir = process.env['QWEN_CODE_MEMORY_BASE_DIR']; +const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; describe('auto-memory storage scaffold', () => { let tempDir: string; let projectRoot: string; beforeEach(async () => { + clearAutoMemoryRootCache(); + Storage.setRuntimeBaseDir(null); + if (originalMemoryLocal === undefined) { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + } else { + process.env['QWEN_CODE_MEMORY_LOCAL'] = originalMemoryLocal; + } + if (originalMemoryBaseDir === undefined) { + delete process.env['QWEN_CODE_MEMORY_BASE_DIR']; + } else { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = originalMemoryBaseDir; + } + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-')); projectRoot = path.join(tempDir, 'project'); await fs.mkdir(projectRoot, { recursive: true }); }); afterEach(async () => { + clearAutoMemoryRootCache(); + Storage.setRuntimeBaseDir(null); + if (originalMemoryLocal === undefined) { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + } else { + process.env['QWEN_CODE_MEMORY_LOCAL'] = originalMemoryLocal; + } + if (originalMemoryBaseDir === undefined) { + delete process.env['QWEN_CODE_MEMORY_BASE_DIR']; + } else { + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = originalMemoryBaseDir; + } + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } await fs.rm(tempDir, { recursive: true, force: true, @@ -63,6 +105,106 @@ describe('auto-memory storage scaffold', () => { ); }); + it('uses the runtime output directory for managed auto-memory', () => { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + const runtimeDir = path.join(tempDir, 'runtime-output'); + Storage.setRuntimeBaseDir(runtimeDir); + clearAutoMemoryRootCache(); + + expect(getAutoMemoryRoot(projectRoot)).toBe( + path.join( + runtimeDir, + 'projects', + sanitizeCwd(path.resolve(projectRoot)), + 'memory', + ), + ); + }); + + it('uses QWEN_RUNTIME_DIR for managed auto-memory', () => { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + const envRuntimeDir = path.join(tempDir, 'env-runtime-output'); + process.env['QWEN_RUNTIME_DIR'] = envRuntimeDir; + Storage.setRuntimeBaseDir(path.join(tempDir, 'settings-runtime-output')); + clearAutoMemoryRootCache(); + + expect(getAutoMemoryRoot(projectRoot)).toBe( + path.join( + envRuntimeDir, + 'projects', + sanitizeCwd(path.resolve(projectRoot)), + 'memory', + ), + ); + }); + + it('does not reuse cached roots across runtime output dirs', () => { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + const runtimeA = path.join(tempDir, 'runtime-a'); + const runtimeB = path.join(tempDir, 'runtime-b'); + + const rootA = Storage.runWithRuntimeBaseDir(runtimeA, undefined, () => + getAutoMemoryRoot(projectRoot), + ); + const rootB = Storage.runWithRuntimeBaseDir(runtimeB, undefined, () => + getAutoMemoryRoot(projectRoot), + ); + + expect(rootA).toBe( + path.join( + runtimeA, + 'projects', + sanitizeCwd(path.resolve(projectRoot)), + 'memory', + ), + ); + expect(rootB).toBe( + path.join( + runtimeB, + 'projects', + sanitizeCwd(path.resolve(projectRoot)), + 'memory', + ), + ); + }); + + it('keeps QWEN_CODE_MEMORY_BASE_DIR ahead of the runtime output directory', () => { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + const memoryBaseDir = path.join(tempDir, 'memory-base'); + const runtimeDir = path.join(tempDir, 'runtime-output'); + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = memoryBaseDir; + Storage.setRuntimeBaseDir(runtimeDir); + clearAutoMemoryRootCache(); + + expect(getAutoMemoryRoot(projectRoot)).toBe( + path.join( + memoryBaseDir, + 'projects', + sanitizeCwd(path.resolve(projectRoot)), + 'memory', + ), + ); + }); + + it('resolves QWEN_CODE_MEMORY_BASE_DIR before using it', () => { + delete process.env['QWEN_CODE_MEMORY_LOCAL']; + const memoryBaseDir = path.join(tempDir, 'relative-memory-base'); + process.env['QWEN_CODE_MEMORY_BASE_DIR'] = path.relative( + process.cwd(), + memoryBaseDir, + ); + clearAutoMemoryRootCache(); + + expect(getAutoMemoryRoot(projectRoot)).toBe( + path.join( + memoryBaseDir, + 'projects', + sanitizeCwd(path.resolve(projectRoot)), + 'memory', + ), + ); + }); + it('creates a complete managed auto-memory scaffold', async () => { const now = new Date('2026-04-01T08:00:00.000Z'); await ensureAutoMemoryScaffold(projectRoot, now);