feat: 添加 Session Memory 多存储支持

Markdown 文件存储的本地记忆系统,支持多 store 管理、
entry 增删改查和归档,存储于 ~/.claude/local-memory/。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 23:04:09 +08:00
parent b8d86e5279
commit a2ea69c05e
4 changed files with 1036 additions and 0 deletions

View File

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

View File

@@ -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<string, string> = {}
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<string> => {
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<string, unknown>
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')
})
})

View File

@@ -0,0 +1,332 @@
/**
* Multi-store extension of local SessionMemory.
*
* Each store is a directory under ~/.claude/local-memory/<store>/
* Each entry is stored as a markdown file: <key>.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 <store>.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 }
}

View File

@@ -4,6 +4,8 @@ import { roughTokenCountEstimation } from '../../services/tokenEstimation.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getErrnoCode, toError } from '../../utils/errors.js' import { getErrnoCode, toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.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_SECTION_LENGTH = 2000
const MAX_TOTAL_SESSION_MEMORY_TOKENS = 12000 const MAX_TOTAL_SESSION_MEMORY_TOKENS = 12000
@@ -233,9 +235,13 @@ export async function buildSessionMemoryUpdatePrompt(
const sectionReminders = generateSectionReminders(sectionSizes, totalTokens) const sectionReminders = generateSectionReminders(sectionSizes, totalTokens)
// Substitute variables in the prompt // Substitute variables in the prompt
const currentModel = getMainLoopModel()
const variables = { const variables = {
currentNotes, currentNotes,
notesPath, notesPath,
CLAUDE_EFFORT: getDisplayedEffortLevel(currentModel, undefined),
CLAUDE_MODEL: currentModel,
CLAUDE_CWD: process.cwd(),
} }
const basePrompt = substituteVariables(promptTemplate, variables) const basePrompt = substituteVariables(promptTemplate, variables)