Claude Code源码系列:4、2-关键功能模块-记忆系统
1. 系统概述 (System Overview)
一句话核心价值
Claude Code 记忆系统是一个跨会话的、基于文件的持久化上下文管理系统,通过严格的四类型分类法(user/feedback/project/reference)自动提取和存储”不可从当前项目状态推导”的关键信息,确保 AI 在未来对话中能够持续理解用户偏好、项目背景和行为指导。
架构图 (ASCII)
┌───────────────────────────────────────────────────────────────────────┐│ Claude Code Memory System ││ (跨会话持久化上下文管理架构) │└───────────────────────────────────────────────────────────────────────┘ ┌──────────────┐ │ 用户对话 │ │ (Messages) │ └──────┬───────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ Query Loop 结束触发 │ │ (stopHooks.ts - handleStopHooks) │ └───────────────────────────┬────────────────────────────────┘ │ ┌───────────────┴───────────────┐ │ │ ▼ ▼ ┌─────────────────────┐ ┌─────────────────────────────┐ │ 主代理直接写入 │ │ 后台提取代理 (Forked) │ │ (hasMemoryWrites │ ───────│ extractMemories.ts │ │ Since 检查跳过) │ 互斥 │ runForkedAgent 模式 │ └─────────────────────┘ └─────────────────────────────┘ │ │ │ ▼ │ ┌─────────────────────────────┐ │ │ 工具权限沙箱 │ │ │ createAutoMemCanUseTool │ │ │ (Read/Grep/Glob + 记忆目录 │ │ │ 内 Edit/Write only) │ │ └─────────────────────────────┘ │ │ └───────────────┬───────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 记忆文件写入 │ │ ~/.claude/projects/<slug>/memory/ │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ MEMORY.md 索引 │ │ (最多 200 行 / 25KB, 截断警告) │ └───────────────────────────┬────────────────────────────────┘ │ ┌───────────────┴───────────────┐ │ │ ▼ ▼ ┌─────────────────────┐ ┌─────────────────────────────┐ │ 私有记忆 │ │ 团队记忆 │ │ memory/*.md │ │ memory/team/*.md │ │ (用户个人偏好) │ │ (项目共享约定) │ └─────────────────────┘ │ + 文件Watcher同步 │ └─────────────────────────────┘ ┌──────────────┐ │ 新会话启动 │ └──────┬───────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ loadMemoryPrompt() │ │ (memdir.ts - 系统提示注入) │ └───────────────────────────┬────────────────────────────────┘ │ ┌───────────────┴───────────────┐ │ │ ▼ ▼ ┌─────────────────────┐ ┌─────────────────────────────┐ │ buildMemoryLines │ │ buildCombinedMemoryPrompt │ │ (单目录模式) │ │ (私有+团队组合模式) │ └─────────────────────┘ └─────────────────────────────┘ │ │ └───────────────┬───────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ System Prompt Section 注册 │ │ prompts.ts - 'memory' 动态提示部分 │ └────────────────────────────────────────────────────────────┘
核心组件概念 (3-4个)
|
|
|
|
|---|---|---|
| Memory Types (记忆类型) |
user / feedback / project / reference |
|
| MEMORY.md Index (索引文件) |
|
|
| Forked Agent (分支代理) |
|
|
| Team Memory Sync (团队同步) |
|
|
2. 核心机制拆解
2.1 记忆类型系统 (Memory Type Taxonomy)
2.1.1 核心文件/组件职责
|
|
|
|
|---|---|---|
src/memdir/memoryTypes.ts |
|
MEMORY_TYPES
MemoryType, parseMemoryType() |
src/memdir/memoryTypes.ts |
|
TYPES_SECTION_COMBINED
TYPES_SECTION_INDIVIDUAL |
src/memdir/memoryTypes.ts |
|
WHAT_NOT_TO_SAVE_SECTION |
src/memdir/memoryTypes.ts |
|
MEMORY_DRIFT_CAVEAT |
2.1.2 数据结构与配置
// 文件: src/memdir/memoryTypes.ts (行14-21)// 四种记忆类型的常量数组 — 作为类型约束的基础export const MEMORY_TYPES = ['user', // 用户角色、偏好、知识'feedback', // 行为指导(纠正/确认)'project', // 项目上下文(截止日期、决策)'reference', // 外部系统指针(Linear、Slack)] as const// TypeScript 类型推导 — 确保类型字符串只能是这四种export type MemoryType = (typeof MEMORY_TYPES)[number]/** * 解析 frontmatter 中的 type 字段 * * @design 防范边界情况: * - legacy文件可能没有type字段 → undefined(继续工作) * - 未知类型值 → undefined(优雅降级) * - 非字符串类型 → undefined(类型安全) */export functionparseMemoryType(raw: unknown): MemoryType | undefined{if (typeof raw !== 'string') return undefinedreturn MEMORY_TYPES.find(t => t === raw)}
关键设计特点:
-
as const+typeof组合实现严格的字符串枚举类型约束 -
parseMemoryType()对历史文件做兼容处理,避免解析失败阻断记忆加载
2.1.3 触发条件/核心逻辑
记忆类型的保存触发条件由 TYPES_SECTION_* 中的 <when_to_save> 标签定义:
|
|
|
|
|---|---|---|
user |
|
memoryTypes.ts:47-48 |
feedback |
|
memoryTypes.ts:61 |
project |
|
memoryTypes.ts:79 |
reference |
|
memoryTypes.ts:94 |
关键边界条件 (WHAT_NOT_TO_SAVE_SECTION):
// 文件: src/memdir/memoryTypes.ts (行183-195)export const WHAT_NOT_TO_SAVE_SECTION: readonly string[] = ['## What NOT to save in memory','',// ❌ 可从项目状态推导的内容'- Code patterns, conventions, architecture, file paths, or project structure',// ❌ Git历史是权威来源'- Git history, recent changes, or who-changed-what — `git log` / `git blame`',// ❌ 修复在代码中,上下文在commit message'- Debugging solutions or fix recipes — the fix is in the code; the commit message',// ❌ 已有CLAUDE.md文档的不要重复'- Anything already documented in CLAUDE.md files.',// ❌ 临时状态不需要跨会话'- Ephemeral task details: in-progress work, temporary state, current conversation','',// ⚠️ 显式保存请求的过滤 — 防止用户把周报当记忆存'These exclusions apply even when the user explicitly asks you to save.',]
2.1.4 执行流程
Step 1: 用户对话产生信息 → 主代理或后台提取代理分析内容Step 2: 代理判断信息是否属于四种类型之一 │ ├─→ user: 学习用户角色/偏好 → 私有目录 ├─→ feedback: 用户纠正/确认 → 私有目录(除非是项目级约定) ├─→ project: 项目上下文/决策 → 强烈倾向于团队目录 └─→ reference: 外部系统指针 → 通常团队目录Step 3: 检查 WHAT_NOT_TO_SAVE_SECTION 排除项 │ └─→ 如果是可推导内容 → 不保存Step 4: 生成 frontmatter 格式的记忆文件 │ └─→ name, description, type 三字段必须填写Step 5: 更新 MEMORY.md 索引(除非 skipIndex=true)
2.2 记忆加载机制 (Memory Loading)
2.2.1 核心文件/组件职责
|
|
|
|
|---|---|---|
src/memdir/memdir.ts |
|
loadMemoryPrompt()
buildMemoryLines() |
src/memdir/paths.ts |
|
getAutoMemPath()
isAutoMemPath() |
src/memdir/memoryScan.ts |
|
scanMemoryFiles()
MemoryHeader |
src/constants/prompts.ts |
|
systemPromptSection('memory', ...) |
2.2.2 数据结构与配置
// 文件: src/memdir/memdir.ts (行34-38)export const ENTRYPOINT_NAME = 'MEMORY.md'// 索引文件名export const MAX_ENTRYPOINT_LINES = 200// 行数上限export const MAX_ENTRYPOINT_BYTES = 25_000 // 字节上限 (~25KB)// 文件: src/memdir/memoryScan.ts (行13-19)export type MemoryHeader = { filename: string// 相对于memory目录的路径 filePath: string// 绝对路径 mtimeMs: number// 最后修改时间(毫秒)— 用于新鲜度追踪 description: string | null// frontmatter中的descriptiontype: MemoryType | undefined// 解析后的类型}const MAX_MEMORY_FILES = 200// 单次扫描最多200个文件const FRONTMATTER_MAX_LINES = 30// 只读前30行获取frontmatter
关键设计特点:
-
双重限制(行数+字节)防止超长索引文件吞噬上下文 -
mtimeMs穿透传递,避免二次 stat 调用
2.2.3 触发条件/核心逻辑
// 文件: src/constants/prompts.ts (行495)// 记忆作为动态系统提示部分注册 — 每次构建系统提示时调用systemPromptSection('memory', () => loadMemoryPrompt()),
触发时机:
-
新会话启动 → 系统提示构建 → 调用 loadMemoryPrompt() -
提示缓存失效 → 动态部分重新计算
// 文件: src/memdir/memdir.ts (行419-507)export async functionloadMemoryPrompt(): Promise<string | null> {const autoEnabled = isAutoMemoryEnabled()// 功能标志: 是否跳过MEMORY.md索引(直接写topic files)const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse', false)// 分支1: KAIROS模式(助理长会话)— 使用日期日志而非索引if (feature('KAIROS') && autoEnabled && getKairosActive()) {return buildAssistantDailyLogPrompt(skipIndex) }// 分支2: 团队记忆组合模式if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) {return teamMemPrompts!.buildCombinedMemoryPrompt(extraGuidelines, skipIndex) }// 分支3: 仅私有记忆模式if (autoEnabled) {return buildMemoryLines('auto memory', autoDir, extraGuidelines, skipIndex).join('\n') }// 记忆禁用 → 返回null,系统提示中不包含记忆部分return null}
2.2.4 执行流程
Step 1: 新会话启动 → prompts.ts 构建系统提示Step 2: systemPromptSection('memory') 被调用 │ └─→ loadMemoryPrompt() 入口Step 3: 检查记忆是否启用 (isAutoMemoryEnabled) │ ├─→ 禁用 → 返回 null(不注入记忆部分) └─→ 启用 → 继续Step 4: 分支选择模式 │ ├─→ KAIROS模式 → buildAssistantDailyLogPrompt() ├─→ TEAMMEM模式 → buildCombinedMemoryPrompt() └─→ 默认模式 → buildMemoryLines()Step 5: 确保记忆目录存在 (ensureMemoryDirExists) │ └─→ fs.mkdir(memoryDir) — recursive + EEXIST swallowStep 6: 读取 MEMORY.md 索引文件 │ └─→ truncateEntrypointContent() — 截断到200行/25KBStep 7: 组装完整提示文本 │ └─→ 类型说明 + 禁止保存清单 + 如何保存 + 如何访问 + 新鲜度警告Step 8: 注入到系统提示
2.3 记忆提取机制 (Memory Extraction)
2.3.1 核心文件/组件职责
|
|
|
|
|---|---|---|
src/services/extractMemories/extractMemories.ts |
|
executeExtractMemories()
initExtractMemories() |
src/services/extractMemories/prompts.ts |
|
buildExtractAutoOnlyPrompt()
buildExtractCombinedPrompt() |
src/query/stopHooks.ts |
|
handleStopHooks() |
src/utils/forkedAgent.ts |
|
runForkedAgent()
createCacheSafeParams() |
2.3.2 数据结构与配置
// 文件: src/services/extractMemories/extractMemories.ts (行275-326)// Closure-scoped 状态管理 — 避免模块级变量污染/** 正在执行的提取Promise集合 — 用于drain等待 */const inFlightExtractions = new Set<Promise<void>>()/** 上次处理的消息UUID — 游标,避免重复处理 */let lastMemoryMessageUuid: string | undefined/** 提取进行中标志 — 防止重叠执行 */let inProgress = false/** 自上次提取以来的轮次数 — 用于节流 */let turnsSinceLastExtraction = 0/** stash的pending context — 用于trailing extraction */let pendingContext: { context, appendSystemMessage } | undefined
关键设计特点:
-
Closure-scoped 而非 module-level,便于测试时 initExtractMemories()重置 -
inFlightExtractions用于进程退出前的 drain 等待
2.3.3 触发条件/核心逻辑
// 文件: src/query/stopHooks.ts (行142-157)// 在每次查询循环结束时触发 — 模型产生最终响应且无工具调用时void extractMemoriesModule!.executeExtractMemories( stopHookContext, toolUseContext.appendSystemMessage,)
触发时机:
-
Query Loop 结束 → handleStopHooks→ fire-and-forget 调用提取 -
非阻塞执行 → 使用 void关键字,不等待结果
// 文件: src/services/extractMemories/extractMemories.ts (行527-567)async functionexecuteExtractMemoriesImpl( context: REPLHookContext, appendSystemMessage?: AppendSystemMessageFn,): Promise<void> {// Gate 1: 只为主代理运行,子代理跳过if (context.toolUseContext.agentId) return// Gate 2: 功能标志检查if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) return// Gate 3: 记忆功能启用检查if (!isAutoMemoryEnabled()) return// Gate 4: 远程模式跳过if (getIsRemoteMode()) return// Gate 5: 正在进行中 → stash pending contextif (inProgress) { pendingContext = { context, appendSystemMessage }return }await runExtraction({ context, appendSystemMessage })}
2.3.4 执行流程
Step 1: Query Loop 结束 → handleStopHooks 触发Step 2: executeExtractMemories() 入口 │ └─→ 检查五个Gate条件Step 3: 检查主代理是否已写入记忆 (hasMemoryWritesSince) │ ├─→ 已写入 → 跳过,仅更新游标 └─→ 未写入 → 继续后台提取Step 4: 节流检查 (tengu_bramble_lintel) │ ├─→ 未达到轮次数 → 返回 └─→ 达到 → 执行提取Step 5: 扫描现有记忆文件 (scanMemoryFiles) │ └─→ 生成 manifest 注入到提取提示Step 6: 构建 forked agent 提示 │ └─→ buildExtractAutoOnlyPrompt() 或 buildExtractCombinedPrompt()Step 7: 运行 forked agent │ ├─→ 共享主对话的提示缓存 (cacheSafeParams) ├─→ 工具权限沙箱 (canUseTool) ├─→ 最大5轮限制 (maxTurns: 5) └─→ 不写入transcript (skipTranscript: true)Step 8: 提取写入的文件路径 (extractWrittenPaths) │ └─→ 记录 telemetry,追加系统消息通知Step 9: 更新游标 (lastMemoryMessageUuid) │ └─→ 下次只处理新增消息Step 10: 处理 stash 的 pending context (trailing extraction)
2.4 相关记忆检索 (Relevance Retrieval)
2.4.1 核心文件/组件职责
|
|
|
|
|---|---|---|
src/memdir/findRelevantMemories.ts |
|
findRelevantMemories()
RelevantMemory |
src/utils/sideQuery.ts |
|
sideQuery() |
2.4.2 数据结构与配置
// 文件: src/memdir/findRelevantMemories.ts (行13-16)exporttype RelevantMemory = { path: string// 记忆文件绝对路径 mtimeMs: number// 修改时间 — 用于新鲜度提示}// 文件: src/memdir/findRelevantMemories.ts (行18-24)const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to Claude Code...Return a list of filenames for the memories that will clearly be useful...(up to 5) // ← 最多选择5个记忆- If you are unsure... then do not include it in your list.- If there are no memories... feel free to return an empty list.- If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools...`
关键设计特点:
-
JSON Schema 强制输出格式 { selected_memories: string[] } -
recentTools过滤:排除正在使用工具的参考文档
2.4.3 触发条件/核心逻辑
// 文件: src/memdir/findRelevantMemories.ts (行39-75)export async functionfindRelevantMemories( query: string, // 用户查询文本 memoryDir: string, // 记忆目录路径 signal: AbortSignal, recentTools: readonly string[] = [], // 正在使用的工具列表 alreadySurfaced: ReadonlySet<string> = new Set(), // 已展示的记忆): Promise<RelevantMemory[]> {// Step 1: 扫描记忆文件,排除已展示的const memories = (await scanMemoryFiles(memoryDir, signal)) .filter(m => !alreadySurfaced.has(m.filePath))if (memories.length === 0) return []// Step 2: 调用Sonnet选择相关记忆const selectedFilenames = await selectRelevantMemories(query, memories, signal, recentTools)// Step 3: 映射回完整的MemoryHeaderconst byFilename = new Map(memories.map(m => [m.filename, m]))const selected = selectedFilenames .map(filename => byFilename.get(filename)) .filter((m): m is MemoryHeader => m !== undefined)return selected.map(m => ({ path: m.filePath, mtimeMs: m.mtimeMs }))}
2.4.4 执行流程
Step 1: 扫描记忆目录 → scanMemoryFiles() │ └─→ 排除 MEMORY.md 和已展示的记忆 (alreadySurfaced)Step 2: 格式化 manifest → formatMemoryManifest() │ └─→ "- [type] filename (timestamp): description"Step 3: 构建选择提示 │ ├─→ Query: {用户查询} ├─→ Available memories: {manifest} └─→ Recently used tools: {recentTools} (如果有)Step 4: 调用 sideQuery (Sonnet模型) │ ├─→ model: getDefaultSonnetModel() ├─→ max_tokens: 256 └─→ output_format: JSON SchemaStep 5: 解析 JSON 输出 │ ├─→ { selected_memories: ["file1.md", "file2.md"] } └─→ 过滤无效文件名Step 6: 返回最多5个 RelevantMemory │ └─→ 包含 path + mtimeMs
2.5 记忆新鲜度机制 (Memory Freshness)
2.5.1 核心文件/组件职责
|
|
|
|
|---|---|---|
src/memdir/memoryAge.ts |
|
memoryAgeDays()
memoryFreshnessText() |
src/memdir/memoryTypes.ts |
|
MEMORY_DRIFT_CAVEAT |
2.5.2 数据结构与配置
// 文件: src/memdir/memoryAge.ts// 一天的毫秒数常量const MS_PER_DAY = 86_400_000 // 24 * 60 * 60 * 1000/** * 计算记忆年龄(天数) * * @design 边界条件: * - Floor-rounded: 今天=0, 昨天=1 * - 未来时间/时钟偏差 → clamp到0 */exportfunctionmemoryAgeDays(mtimeMs: number): number{returnMath.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000))}
2.5.3 触发条件/核心逻辑
// 文件: src/memdir/memoryAge.ts (行33-42)/** * 新鲜度警告文本 — 超过1天的记忆才显示 * * @design 为什么是1天? * - 今天的记忆足够新鲜,警告是噪音 * - 超过1天的记忆可能引用已变更的代码(file:line citations) * - 用户报告过旧记忆被当作事实断言的问题 */export functionmemoryFreshnessText(mtimeMs: number): string{const d = memoryAgeDays(mtimeMs)if (d <= 1) return ''// 新鲜记忆不警告return (`This memory is ${d} days old. ` +`Memories are point-in-time observations, not live state — ` +`claims about code behavior or file:line citations may be outdated. ` +`Verify against current code before asserting as fact.` )}
3. 深度机制/策略系统
记忆类型分类详解
3.1 四类型分类法对比
|
|
|
|
|
|
|---|---|---|---|---|
| user | always private |
|
|
|
| feedback | default private
|
|
Rule → Why → How to apply
|
|
| project | bias toward team |
|
Fact → Why → How to apply
|
|
| reference | usually team |
|
|
|
3.2 类型作用域决策矩阵
┌─────────────────────────────────────┐ │ Scope Decision Matrix │ └─────────────────────────────────────┘ private scope team scope (个人偏好/知识) (项目共享约定) ┌─────────────────┐ ┌─────────────────┐ │ user │ │ │ │ (always) │ │ │ │ │ │ │ └─────────────────┘ │ │ │ │ ┌─────────────────┐ │ │ │ feedback │ ──────────────────────│─→ feedback │ │ (default) │ 项目级约定时 │ (项目约定) │ │ │ │ │ └─────────────────┘ │ │ │ │ ┌─────────────────┐ └─────────────────┐ │ project │ ──────────────────────│─→ project │ │ (可选) │ 强烈倾向team │ (强烈倾向) │ │ │ │ │ └─────────────────┘ └─────────────────┘ ┌─────────────────┐ │ reference │ │ (usually) │ │ │ └─────────────────┘
3.3 Frontmatter 格式示例
---name: feedback_testing_policydescription: integration tests must hit real database, not mockstype: feedback---**Rule**: Integration tests must hit a real database, not mocks.**Why**: We got burned last quarter when mocked tests passed but the prod migration failed — mock/prod divergence masked a broken migration.**How to apply**: All database-related tests must use the real test database instance. This is a project testing policy, not a personal preference.
3.4 记忆保存两步流程
┌──────────────────────────────────────────────────────────────────────┐│ Memory Save Two-Step Process │└──────────────────────────────────────────────────────────────────────┘ Step 1: 写入记忆文件 Step 2: 更新索引 ┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ │ │ │ │ │ memory/feedback_testing.md │ │ MEMORY.md │ │ │ │ │ │ --- │ │ - [Testing Policy](feedback_testing.md) │ │ name: feedback_testing_policy │ │ — integration tests use real DB │ │ description: ... │ │ │ │ type: feedback │ │ (one line, ~150 chars max) │ │ --- │ │ │ │ │ │ │ │ **Rule**: ... │ │ │ │ **Why**: ... │ │ │ │ **How to apply**: ... │ │ │ │ │ │ │ └─────────────────────────────────────┘ └─────────────────────────────────────┘ │ │ │ │ ▼ ▼ 内容文件(完整记忆) 索引文件(快速定位) ✓ 完整的记忆内容 ✓ 每行一个链接,~150字符 ✓ frontmatter必须 ✓ 无frontmatter ✓ 按主题组织,不按时间 ✓ 超过200行会被截断
4. 代码片段深度解析 (Hardcore Code Snippets)
4.1 parseMemoryType() — 类型解析的优雅降级
【文件定位】src/memdir/memoryTypes.ts:28-31
/** * Parse a raw frontmatter value into a MemoryType. * Invalid or missing values return undefined — legacy files without a * `type:` field keep working, files with unknown types degrade gracefully. */exportfunctionparseMemoryType(raw: unknown): MemoryType | undefined{if (typeof raw !== 'string') returnundefinedreturn MEMORY_TYPES.find(t => t === raw)}
【深度解析】
-
防范的边界情况:
-
legacy文件兼容:历史记忆文件可能没有 type字段 →undefined(不阻断加载) -
未知类型优雅降级: type: "custom"→undefined(不是硬错误) -
非字符串输入: type: 123→undefined(类型安全) -
算法巧妙之处:
-
MEMORY_TYPES.find()比includes()更语义化 -
undefined而非null的选择:TypeScript中undefined是”缺失”的自然表示 -
变量演变:
-
raw: unknown→ 类型不确定的输入 -
typeof raw !== 'string'→ 类型收窄为string或返回 -
MEMORY_TYPES.find()→ 返回MemoryType | undefined
4.2 truncateEntrypointContent() — 双重截断保护上下文
【文件定位】src/memdir/memdir.ts:57-103
/** * Truncate MEMORY.md content to the line AND byte caps. * * @design 防止超长索引吞噬上下文: * - Line-truncates first (natural boundary) * - Byte-truncates at the last newline before the cap * - Appends warning explaining which cap fired */export functiontruncateEntrypointContent(raw: string): EntrypointTruncation{const trimmed = raw.trim()const contentLines = trimmed.split('\n')const lineCount = contentLines.lengthconst byteCount = trimmed.length// 检查是否超过任一限制const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES // 200const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES // 25KBif (!wasLineTruncated && !wasByteTruncated) {return { content: trimmed, lineCount, byteCount, wasLineTruncated, wasByteTruncated } }// 先按行截断(自然边界)let truncated = wasLineTruncated ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n') : trimmed// 再按字节截断 — 在最后一个换行处切割,避免切断行if (truncated.length > MAX_ENTRYPOINT_BYTES) {const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES) truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES) }// 生成截断原因描述const reason = wasByteTruncated && !wasLineTruncated ? `${formatFileSize(byteCount)} (limit: ${formatFileSize(MAX_ENTRYPOINT_BYTES)}) — index entries are too long` : wasLineTruncated && !wasByteTruncated ? `${lineCount} lines (limit: ${MAX_ENTRYPOINT_LINES})` : `${lineCount} lines and ${formatFileSize(byteCount)}`// 返回截断内容 + 警告return { content: truncated +`\n\n> WARNING: ${ENTRYPOINT_NAME} is ${reason}. Only part of it was loaded. Keep index entries to one line under ~200 chars.`, lineCount, byteCount, wasLineTruncated, wasByteTruncated }}
【深度解析】
-
防范的边界情况:
-
超长行问题:有些用户把整段内容写进MEMORY.md → 字节限制兜底 -
截断位置:在换行处切割,避免切断行造成格式混乱 -
双重检查:先用原始byteCount检查( line-truncated后的内容可能仍超字节) -
算法巧妙之处:
-
lastIndexOf('\n', MAX_ENTRYPOINT_BYTES)找最后一个安全换行 -
cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES兜底:如果没有换行,硬切 -
变量演变:
-
raw: string→ 用户写入的原始内容 -
trimmed→ 去除首尾空白 -
contentLines[]→ 按行分割 -
truncated→ 经过两次截断后的内容
4.3 scanMemoryFiles() — 高效的单次扫描
【文件定位】src/memdir/memoryScan.ts:35-77
/** * Scan a memory directory for .md files. * * @design 单次扫描策略: * - readFileInRange stats internally and returns mtimeMs * - read-then-sort halves syscalls vs a separate stat round * - For large N, we read a few extra small files but still avoid double-stat */export async functionscanMemoryFiles( memoryDir: string, signal: AbortSignal,): Promise<MemoryHeader[]> {try {// Step 1: 递归读取目录const entries = await readdir(memoryDir, { recursive: true })// Step 2: 过滤.md文件,排除MEMORY.mdconst mdFiles = entries.filter(f => f.endsWith('.md') && basename(f) !== 'MEMORY.md', )// Step 3: 并行读取所有文件的frontmatter(前30行)const headerResults = await Promise.allSettled( mdFiles.map(async (relativePath): Promise<MemoryHeader> => {const filePath = join(memoryDir, relativePath)// readFileInRange 内部调用stat获取mtimeMs → 避免二次statconst { content, mtimeMs } = await readFileInRange( filePath,0, FRONTMATTER_MAX_LINES, // 30undefined, signal, )const { frontmatter } = parseFrontmatter(content, filePath)return { filename: relativePath, filePath, mtimeMs, // ← 从readFileInRange穿透 description: frontmatter.description || null,type: parseMemoryType(frontmatter.type), } }), )// Step 4: 过滤成功结果,按时间排序,取前200个return headerResults .filter((r): r is PromiseFulfilledResult<MemoryHeader> => r.status === 'fulfilled') .map(r => r.value) .sort((a, b) => b.mtimeMs - a.mtimeMs) // 新的在前 .slice(0, MAX_MEMORY_FILES) // 最多200个 } catch {return [] // 目录不存在或不可读 → 空数组 }}
【深度解析】
-
防范的边界情况:
-
目录不存在: catch { return [] }— 不阻断记忆加载 -
文件解析失败: Promise.allSettled→ 只取成功结果 -
文件过多: slice(0, 200)— 防止内存爆炸 -
算法巧妙之处:
-
readFileInRange内部stat获取mtimeMs,避免stat → read双重调用 -
Promise.allSettled而非Promise.all:部分失败不影响整体 -
变量演变:
-
entries[]→ 目录下所有文件/子目录 -
mdFiles[]→ 过滤后的.md文件列表 -
headerResults[]→ Promise结果(settled状态) -
MemoryHeader[]→ 最终的记忆头信息列表
4.4 createAutoMemCanUseTool() — 工具权限沙箱
【文件定位】src/services/extractMemories/extractMemories.ts:171-222
/** * Creates a canUseTool function that allows Read/Grep/Glob (unrestricted), * read-only Bash commands, and Edit/Write only for paths within the * auto-memory directory. * * @security 沙箱边界: * - REPL允许(内部会重新调用canUseTool) * - Read/Grep/Glob无限制(只读) * - Bash只允许isReadOnly命令 * - Edit/Write只允许记忆目录内路径 */export functioncreateAutoMemCanUseTool(memoryDir: string): CanUseToolFn{return async (tool: Tool, input: Record<string, unknown>) => {// Gate 1: REPL工具允许 — 内部会重新gate子工具if (tool.name === REPL_TOOL_NAME) {return { behavior: 'allow' as const, updatedInput: input } }// Gate 2: Read/Grep/Glob无限制 — 本质只读if ( tool.name === FILE_READ_TOOL_NAME || tool.name === GREP_TOOL_NAME || tool.name === GLOB_TOOL_NAME ) {return { behavior: 'allow' asc onst, updatedInput: input } }// Gate 3: Bash只允许read-only命令if (tool.name === BASH_TOOL_NAME) {const parsed = tool.inputSchema.safeParse(input)if (parsed.success && tool.isReadOnly(parsed.data)) {return { behavior: 'allow' as const, updatedInput: input } }return denyAutoMemTool( tool,'Only read-only shell commands are permitted (ls, find, grep, cat, stat, wc, head, tail)', ) }// Gate 4: Edit/Write只允许记忆目录内路径if ( (tool.name === FILE_EDIT_TOOL_NAME || tool.name === FILE_WRITE_TOOL_NAME) &&'file_path' in input ) {const filePath = input.file_path// isAutoMemPath 做了normalize防止路径遍历if (typeof filePath === 'string' && isAutoMemPath(filePath)) {return { behavior: 'allow' as const, updatedInput: input } } }// 默认拒绝所有其他工具return denyAutoMemTool( tool,`only Read/Grep/Glob, read-only Bash, and Edit/Write within ${memoryDir} are allowed`, ) }}
【深度解析】
-
防范的边界情况:
-
路径遍历攻击: isAutoMemPath(filePath)内部normalize()防止../绕过 -
REPL嵌套调用:REPL内部的子工具会重新gate -
非字符串路径: typeof filePath === 'string'类型检查 -
算法巧妙之处:
-
tool.isReadOnly(parsed.data)利用工具自身的readonly判断 -
denyAutoMemTool记录telemetry用于分析被拒绝的工具 -
安全边界:
-
MCP工具、Agent工具、Bash rm — 全部被拒绝 -
写入权限严格限制在记忆目录内
4.5 runExtraction() — 提取执行的核心循环
【文件定位】src/services/extractMemories/extractMemories.ts:329-523
/** * 执行记忆提取的核心逻辑 * * @design 关键机制: * - 互斥检查:主代理已写入时跳过 * - 节流机制:按轮次数控制执行频率 * - trailing extraction:处理stash的pending context */async functionrunExtraction({ context, appendSystemMessage, isTrailingRun,}: {...}): Promise<void> {const { messages } = contextconst memoryDir = getAutoMemPath()// 计算新增消息数(从游标位置开始)const newMessageCount = countModelVisibleMessagesSince( messages, lastMemoryMessageUuid, )// 互斥检查:主代理已写入记忆 → 跳过,只更新游标if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) { logForDebugging('[extractMemories] skipping — conversation already wrote to memory')const lastMessage = messages.at(-1)if (lastMessage?.uuid) { lastMemoryMessageUuid = lastMessage.uuid } logEvent('tengu_extract_memories_skipped_direct_write', { message_count: newMessageCount, })return }// 节流检查:非trailing run时检查轮次数if (!isTrailingRun) { turnsSinceLastExtraction++if ( turnsSinceLastExtraction < (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bramble_lintel', null) ?? 1) ) {return// 未达到执行轮次数 } } turnsSinceLastExtraction = 0 inProgress = trueconst startTime = Date.now()try {// 预注入现有记忆manifest — 避免代理浪费一轮lsconst existingMemories = formatMemoryManifest(await scanMemoryFiles(memoryDir, createAbortController().signal), )// 构建提取提示const userPrompt = teamMemoryEnabled ? buildExtractCombinedPrompt(newMessageCount, existingMemories, skipIndex) : buildExtractAutoOnlyPrompt(newMessageCount, existingMemories, skipIndex)// 运行forked agentconst result = await runForkedAgent({ promptMessages: [createUserMessage({ content: userPrompt })], cacheSafeParams, // 共享主对话缓存 canUseTool, // 沙箱化工具权限 querySource: 'extract_memories', forkLabel: 'extract_memories', skipTranscript: true, // 不写入transcript maxTurns: 5, // 最大5轮 })// 更新游标const lastMessage = messages.at(-1)if (lastMessage?.uuid) { lastMemoryMessageUuid = lastMessage.uuid }// 提取写入的文件路径const writtenPaths = extractWrittenPaths(result.messages)const turnCount = count(result.messages, m => m.type === 'assistant')// 记录telemetry logEvent('tengu_extract_memories_extraction', { input_tokens: result.totalUsage.input_tokens, cache_read_input_tokens: result.totalUsage.cache_read_input_tokens, message_count: newMessageCount, turn_count: turnCount, files_written: writtenPaths.length, duration_ms: Date.now() - startTime, })// 通知用户记忆已保存if (memoryPaths.length > 0) {const msg = createMemorySavedMessage(memoryPaths) appendSystemMessage?.(msg) } } catch (error) { logForDebugging(`[extractMemories] error: ${error}`)// 失败不更新游标 → 下次重新处理这些消息 } finally { inProgress = false// 处理stash的pending context — trailing extractionconst trailing = pendingContext pendingContext = undefinedif (trailing) {await runExtraction({ context: trailing.context, appendSystemMessage: trailing.appendSystemMessage, isTrailingRun: true, // ← 标记为trailing,跳过节流 }) } }}
【深度解析】
-
防范的边界情况:
-
游标未找到: sinceUuid被compact删除 → fallback到全量计数 -
提取失败:不更新游标 → 下次重新处理 -
重叠调用: inProgress标志 +pendingContextstash -
算法巧妙之处:
-
hasMemoryWritesSince互斥检查 → 主代理写入时跳过 -
isTrailingRun标记 → trailing extraction跳过节流 -
finally块处理pending context → 确保不丢失 -
变量演变:
-
newMessageCount→ 从游标位置计算的新消息数 -
turnsSinceLastExtraction→ 节流计数器 -
inProgress→ 互斥标志 -
pendingContext→ stash的上下文 -
writtenPaths[]→ 提取代理写入的文件列表
4.6 selectRelevantMemories() — Sonnet相关性选择
【文件定位】src/memdir/findRelevantMemories.ts:77-141
/** * 使用Sonnet模型选择最相关的记忆 * * @design: * - JSON Schema强制输出格式 * - recentTools排除正在使用工具的参考文档 */async functionselectRelevantMemories( query: string, memories: MemoryHeader[], signal: AbortSignal, recentTools: readonly string[],): Promise<string[]> {const validFilenames = new Set(memories.map(m => m.filename))const manifest = formatMemoryManifest(memories)// recentTools过滤:正在使用的工具的参考文档是噪音const toolsSection = recentTools.length > 0 ? `\n\nRecently used tools: ${recentTools.join(', ')}` : ''try {const result = await sideQuery({ model: getDefaultSonnetModel(), // 使用Sonnet而非Opus system: SELECT_MEMORIES_SYSTEM_PROMPT, skipSystemPromptPrefix: true, messages: [ { role: 'user', content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}`, }, ], max_tokens: 256,// JSON Schema强制输出格式 output_format: {type: 'json_schema', schema: {type: 'object', properties: { selected_memories: { type: 'array', items: { type: 'string' } }, }, required: ['selected_memories'], additionalProperties: false, }, }, signal, querySource: 'memdir_relevance', })// 解析JSON输出const textBlock = result.content.find(block => block.type === 'text')if (!textBlock || textBlock.type !== 'text') return []const parsed: { selected_memories: string[] } = jsonParse(textBlock.text)// 过滤无效文件名return parsed.selected_memories.filter(f => validFilenames.has(f)) } catch (e) {if (signal.aborted) return [] logForDebugging(`[memdir] selectRelevantMemories failed: ${errorMessage(e)}`)return [] }}
【深度解析】
-
防范的边界情况:
-
解析失败: catch { return [] }— 不阻断主流程 -
无效文件名: validFilenames.has(f)过滤 -
AbortSignal:取消时返回空数组 -
算法巧妙之处:
-
sideQuery而非完整API调用 — 轻量级 -
additionalProperties: false严格JSON Schema -
recentTools排除正在使用工具的文档 -
变量演变:
-
manifest→ 格式化的记忆列表文本 -
toolsSection→ recentTools提示(如果有) -
parsed.selected_memories[]→ Sonnet选择的文件名列表
4.7 validateMemoryPath() — 安全路径验证
【文件定位】src/memdir/paths.ts:109-150
/** * Normalize and validate a candidate auto-memory directory path. * * @security 拒绝危险路径: * - relative (!isAbsolute): "../foo" * - root/near-root (length < 3): "/" → "" * - Windows drive-root (C: regex): "C:\" * - UNC paths (\\server\share): network paths * - null byte: survives normalize(), truncates in syscalls */functionvalidateMemoryPath( raw: string | undefined, expandTilde: boolean,): string | undefined{if (!raw) return undefinedlet candidate = raw// ~/ expansion (只在settings.json中启用)if (expandTilde && (candidate.startsWith('~/') || candidate.startsWith('~\\'))) {const rest = candidate.slice(2)// 拒绝会展开到$HOME或祖先的路径const restNorm = normalize(rest || '.')if (restNorm === '.' || restNorm === '..') {return undefined } candidate = join(homedir(), rest) }// normalize并去除尾部分隔符const normalized = normalize(candidate).replace(/[/\\]+$/, '')// 安全检查:拒绝危险路径if ( !isAbsolute(normalized) || // 相对路径 normalized.length < 3 || // root或太短 /^[A-Za-z]:$/.test(normalized) || // Windows drive-root normalized.startsWith('\\\\') || // UNC path normalized.startsWith('//') || // UNC path (alt) normalized.includes('\0') // null byte attack ) {return undefined }// 返回规范化路径 + 一个尾部分隔符return (normalized + sep).normalize('NFC') // Unicode normalization}
【深度解析】
-
防范的边界情况:
-
路径遍历: ../通过normalize()解决 -
UNC路径:网络路径是 opaque trust boundary -
null byte攻击: \0在某些syscall中会截断字符串 -
Unicode攻击: normalize('NFC')防止 Unicode normalization attack -
Windows drive-root: C:→ 拒绝 -
算法巧妙之处:
-
normalize(candidate).replace(/[/\\]+$/, '')去除尾部分隔符再添加一个 -
restNorm === '.' || restNorm === '..'检测会展开到$HOME的路径 -
安全边界:
-
禁止 ~,~/,~/.,~/..— 会展开到HOME或祖先 -
projectSettings (仓库内settings.json) 被排除 — 防止恶意仓库攻击
4.8 memoryFreshnessText() — 新鲜度警告生成
【文件定位】src/memdir/memoryAge.ts:33-42
/** * Plain-text staleness caveat for memories >1 day old. * Returns '' for fresh (today/yesterday) memories — warning there is noise. * * @design 用户体验考量: * - 超过1天的记忆可能引用已变更的代码 * - file:line citations 让旧记忆看起来更权威(危险) * - 用户报告过旧记忆被当作事实断言的问题 */export functionmemoryFreshnessText(mtimeMs: number): string{const d = memoryAgeDays(mtimeMs)if (d <= 1) return ''// 新鲜记忆不警告(避免噪音)return (`This memory is ${d} days old. ` +`Memories are point-in-time observations, not live state — ` +`claims about code behavior or file:line citations may be outdated. ` +`Verify against current code before asserting as fact.` )}/** * 辅助函数:计算记忆年龄(天数) */export functionmemoryAgeDays(mtimeMs: number): number{// Floor-rounded: 今天=0, 昨天=1// 未来时间/时钟偏差 → Math.max(0, ...) clampreturn Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000))}/** * 辅助函数:人类可读年龄字符串 */export functionmemoryAge(mtimeMs: number): string{const d = memoryAgeDays(mtimeMs)if (d === 0) return 'today'if (d === 1) return 'yesterday'return `${d} days ago`}
【深度解析】
-
防范的边界情况:
-
未来时间/时钟偏差: Math.max(0, ...)clamp到0 -
新鲜记忆噪音: d <= 1不警告,避免干扰用户 -
算法巧妙之处:
-
Floor-rounded:今天=0,昨天=1(自然语义) -
三段式警告文本:年龄 + 本质声明 + 行动建议 -
设计动机:
-
file:line citations 让旧记忆看起来权威(”权威陷阱”) -
用户报告:旧记忆被当作事实断言 → 需要明确提醒
5. 小结 (Summary)
架构分层
┌───────────────────────────────────────────────────────────────────────┐│ Memory System Architecture Layers │└───────────────────────────────────────────────────────────────────────┘Layer 5: 同步层 (Team Memory Sync) │ watcher.ts → File Watcher + 2s debounce push │ teamMemPaths.ts → 路径安全验证 └─────────────────────────────────────────────┐ │Layer 4: 检索层 (Relevance Retrieval) │ │ findRelevantMemories.ts → Sonnet选择(最多5个) │ │ memoryAge.ts → 新鲜度警告 │ └─────────────────────────────────────────────┐ │Layer 3: 提取层 (Memory Extraction) │ │ extractMemories.ts → Forked Agent │ │ prompts.ts → 提取提示模板 │ │ createAutoMemCanUseTool → 工具沙箱 │ └─────────────────────────────────────────────┐ │Layer 2: 加载层 (Memory Loading) │ │ memdir.ts → loadMemoryPrompt() │ │ buildMemoryLines() → 提示构建 │ │ truncateEntrypointContent → 截断保护 │ └─────────────────────────────────────────────┐ │Layer 1: 存储层 (File Storage) │ │ ~/.claude/projects/<slug>/memory/ │ │ MEMORY.md (索引) + *.md (内容) │ │ memory/team/ (团队记忆) │ └─────────────────────────────────────────────┘
关键设计原则 (Design Principles)
|
|
|
|
|---|---|---|
| 1 | 不可推导性 (Non-Derivability) | WHAT_NOT_TO_SAVE_SECTION
|
| 2 | 沙箱隔离 (Sandbox Isolation) | createAutoMemCanUseTool()
|
| 3 | 互斥机制 (Mutual Exclusion) | hasMemoryWritesSince()
inProgress 标志防止重叠执行 |
| 4 | 新鲜度追踪 (Freshness Tracking) | mtimeMs
memoryFreshnessText() 超过1天显示警告;MEMORY_DRIFT_CAVEAT 提醒验证当前状态 |
| 5 | 安全验证 (Security Validation) | validateMemoryPath()
|
| 6 | 缓存共享 (Cache Sharing) | runForkedAgent()
createCacheSafeParams() 共享主对话的提示缓存prefix,避免重复计算 |
核心文件一览
|
|
|
|
|---|---|---|
src/memdir/memoryTypes.ts |
|
MEMORY_TYPES
TYPES_SECTION_*, WHAT_NOT_TO_SAVE_SECTION |
src/memdir/memdir.ts |
|
loadMemoryPrompt()
buildMemoryLines(), truncateEntrypointContent() |
src/memdir/memoryScan.ts |
|
scanMemoryFiles()
MemoryHeader |
src/memdir/paths.ts |
|
getAutoMemPath()
isAutoMemPath(), validateMemoryPath() |
src/memdir/findRelevantMemories.ts |
|
findRelevantMemories()
selectRelevantMemories() |
src/memdir/memoryAge.ts |
|
memoryAgeDays()
memoryFreshnessText() |
src/memdir/teamMemPrompts.ts |
|
buildCombinedMemoryPrompt() |
src/services/extractMemories/extractMemories.ts |
|
executeExtractMemories()
createAutoMemCanUseTool() |
src/services/extractMemories/prompts.ts |
|
buildExtractAutoOnlyPrompt()
buildExtractCombinedPrompt() |
夜雨聆风