claude-code-best/src/services/SessionMemory/multiStore.ts
claude-code-best 87f5107024 feat: 添加 Session Memory 多存储支持
Markdown 文件存储的本地记忆系统,支持多 store 管理、
entry 增删改查和归档,存储于 ~/.claude/local-memory/。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-09 23:04:09 +08:00

333 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 }
}