From a2ea69c05ebde83dfff3df0bf17da5799bc246fd Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 9 May 2026 23:04:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Session=20Memory?= =?UTF-8?q?=20=E5=A4=9A=E5=AD=98=E5=82=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown 文件存储的本地记忆系统,支持多 store 管理、 entry 增删改查和归档,存储于 ~/.claude/local-memory/。 Co-Authored-By: glm-5-turbo --- .../__tests__/multiStore.test.ts | 308 ++++++++++++++ .../SessionMemory/__tests__/prompts.test.ts | 390 ++++++++++++++++++ src/services/SessionMemory/multiStore.ts | 332 +++++++++++++++ src/services/SessionMemory/prompts.ts | 6 + 4 files changed, 1036 insertions(+) create mode 100644 src/services/SessionMemory/__tests__/multiStore.test.ts create mode 100644 src/services/SessionMemory/__tests__/prompts.test.ts create mode 100644 src/services/SessionMemory/multiStore.ts diff --git a/src/services/SessionMemory/__tests__/multiStore.test.ts b/src/services/SessionMemory/__tests__/multiStore.test.ts new file mode 100644 index 000000000..14dae5501 --- /dev/null +++ b/src/services/SessionMemory/__tests__/multiStore.test.ts @@ -0,0 +1,308 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// No mocks needed — multiStore.ts is pure fs, no log/debug/bun:bundle side effects. + +describe('multiStore', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-test-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + }) + + test('listStores returns empty when no stores exist', async () => { + const { listStores } = await import('../multiStore.js') + expect(listStores()).toEqual([]) + }) + + test('createStore creates a store directory', async () => { + const { createStore, listStores } = await import('../multiStore.js') + createStore('my-store') + expect(listStores()).toContain('my-store') + }) + + test('createStore throws if store already exists', async () => { + const { createStore } = await import('../multiStore.js') + createStore('duplicate') + expect(() => createStore('duplicate')).toThrow('already exists') + }) + + test('setEntry and getEntry round-trip', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('notes') + setEntry('notes', 'hello', '# Hello\nThis is a note.') + expect(getEntry('notes', 'hello')).toBe('# Hello\nThis is a note.') + }) + + test('getEntry returns null for missing key', async () => { + const { createStore, getEntry } = await import('../multiStore.js') + createStore('empty-store') + expect(getEntry('empty-store', 'nonexistent')).toBeNull() + }) + + test('cross-store isolation: entries in different stores do not bleed', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('store-a') + createStore('store-b') + setEntry('store-a', 'shared-key', 'value-from-a') + setEntry('store-b', 'shared-key', 'value-from-b') + expect(getEntry('store-a', 'shared-key')).toBe('value-from-a') + expect(getEntry('store-b', 'shared-key')).toBe('value-from-b') + }) + + test('listEntries returns keys in a store', async () => { + const { createStore, setEntry, listEntries } = await import( + '../multiStore.js' + ) + createStore('listing') + setEntry('listing', 'alpha', 'a') + setEntry('listing', 'beta', 'b') + const entries = listEntries('listing') + expect(entries).toContain('alpha') + expect(entries).toContain('beta') + }) + + test('deleteEntry removes entry and returns true', async () => { + const { createStore, setEntry, deleteEntry, getEntry } = await import( + '../multiStore.js' + ) + createStore('del-store') + setEntry('del-store', 'to-remove', 'temp') + expect(deleteEntry('del-store', 'to-remove')).toBe(true) + expect(getEntry('del-store', 'to-remove')).toBeNull() + }) + + test('deleteEntry returns false for missing entry', async () => { + const { createStore, deleteEntry } = await import('../multiStore.js') + createStore('del-store-2') + expect(deleteEntry('del-store-2', 'ghost')).toBe(false) + }) + + test('archiveStore renames directory with .archived suffix', async () => { + const { createStore, archiveStore, listStores, listAllStores } = + await import('../multiStore.js') + createStore('to-archive') + archiveStore('to-archive') + expect(listStores()).not.toContain('to-archive') + expect(listAllStores()).toContain('to-archive.archived') + }) + + test('large entry round-trip (>500KB)', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('large') + const largeValue = 'A'.repeat(512 * 1024) + setEntry('large', 'big-entry', largeValue) + expect(getEntry('large', 'big-entry')).toBe(largeValue) + }) + + test('Unicode key is rejected (path-safety policy from PR-0a)', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('unicode-store') + // Unicode keys are now rejected by validateKey to keep path-safety + // semantics OS-portable and to enable safe permission rule contents. + // Value can still contain unicode — only the key is constrained. + expect(() => + setEntry('unicode-store', '日本語キー', 'value with 日本語'), + ).toThrow(/invalid key chars/i) + }) + + test('value with unicode is still stored fine (only key is constrained)', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('unicode-value-store') + setEntry('unicode-value-store', 'ascii_key', 'value with 日本語 ✓') + expect(getEntry('unicode-value-store', 'ascii_key')).toBe( + 'value with 日本語 ✓', + ) + }) + + test('backward compat: pre-existing a_b.md file remains readable as a_b key', async () => { + // Simulates the pre-PR-0a state where a user wrote setEntry('s', 'a_b', X) + // OR setEntry('s', 'a/b', X) — both produced a_b.md on disk. After PR-0a, + // the new validateKey rejects 'a/b' but accepts 'a_b'. Existing a_b.md + // files must still load via getEntry('s', 'a_b'). + const { createStore, getEntry } = await import('../multiStore.js') + createStore('compat-store') + const storeDir = join(tmpDir, 'local-memory', 'compat-store') + writeFileSync(join(storeDir, 'a_b.md'), 'legacy content') + expect(getEntry('compat-store', 'a_b')).toBe('legacy content') + }) + + test('key collision regression: a/b is rejected, no longer collides with a_b', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('regression-store') + // a_b is valid and stored + setEntry('regression-store', 'a_b', 'value-from-underscore') + // a/b is now rejected (would have collided pre-PR-0a) + expect(() => + setEntry('regression-store', 'a/b', 'value-from-slash'), + ).toThrow(/invalid key chars/i) + // a_b still has the correct value (no overwrite happened) + expect(getEntry('regression-store', 'a_b')).toBe('value-from-underscore') + }) + + test('Windows reserved name NUL is rejected (would silently lose data on Windows)', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('win-reserved') + expect(() => setEntry('win-reserved', 'NUL', 'lost')).toThrow( + /windows reserved/i, + ) + }) + + test('leading dot key is rejected (.gitconfig)', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('hidden-keys') + expect(() => setEntry('hidden-keys', '.gitconfig', 'x')).toThrow( + /leading dot/i, + ) + }) +}) + +// ── I3 / E1: Path traversal regression tests ───────────────────────────────── +// All these MUST throw BEFORE the fix lands (they test the invariant that +// invalid store names are rejected before any file I/O occurs). + +describe('multiStore: path traversal rejection (E1 regression)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-sec-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + }) + + test('store name ".." is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('..', 'key', 'value')).toThrow() + }) + + test('store name "a/b" is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('a/b', 'key', 'value')).toThrow() + }) + + test('store name "a\\\\b" is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('a\\b', 'key', 'value')).toThrow() + }) + + test('store name with null byte is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('foo\x00bar', 'key', 'value')).toThrow() + }) + + test('store name "C:hack" (Windows drive prefix) is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('C:hack', 'key', 'value')).toThrow() + }) + + test('store name that resolves outside base dir is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + // An encoded-style path that could escape + expect(() => setEntry('../escape', 'key', 'value')).toThrow() + }) + + test('store name too long (>255 chars) is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + const longName = 'a'.repeat(256) + expect(() => setEntry(longName, 'key', 'value')).toThrow() + }) + + test('validateStoreName: accepted store name passes', async () => { + const { createStore } = await import('../multiStore.js') + // Should NOT throw + expect(() => createStore('valid-store-name')).not.toThrow() + }) + + test('D2: value >1MB is rejected', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('size-test') + const bigValue = 'X'.repeat(1_048_577) // 1MB + 1 byte + expect(() => setEntry('size-test', 'big', bigValue)).toThrow() + }) +}) + +// ── M5 (codecov-100 audit #9): getEntryBounded short-read handling ────────── +// The audit flagged that the old loop returned a `readBytes`-sized buffer +// even if readSync delivered fewer bytes (e.g. file truncated mid-read), +// with `truncated=false`. Test pins the new behavior: short reads surface +// as `truncated=true`, and the returned value's length matches what was +// actually read (no trailing zero bytes). + +describe('multiStore: getEntryBounded short-read handling (M5 audit #9)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-bounded-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + }) + + test('getEntryBounded: full read with file <= maxBytes returns truncated=false', async () => { + const { createStore, setEntry, getEntryBounded } = await import( + '../multiStore.js' + ) + createStore('bounded') + setEntry('bounded', 'small', 'hello') + const result = getEntryBounded('bounded', 'small', 1024) + expect(result).not.toBeNull() + expect(result!.value).toBe('hello') + expect(result!.truncated).toBe(false) + }) + + test('getEntryBounded: file larger than maxBytes returns truncated=true and prefix only', async () => { + const { createStore, setEntry, getEntryBounded } = await import( + '../multiStore.js' + ) + createStore('bounded') + setEntry('bounded', 'big', 'X'.repeat(2048)) + const result = getEntryBounded('bounded', 'big', 100) + expect(result).not.toBeNull() + expect(result!.value.length).toBe(100) + expect(result!.value).toBe('X'.repeat(100)) + expect(result!.truncated).toBe(true) + }) + + test('getEntryBounded: returned value has no trailing zero bytes (audit #9 regression)', async () => { + // The old code returned `buf.toString('utf8')` directly — if readSync + // delivered fewer bytes than the buffer was allocated for (statSync + // saw 100 bytes but only 50 were readable by readSync), the returned + // string would have 50 trailing NUL bytes () silently. The new + // code uses subarray(0, offset) so the returned string length matches + // exactly what was read. + const { createStore, setEntry, getEntryBounded } = await import( + '../multiStore.js' + ) + createStore('bounded') + setEntry('bounded', 'exact', 'a'.repeat(50)) + const result = getEntryBounded('bounded', 'exact', 100) + expect(result).not.toBeNull() + // 50-byte file, read with cap of 100 → readBytes=50, buf is 50 bytes, + // value is exactly 50 bytes with no trailing NULs. + expect(result!.value.length).toBe(50) + expect(result!.value).toBe('a'.repeat(50)) + expect(result!.value).not.toContain('') + expect(result!.truncated).toBe(false) + }) + + test('getEntryBounded: returns null for missing entry', async () => { + const { createStore, getEntryBounded } = await import('../multiStore.js') + createStore('bounded') + expect(getEntryBounded('bounded', 'missing', 1024)).toBeNull() + }) +}) diff --git a/src/services/SessionMemory/__tests__/prompts.test.ts b/src/services/SessionMemory/__tests__/prompts.test.ts new file mode 100644 index 000000000..7129a1846 --- /dev/null +++ b/src/services/SessionMemory/__tests__/prompts.test.ts @@ -0,0 +1,390 @@ +import { afterAll, describe, test, expect, mock, beforeEach } from 'bun:test' +import { homedir } from 'node:os' +import { join } from 'node:path' + +// ── Mock infrastructure ───────────────────────────────────────────────────── +// All mock.module calls must precede the import of the module under test. +// mock.module is process-global; mocks here must cover all exported names used +// transitively so sibling test files are not broken by an incomplete mock. +// +// To prevent cross-file pollution (skill prefetch / skillLearning smoke, +// model.test.ts, providers.test.ts), keep the mock surface ONLY for the +// names this suite actually exercises, and delegate to behavior that matches +// the real impl (e.g. isEnvTruthy parses '0'/'false'/'no'/'off' as falsy). +// A sentinel flag flipped in afterAll lets us scope the suite-specific +// override (mocked main-loop model, mocked effort level, fixed config dir). +let useMockForSessionMemory = true +afterAll(() => { + useMockForSessionMemory = false +}) + +const mockGetMainLoopModel = mock(() => 'claude-opus-4-7') +const mockGetDisplayedEffortLevel = mock((): string => 'high') + +const realIsEnvTruthy = (v: string | boolean | undefined): boolean => { + if (!v) return false + if (typeof v === 'boolean') return v + return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim()) +} + +// Inline a minimum env-driven default-Opus resolver so getDefaultOpusModel +// .test.ts (running in the same process) sees env-precedence semantics +// after this suite's flag flips off. Keep aligned with +// src/utils/model/model.ts getDefaultOpusModel(). +function resolveDefaultOpusModelForTests(): string { + if (process.env.CLAUDE_CODE_USE_OPENAI === '1') { + if (process.env.OPENAI_DEFAULT_OPUS_MODEL) + return process.env.OPENAI_DEFAULT_OPUS_MODEL + } + if (process.env.CLAUDE_CODE_USE_GEMINI === '1') { + if (process.env.GEMINI_DEFAULT_OPUS_MODEL) + return process.env.GEMINI_DEFAULT_OPUS_MODEL + } + if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) + return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') + return 'us.anthropic.claude-opus-4-7-v1' + if (process.env.CLAUDE_CODE_USE_VERTEX === '1') return 'claude-opus-4-7' + if (process.env.CLAUDE_CODE_USE_FOUNDRY === '1') return 'claude-opus-4-7' + return 'claude-opus-4-7' +} + +// Inline the real firstPartyNameToCanonical logic so its semantics survive +// even after this suite's mock wins the registration race. Pre-importing +// model.ts hangs the test process due to heavy transitive deps. +function realFirstPartyNameToCanonical(name: string): string { + name = name.toLowerCase() + if (name.includes('claude-opus-4-7')) return 'claude-opus-4-7' + if (name.includes('claude-opus-4-6')) return 'claude-opus-4-6' + if (name.includes('claude-opus-4-5')) return 'claude-opus-4-5' + if (name.includes('claude-opus-4-1')) return 'claude-opus-4-1' + if (name.includes('claude-opus-4')) return 'claude-opus-4' + if (name.includes('claude-sonnet-4-6')) return 'claude-sonnet-4-6' + if (name.includes('claude-sonnet-4-5')) return 'claude-sonnet-4-5' + if (name.includes('claude-sonnet-4')) return 'claude-sonnet-4' + if (name.includes('claude-haiku-4-5')) return 'claude-haiku-4-5' + if (name.includes('claude-3-7-sonnet')) return 'claude-3-7-sonnet' + if (name.includes('claude-3-5-sonnet')) return 'claude-3-5-sonnet' + if (name.includes('claude-3-5-haiku')) return 'claude-3-5-haiku' + if (name.includes('claude-3-opus')) return 'claude-3-opus' + if (name.includes('claude-3-sonnet')) return 'claude-3-sonnet' + if (name.includes('claude-3-haiku')) return 'claude-3-haiku' + const m = name.match(/(claude-(\d+-\d+-)?\w+)/) + if (m && m[1]) return m[1] + return name +} + +mock.module('src/utils/model/model.js', () => ({ + getMainLoopModel: mockGetMainLoopModel, + getSmallFastModel: mock(() => 'claude-haiku'), + getUserSpecifiedModelSetting: mock(() => undefined), + getBestModel: mock(() => 'claude-opus-4-7'), + getDefaultOpusModel: mock(() => + useMockForSessionMemory + ? 'claude-opus-4-7' + : resolveDefaultOpusModelForTests(), + ), + getDefaultSonnetModel: mock(() => 'claude-sonnet-4-6'), + getDefaultHaikuModel: mock(() => 'claude-haiku-3-5'), + getRuntimeMainLoopModel: mock(() => 'claude-opus-4-7'), + getDefaultMainLoopModelSetting: mock(() => 'claude-opus-4-7'), + getDefaultMainLoopModel: mock(() => 'claude-opus-4-7'), + firstPartyNameToCanonical: mock((n: string) => + realFirstPartyNameToCanonical(n), + ), + getCanonicalName: mock((n: string) => n), + getClaudeAiUserDefaultModelDescription: mock(() => ''), + renderDefaultModelSetting: mock(() => ''), + getOpusPricingSuffix: mock(() => ''), + isOpus1mMergeEnabled: mock(() => false), + renderModelSetting: mock((s: string) => s), + getPublicModelDisplayName: mock(() => null), + renderModelName: mock((n: string) => n), + getPublicModelName: mock((n: string) => n), + parseUserSpecifiedModel: mock((m: string) => m), + resolveSkillModelOverride: mock(() => undefined), + isLegacyModelRemapEnabled: mock(() => false), + modelDisplayString: mock(() => ''), + getMarketingNameForModel: mock(() => undefined), + normalizeModelStringForAPI: mock((m: string) => m), + isNonCustomOpusModel: mock(() => false), +})) + +mock.module('src/utils/effort.js', () => ({ + getDisplayedEffortLevel: mockGetDisplayedEffortLevel as ( + _m: string, + _e: unknown, + ) => string, + getEffortEnvOverride: mock(() => undefined), + resolveAppliedEffort: mock(() => 'high'), + getInitialEffortSetting: mock(() => undefined), + parseEffortValue: mock(() => undefined), + toPersistableEffort: mock(() => undefined), + modelSupportsEffort: mock(() => true), + modelSupportsMaxEffort: mock(() => true), + modelSupportsXhighEffort: mock(() => false), + isEffortLevel: mock(() => true), + getEffortSuffix: mock(() => ''), + convertEffortValueToLevel: mock(() => 'high'), + getDefaultEffortForModel: mock(() => undefined), + getEffortLevelDescription: mock(() => ''), + getEffortValueDescription: mock(() => ''), + getOpusDefaultEffortConfig: mock(() => ({ + enabled: true, + dialogTitle: '', + dialogDescription: '', + })), + resolvePickerEffortPersistence: mock(() => undefined), + isValidNumericEffort: mock(() => false), + EFFORT_LEVELS: ['low', 'medium', 'high', 'xhigh', 'max'], +})) + +// Use REAL semantics for non-overridden envUtils exports — this mock is +// process-global, so envUtils.test.ts and other consumers running in the +// same process must see correct behavior. +const realIsEnvDefinedFalsy = (v: string | boolean | undefined): boolean => { + if (v === undefined) return false + if (typeof v === 'boolean') return !v + if (!v) return false + return ['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim()) +} +const realDefaultVertexRegion = (): string => + process.env.CLOUD_ML_REGION || 'us-east5' +const VERTEX_REGION_OVERRIDES_SM: ReadonlyArray<[string, string]> = [ + ['claude-haiku-4-5', 'VERTEX_REGION_CLAUDE_HAIKU_4_5'], + ['claude-3-5-haiku', 'VERTEX_REGION_CLAUDE_3_5_HAIKU'], + ['claude-3-5-sonnet', 'VERTEX_REGION_CLAUDE_3_5_SONNET'], + ['claude-3-7-sonnet', 'VERTEX_REGION_CLAUDE_3_7_SONNET'], + ['claude-opus-4-1', 'VERTEX_REGION_CLAUDE_4_1_OPUS'], + ['claude-opus-4', 'VERTEX_REGION_CLAUDE_4_0_OPUS'], + ['claude-sonnet-4-6', 'VERTEX_REGION_CLAUDE_4_6_SONNET'], + ['claude-sonnet-4-5', 'VERTEX_REGION_CLAUDE_4_5_SONNET'], + ['claude-sonnet-4', 'VERTEX_REGION_CLAUDE_4_0_SONNET'], +] + +// Real getClaudeConfigHomeDir is memoized via lodash, so consumers may call +// `.cache.clear()` on it. Provide a no-op .cache stub. +const mockedGetClaudeConfigHomeDirSM: (() => string) & { + cache: { clear: () => void; get: (k: unknown) => unknown } +} = Object.assign( + () => + useMockForSessionMemory + ? '/mock/home/.claude' + : (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')).normalize( + 'NFC', + ), + { cache: { clear: () => {}, get: (_k: unknown) => undefined } }, +) + +mock.module('src/utils/envUtils.js', () => ({ + getClaudeConfigHomeDir: mockedGetClaudeConfigHomeDirSM, + isEnvTruthy: realIsEnvTruthy, + getEnvBool: () => false, + getEnvNumber: () => undefined, + getVertexRegionForModel: (model: string | undefined) => { + if (model) { + const match = VERTEX_REGION_OVERRIDES_SM.find(([prefix]) => + model.startsWith(prefix), + ) + if (match) { + return process.env[match[1]] || realDefaultVertexRegion() + } + } + return realDefaultVertexRegion() + }, + getTeamsDir: () => + join( + useMockForSessionMemory + ? '/mock/home/.claude' + : (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')), + 'teams', + ), + hasNodeOption: (flag: string) => { + const opts = process.env.NODE_OPTIONS + return !!opts && opts.split(/\s+/).includes(flag) + }, + isEnvDefinedFalsy: realIsEnvDefinedFalsy, + isBareMode: () => + realIsEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) || + process.argv.includes('--bare'), + parseEnvVars: (rawEnvArgs: string[] | undefined) => { + const parsed: Record = {} + if (rawEnvArgs) { + for (const envStr of rawEnvArgs) { + const [key, ...valueParts] = envStr.split('=') + if (!key || valueParts.length === 0) { + throw new Error( + `Invalid environment variable format: ${envStr}, environment variables should be added as: -e KEY1=value1 -e KEY2=value2`, + ) + } + parsed[key] = valueParts.join('=') + } + } + return parsed + }, + getAWSRegion: () => + process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1', + getDefaultVertexRegion: realDefaultVertexRegion, + shouldMaintainProjectWorkingDir: () => + realIsEnvTruthy(process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR), + isRunningOnHomespace: () => + process.env.USER_TYPE === 'ant' && + realIsEnvTruthy(process.env.COO_RUNNING_ON_HOMESPACE), + isInProtectedNamespace: () => false, +})) + +mock.module('src/utils/log.js', () => ({ + logError: mock(() => {}), + getLogDisplayTitle: mock(() => ''), + dateToFilename: mock((d: Date) => d.toISOString()), + attachErrorLogSink: mock(() => {}), + getInMemoryErrors: mock(() => []), + loadErrorLogs: mock(async () => []), + getErrorLogByIndex: mock(async () => null), + logMCPError: mock(() => {}), + logMCPDebug: mock(() => {}), + captureAPIRequest: mock(() => {}), + _resetErrorLogForTesting: mock(() => {}), +})) + +mock.module('src/services/tokenEstimation.js', () => ({ + roughTokenCountEstimation: mock((s: string) => Math.ceil(s.length / 4)), + countTokens: mock(async () => 0), +})) + +mock.module('src/utils/errors.js', () => ({ + getErrnoCode: mock((e: unknown) => (e as NodeJS.ErrnoException)?.code), + toError: mock((e: unknown) => + e instanceof Error ? e : new Error(String(e)), + ), +})) + +// Mock fs/promises so loadSessionMemoryPrompt() and loadSessionMemoryTemplate() +// return our controlled templates. Once afterAll flips +// useMockForSessionMemory off, readFile delegates to the real impl so +// sibling tests in the same process (skill prefetch, skillLearning smoke) +// still see real disk reads. We must list every export the prefetch / +// skillLearning paths use so this process-global mock doesn't strip names +// to undefined. +// +// Instead of pre-importing node:fs/promises (which can interact poorly +// with bun:test mock processing), use require() at mock-factory-call time +// to fetch the real module lazily. +const mockReadFileFsPromises = mock( + async (_path: string, _opts?: unknown): Promise => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }, +) + +mock.module('fs/promises', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const real = require('node:fs/promises') as Record + return { + ...real, + readFile: ((path: unknown, opts?: unknown) => { + if (useMockForSessionMemory) { + return mockReadFileFsPromises(path as string, opts) + } + return (real.readFile as (...a: unknown[]) => unknown)( + path as string, + opts, + ) + }) as typeof real.readFile, + } +}) + +// ── Import module under test (after all mock.module calls) ────────────────── +import { buildSessionMemoryUpdatePrompt } from '../prompts.js' + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('buildSessionMemoryUpdatePrompt – dynamic variable substitution', () => { + beforeEach(() => { + mockGetMainLoopModel.mockReturnValue('claude-opus-4-7') + mockGetDisplayedEffortLevel.mockReturnValue('high') + // Default: ENOENT so the built-in default prompt is used + mockReadFileFsPromises.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + }) + + test('substitutes {{CLAUDE_MODEL}} with the current model', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return 'Model: {{CLAUDE_MODEL}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-opus-4-7') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('Model: claude-opus-4-7') + expect(result).not.toContain('{{CLAUDE_MODEL}}') + }) + + test('substitutes {{CLAUDE_EFFORT}} with the current effort level', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return 'Effort: {{CLAUDE_EFFORT}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetDisplayedEffortLevel.mockReturnValue('high') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('Effort: high') + expect(result).not.toContain('{{CLAUDE_EFFORT}}') + }) + + test('substitutes {{CLAUDE_CWD}} with process.cwd()', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) return 'CWD: {{CLAUDE_CWD}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain(`CWD: ${process.cwd()}`) + expect(result).not.toContain('{{CLAUDE_CWD}}') + }) + + test('substitutes all three dynamic variables in one template', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return 'effort={{CLAUDE_EFFORT}} model={{CLAUDE_MODEL}} cwd={{CLAUDE_CWD}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-sonnet-4-6') + mockGetDisplayedEffortLevel.mockReturnValue('medium') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('effort=medium') + expect(result).toContain('model=claude-sonnet-4-6') + expect(result).toContain(`cwd=${process.cwd()}`) + }) + + test('leaves unknown template variables unchanged', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return '{{UNKNOWN_VAR}} {{CLAUDE_MODEL}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-opus-4-7') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('{{UNKNOWN_VAR}}') + expect(result).toContain('claude-opus-4-7') + }) + + test('existing substitution variables still work alongside new ones', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return '{{notesPath}} effort={{CLAUDE_EFFORT}} model={{CLAUDE_MODEL}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-haiku') + mockGetDisplayedEffortLevel.mockReturnValue('low') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('/notes.md') + expect(result).toContain('effort=low') + expect(result).toContain('model=claude-haiku') + }) +}) diff --git a/src/services/SessionMemory/multiStore.ts b/src/services/SessionMemory/multiStore.ts new file mode 100644 index 000000000..f740e1bf6 --- /dev/null +++ b/src/services/SessionMemory/multiStore.ts @@ -0,0 +1,332 @@ +/** + * Multi-store extension of local SessionMemory. + * + * Each store is a directory under ~/.claude/local-memory// + * Each entry is stored as a markdown file: .md + * + * This is a new sibling layer — does NOT modify sessionMemory.ts. + */ + +import { + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + renameSync, + rmSync, + statSync, + closeSync, + writeFileSync, +} from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { randomBytes } from 'node:crypto' +import { validateKey } from '../../utils/localValidate.js' + +// ── Path helpers ────────────────────────────────────────────────────────────── + +// L8 fix: cache the result so repeated tool calls don't re-do homedir() + +// join() on every list/fetch. Cache is keyed on the env var so a test that +// changes CLAUDE_CONFIG_DIR mid-process still picks up the new dir. +let _baseDirCache: { configDir: string; baseDir: string } | undefined +function getBaseDir(): string { + const configDir = + process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude') + if (_baseDirCache && _baseDirCache.configDir === configDir) { + return _baseDirCache.baseDir + } + const baseDir = join(configDir, 'local-memory') + _baseDirCache = { configDir, baseDir } + return baseDir +} + +function getStoreDir(store: string): string { + return join(getBaseDir(), store) +} + +function getEntryPath(store: string, key: string): string { + // PR-0a fix: validateKey rejects any '/' or '\' (and other unsafe chars) + // up front, so the previous .replace(/[/\\]/g, '_') sanitize is no longer + // needed and was actually harmful: it caused 'a/b' and 'a_b' to collide + // on the same a_b.md file. Backward compat: pre-existing a_b.md files + // (regardless of the original key the user typed) remain readable as + // key='a_b' under the new validator. + validateKey(key) + return join(getStoreDir(store), `${key}.md`) +} + +/** Maximum allowed store name length (OS path component limit). */ +const MAX_STORE_NAME_LENGTH = 255 +/** Maximum allowed entry value size: 1 MB. */ +const MAX_VALUE_BYTES = 1_048_576 + +/** + * Validates a store name for path-safety. + * + * Rejects: + * - empty string + * - names that do not equal their own basename (path-like, e.g. "a/b", "../x") + * - forward slash, backslash, null byte, colon (Windows drive prefix: "C:foo") + * - names starting with "." (hidden/relative marker) + * - the literal ".." string + * - names longer than 255 characters + * + * E1 fix: hardened against path traversal on Windows and POSIX. + */ +export function isValidStoreName(store: string): boolean { + try { + validateStoreName(store) + return true + } catch { + return false + } +} + +function validateStoreName(store: string): void { + if (!store) { + throw new Error('Invalid store name: store name must not be empty.') + } + if (store.length > MAX_STORE_NAME_LENGTH) { + throw new Error( + `Invalid store name: "${store.slice(0, 20)}…" is too long (max ${MAX_STORE_NAME_LENGTH} chars).`, + ) + } + // Reject path separators (forward slash, backslash), Windows drive colons. + // Null bytes checked separately to avoid biome noControlCharactersInRegex warning. + if (/[/\\:]/.test(store) || store.includes('\0')) { + throw new Error( + `Invalid store name: "${store}" contains illegal characters (path separators, null byte, or colon).`, + ) + } + // Reject names starting with "." — covers ".." and hidden names + if (store.startsWith('.')) { + throw new Error(`Invalid store name: "${store}" must not start with ".".`) + } + // Guard: resolved basename must equal the store name itself. + // This catches any path-like names that slipped through the above checks. + if (basename(store) !== store) { + throw new Error( + `Invalid store name: "${store}" is path-like and would escape the base directory.`, + ) + } +} + +// validateKey is now imported from src/utils/localValidate.ts (shared with PR-1/2) + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** List all active (non-archived) stores. */ +export function listStores(): string[] { + const baseDir = getBaseDir() + if (!existsSync(baseDir)) return [] + return readdirSync(baseDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.endsWith('.archived')) + .map(d => d.name) + .sort() +} + +/** List all stores (active + archived). */ +export function listAllStores(): string[] { + const baseDir = getBaseDir() + if (!existsSync(baseDir)) return [] + return readdirSync(baseDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + .sort() +} + +/** Create a new store directory. */ +export function createStore(store: string): void { + validateStoreName(store) + const storeDir = getStoreDir(store) + if (existsSync(storeDir)) { + throw new Error(`Store "${store}" already exists`) + } + mkdirSync(storeDir, { recursive: true }) +} + +/** Archive a store by renaming it to .archived */ +export function archiveStore(store: string): void { + validateStoreName(store) + const storeDir = getStoreDir(store) + if (!existsSync(storeDir)) { + throw new Error(`Store "${store}" does not exist`) + } + const archivedDir = storeDir + '.archived' + renameSync(storeDir, archivedDir) +} + +/** Write an entry to a store. Creates the store dir if needed. */ +export function setEntry(store: string, key: string, value: string): void { + validateStoreName(store) + validateKey(key) + + // D2: Guard against unbounded value sizes (1 MB limit). + // File-fallback vault is not designed for large data blobs. + const byteLength = Buffer.byteLength(value, 'utf8') + if (byteLength > MAX_VALUE_BYTES) { + throw new Error( + `Entry value too large: ${byteLength} bytes exceeds the 1 MB limit. ` + + 'Use external storage for large data.', + ) + } + + const storeDir = getStoreDir(store) + if (!existsSync(storeDir)) { + mkdirSync(storeDir, { recursive: true }) + } + const entryPath = getEntryPath(store, key) + + // C2: Atomic write — write to a .tmp file then rename. + // On POSIX, rename(2) is atomic; on Windows it is best-effort but safe. + // This prevents half-written files on crash mid-write. + const tmpPath = join(storeDir, `.${randomBytes(8).toString('hex')}.tmp`) + try { + writeFileSync(tmpPath, value, 'utf8') + renameSync(tmpPath, entryPath) + } catch (err) { + // Clean up tmp file on error + try { + rmSync(tmpPath, { force: true }) + } catch { + /* ignore cleanup error */ + } + throw err + } +} + +/** Read an entry from a store. Returns null if not found. */ +export function getEntry(store: string, key: string): string | null { + validateStoreName(store) + validateKey(key) + const entryPath = getEntryPath(store, key) + if (!existsSync(entryPath)) return null + return readFileSync(entryPath, 'utf8') +} + +/** + * M4 fix: bounded read variant. Returns at most `maxBytes` bytes from the + * entry file. If the on-disk file is larger, returns the prefix and sets + * truncated=true. Caller should not assume the returned string is a complete + * entry. Used by LocalMemoryRecallTool to defend against externally written + * 1GB markdown files (the in-tool 1MB cap only guards setEntry; an attacker + * with file system access could write any size). + * + * Bytes are read from a single fd, not the whole file. Result is decoded as + * UTF-8 with truncate-at-codepoint-boundary semantics handled by the caller + * (truncateUtf8 in LocalMemoryRecallTool). + */ +export function getEntryBounded( + store: string, + key: string, + maxBytes: number, +): { value: string; truncated: boolean } | null { + validateStoreName(store) + validateKey(key) + const entryPath = getEntryPath(store, key) + if (!existsSync(entryPath)) return null + const stat = statSync(entryPath) + const total = stat.size + const readBytes = Math.min(total, maxBytes) + const buf = Buffer.alloc(readBytes) + const fd = openSync(entryPath, 'r') + // M5 fix (codecov-100 audit #9): track how many bytes we ACTUALLY read, + // and surface short-reads as truncation. Previously the loop returned + // `buf` (a `readBytes`-sized allocation) regardless of whether the + // readSync calls cumulatively delivered that many bytes — a file that + // was truncated on disk between statSync and readSync would yield a + // half-zeroed buffer with truncated=false, silently corrupting the + // returned string. + let offset = 0 + try { + while (offset < readBytes) { + const n = readSync(fd, buf, offset, readBytes - offset, offset) + if (n === 0) break // EOF: file shrank between stat and read + // n < 0 cannot happen — Node's readSync throws on errno < 0 — but + // belt-and-suspenders for clarity: treat negative as EOF. + if (n < 0) break + offset += n + } + } finally { + closeSync(fd) + } + // M5: include `offset < readBytes` in the truncated flag so callers see + // EOF-during-read as truncation. Use subarray(0, offset) so the value + // length matches what we actually read (no trailing zero bytes). + const truncated = total > maxBytes || offset < readBytes + return { value: buf.subarray(0, offset).toString('utf8'), truncated } +} + +/** Delete an entry from a store. Returns true if it existed. */ +export function deleteEntry(store: string, key: string): boolean { + validateStoreName(store) + validateKey(key) + const entryPath = getEntryPath(store, key) + if (!existsSync(entryPath)) return false + rmSync(entryPath) + return true +} + +/** List all entry keys in a store (without .md extension). */ +export function listEntries(store: string): string[] { + validateStoreName(store) + const storeDir = getStoreDir(store) + if (!existsSync(storeDir)) return [] + return readdirSync(storeDir) + .filter(f => f.endsWith('.md')) + .map(f => f.slice(0, -3)) + .sort() +} + +/** + * M5 + F4 fix: truly bounded list variant. + * + * F4 (Codex round 6) found that the previous implementation collected every + * .md filename into memory and sorted them all before slicing — that meant + * a 100k-entry store still paid O(N) memory + O(N log N) sort. The cap + * only limited what we returned to the caller, not what we processed. + * + * New approach: walk the dirents and maintain a bounded "top-K" buffer. + * For maxEntries entries we keep the K alphabetically smallest names seen + * so far. We use a simple insertion-sort-style approach with linear scan + * because K is small (typically 1024) — for the realistic store sizes + * (≤10k entries) the O(N×K) cost (~10M comparisons) is well under 100ms. + * For pathological stores (1M+ entries) we still paid linear time on + * readdirSync which lists the entire directory; truly avoiding that + * needs an async streaming dirent walk that we'll do in a follow-up. + * + * Memory after this fix: O(K) instead of O(N). + */ +export function listEntriesBounded( + store: string, + maxEntries: number, +): { entries: string[]; truncated: boolean } { + validateStoreName(store) + const storeDir = getStoreDir(store) + if (!existsSync(storeDir)) return { entries: [], truncated: false } + // Bounded top-K accumulator. We keep `top` sorted ascending and never + // grow beyond `maxEntries` items. + const top: string[] = [] + let totalMd = 0 + for (const f of readdirSync(storeDir)) { + if (!f.endsWith('.md')) continue + totalMd++ + const key = f.slice(0, -3) + if (top.length < maxEntries) { + // Insert in sorted position (linear scan, K bounded so cheap) + let i = 0 + while (i < top.length && top[i]! < key) i++ + top.splice(i, 0, key) + } else if (key < top[maxEntries - 1]!) { + // key is smaller than current largest in top; insert and pop largest + let i = 0 + while (i < top.length && top[i]! < key) i++ + top.splice(i, 0, key) + top.pop() + } + // else: key is larger than current top-K largest, skip + } + return { entries: top, truncated: totalMd > maxEntries } +} diff --git a/src/services/SessionMemory/prompts.ts b/src/services/SessionMemory/prompts.ts index dc889cbe6..e94068d2d 100644 --- a/src/services/SessionMemory/prompts.ts +++ b/src/services/SessionMemory/prompts.ts @@ -4,6 +4,8 @@ import { roughTokenCountEstimation } from '../../services/tokenEstimation.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getErrnoCode, toError } from '../../utils/errors.js' import { logError } from '../../utils/log.js' +import { getDisplayedEffortLevel } from '../../utils/effort.js' +import { getMainLoopModel } from '../../utils/model/model.js' const MAX_SECTION_LENGTH = 2000 const MAX_TOTAL_SESSION_MEMORY_TOKENS = 12000 @@ -233,9 +235,13 @@ export async function buildSessionMemoryUpdatePrompt( const sectionReminders = generateSectionReminders(sectionSizes, totalTokens) // Substitute variables in the prompt + const currentModel = getMainLoopModel() const variables = { currentNotes, notesPath, + CLAUDE_EFFORT: getDisplayedEffortLevel(currentModel, undefined), + CLAUDE_MODEL: currentModel, + CLAUDE_CWD: process.cwd(), } const basePrompt = substituteVariables(promptTemplate, variables)