Claude Code 源码深度解析:运行机制与 Memory 模块详解
主页:http://qingkeai.online/
作者:程家乐,清华大学博士生https://github.com/chengjl19/awesome-agent-harness-notes
本文档基于 Claude Code CLI 工具的源码进行深度分析,旨在用清晰易懂但专业的语言,详细讲解其内部运行机制。特别关注 Memory(记忆)模块的设计与实现。

1. 项目总览:Claude Code 是什么
Claude Code 是 Anthropic 官方开发的命令行 AI 编程助手(CLI)。你可以把它想象成一个”住在终端里的 AI 程序员”——它能读文件、写代码、运行命令、搜索代码库,甚至能记住你的偏好。
技术栈
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
入口文件
src/main.tsx ← 主入口,初始化一切src/entrypoints/cli.tsx ← CLI 命令注册src/replLauncher.tsx ← 交互式 REPL 启动
2. 整体架构:代码是怎么组织的
src/├── main.tsx # 主入口:初始化、加载配置、启动 REPL├── QueryEngine.ts # 查询引擎:管理多轮对话状态├── query.ts # 核心查询循环(最重要的文件之一)├── Tool.ts # 工具接口定义├── tools.ts # 工具注册聚合├── context.ts # 上下文构建(CLAUDE.md、git 状态等)├── commands/ # 87+ 个斜杠命令 (/memory, /compact, /clear 等)├── tools/ # 43+ 个工具实现│ ├── BashTool/ # 执行 shell 命令│ ├── FileReadTool/ # 读文件│ ├── FileWriteTool/ # 写文件│ ├── FileEditTool/ # 编辑文件│ ├── GrepTool/ # 搜索文件内容│ ├── GlobTool/ # 搜索文件名│ ├── AgentTool/ # 启动子 Agent│ ├── WebSearchTool/ # 网络搜索│ └── ...├── services/ # 业务逻辑服务│ ├── SessionMemory/ # 会话记忆(Compact 时用的摘要)│ ├── extractMemories/ # 自动记忆提取(跨会话持久化)│ ├── compact/ # 上下文压缩│ └── api/ # API 通信├── memdir/ # Memory 目录系统(核心!)├── constants/ # 常量和 Prompt 模板│ └── prompts.ts # System Prompt 构建(非常重要)├── utils/ # 工具函数│ ├── hooks/ # Hooks 系统│ ├── settings/ # 设置管理│ ├── memory/ # 记忆工具类型│ └── claudemd.ts # CLAUDE.md 加载├── components/ # React UI 组件├── state/ # 状态管理 (Zustand)└── query/ # 查询辅助逻辑 └── stopHooks.ts # 查询结束后触发的钩子
关键理解:Claude Code 不是一个简单的”发消息-收回复”程序。它是一个循环执行引擎:发消息给模型 → 模型返回工具调用 → 执行工具 → 把结果发回模型 → 重复,直到模型不再调用工具。
3. 核心运行流程:从用户输入到模型输出
3.1 生命周期概览
用户输入 "帮我修复 bug" │ ▼┌──────────────────────┐│ REPL 接收输入 │ ← replLauncher.tsx│ 构建 User Message │└──────────┬───────────┘ │ ▼┌──────────────────────┐│ QueryEngine │ ← QueryEngine.ts│ .submitMessage() │ 一个 QueryEngine 实例对应一个对话│ 管理多轮状态 │ mutableMessages 跨 turn 持久化└──────────┬───────────┘ │ ▼┌──────────────────────────────────────────┐│ queryLoop() 核心循环 │ ← query.ts (最重要的函数)│ ││ while (true) { ││ 1. 准备消息(压缩、裁剪、折叠) ││ 2. 组装 System Prompt ││ 3. 检查是否需要 Auto-Compact ││ 4. 调用 Claude API(流式) ││ 5. 收集 Assistant 消息和工具调用 ││ 6. 执行工具(可并发) ││ 7. 收集工具结果 ││ 8. 检查是否需要继续(有工具调用?) ││ 9. 执行 Stop Hooks(含记忆提取) ││ 10. 若无工具调用 → 退出循环返回用户 ││ } │└──────────────────────────────────────────┘
3.2 QueryEngine:对话状态管理器
// 源码位置: src/QueryEngine.ts (line 184+)class QueryEngine { // 跨 turn 持久化的状态 mutableMessages: Message[] // 完整对话历史 abortController: AbortController // 中断控制 totalUsage: TokenUsage // 累计 token 消耗 readFileState: LRUCache // 文件读取缓存 // 核心方法:提交一条新消息,开始一个新 turn async *submitMessage(userMessage: Message): AsyncGenerator<StreamEvent> { // 1. 获取 System Prompt 各部分 const { defaultSystemPrompt, userContext, systemContext } = await this.fetchSystemPromptParts() // 2. 组装最终 System Prompt const systemPrompt = [ ...(customSystemPrompt || defaultSystemPrompt), ...(memoryMechanicsPrompt || []), ...(appendSystemPrompt || []) ] // 3. 进入查询循环 yield* queryLoop(this.mutableMessages, systemPrompt, ...) }}
通俗解释:QueryEngine 就像一个”对话管家”。你每说一句话,它都会记住之前的所有对话,准备好”世界观”(System Prompt),然后送去给 Claude 处理。
3.3 queryLoop():最核心的循环
这是整个 Claude Code 的”心脏”。位于 src/query.ts。
// 简化后的核心逻辑async function* queryLoop(messages, systemPrompt, tools, ...) { let state = { messages, turnCount: 0, autoCompactTracking: undefined, // ... 其他状态 } while (true) { state.turnCount++ // ========== 阶段1:消息准备 ========== // 1a. 裁剪历史(snip compact) let messagesForQuery = applySnipCompaction(state.messages) // 1b. 微压缩(microcompact)—— 压缩工具结果 messagesForQuery = applyMicrocompact(messagesForQuery) // 1c. 上下文折叠(context collapse) messagesForQuery = applyContextCollapse(messagesForQuery) // ========== 阶段2:System Prompt 组装 ========== const fullSystemPrompt = appendSystemContext(systemPrompt, systemContext) // ========== 阶段3:自动压缩检查 ========== if (await shouldAutoCompact(messagesForQuery, model)) { const result = await autoCompactIfNeeded(messagesForQuery, ...) if (result.wasCompacted) { // 用压缩后的消息替换,继续下一轮 state.messages = result.compactionResult.summaryMessages continue } } // ========== 阶段4:调用 Claude API(流式) ========== const stream = callModel({ messages: messagesForQuery, system: fullSystemPrompt, tools: toolDefinitions, // ... }) let assistantMessages = [] let toolUseBlocks = [] for await (const chunk of stream) { // 收集 assistant 消息(文本 + 思考 + 工具调用) if (chunk.type === 'assistant') { assistantMessages.push(chunk) // 提取其中的 tool_use blocks for (const block of chunk.content) { if (block.type === 'tool_use') { toolUseBlocks.push(block) } } } yield chunk // 流式输出给前端 } // ========== 阶段5:执行工具 ========== let toolResults = [] if (toolUseBlocks.length > 0) { // 工具编排:并发安全的工具一起跑,不安全的排队跑 for await (const result of runTools(toolUseBlocks, toolUseContext)) { toolResults.push(result) yield result } } // ========== 阶段6:决定是否继续 ========== const needsFollowUp = toolUseBlocks.length > 0 if (!needsFollowUp) { // 没有工具调用了 → 模型给出了最终回答 // ========== 阶段7:执行 Stop Hooks ========== // 这里会触发记忆提取! await handleStopHooks(messagesForQuery, assistantMessages, ...) return { reason: 'completed' } } // 有工具调用 → 把结果追加到消息中,继续循环 state.messages = [...messagesForQuery, ...assistantMessages, ...toolResults] }}
3.4 工具执行管道
模型返回: [tool_use: Bash("ls"), tool_use: Read("main.ts")] │ ▼ ┌─────────────────────┐ │ 工具编排器 │ ← toolOrchestration.ts │ 分组:并发 vs 串行 │ └─────────┬───────────┘ │ ┌─────────────┴──────────────┐ │ │ ▼ ▼┌────────────┐ ┌────────────┐│ Bash("ls") │ 并发执行 │ Read(...) │└─────┬──────┘ └─────┬──────┘ │ │ ▼ ▼ 检查权限 检查权限 (PreToolUse Hook) (PreToolUse Hook) │ │ ▼ ▼ 执行工具 执行工具 │ │ ▼ ▼ (PostToolUse Hook) (PostToolUse Hook) │ │ └─────────┬───────────────┘ │ ▼ 收集为 tool_result 消息 追加到对话历史
并发策略:
-
• 只读工具(Read、Grep、Glob)→ 最多 10 个并发 -
• 写工具(Write、Edit、Bash)→ 串行执行
3.5 自定义 Agent:定义、发现与调用
Claude Code 的 Agent 系统让模型能够委派子任务给专门的子代理。Agent 本质上是一个普通工具(工具名 Agent),由模型根据对话需要自主决定何时调用。
3.5.1 Agent 的三种来源
┌─────────────────────────────────────────────────────────┐│ 1. 内置 Agent(built-in) ││ - general-purpose 通用多步任务 agent ││ - Explore 快速代码库探索(只读) ││ - Plan 架构设计与实现规划(只读) ││ - claude-code-guide Claude Code 使用指南 ││ - statusline-setup 状态栏设置 ││ 源码: src/tools/AgentTool/builtInAgents.ts │├─────────────────────────────────────────────────────────┤│ 2. 自定义 Agent(custom) ││ - Markdown 格式: .claude/agents/*.md ││ - JSON 格式: settings.json 的 agents 字段 ││ - Plugin Agent: 插件系统注入 ││ 源码: src/tools/AgentTool/loadAgentsDir.ts │├─────────────────────────────────────────────────────────┤│ 3. 优先级(同名覆盖) ││ built-in < plugin < userSettings < projectSettings ││ < flagSettings < policySettings ││ 同名 Agent 后者覆盖前者 │└─────────────────────────────────────────────────────────┘
3.5.2 Markdown 定义格式(推荐)
在 .claude/agents/ 目录下创建 .md 文件:
---name: my-reviewer # 必填,Agent 的唯一标识description: "Code review specialist" # 必填,告诉主模型何时使用tools: # 可选,限制可用工具 - Read - Grep - Glob - Bash(read-only)disallowedTools: # 可选,排除特定工具 - Writemodel: sonnet # 可选,指定模型(或 inherit 继承父级)effort: high # 可选,推理力度maxTurns: 10 # 可选,最大执行轮数memory: scope: project # 可选,user | project | localcolor: blue # 可选,终端显示颜色background: true # 可选,是否后台运行isolation: worktree # 可选,在独立 worktree 中运行permissionMode: default # 可选,权限模式skills: # 可选,预加载的 skill - commitinitialPrompt: "Review the code changes." # 可选,首轮追加 promptmcpServers: # 可选,Agent 专属 MCP 服务器 - slackhooks: # 可选,Agent 级别的 hooks PreToolUse: - ...---(frontmatter 下方的正文是 Agent 的 system prompt)You are a code review agent. Focus on:- Logic errors- Security vulnerabilities- Performance issues
源码位置:
src/tools/AgentTool/loadAgentsDir.ts,parseAgentFromMarkdown()函数
3.5.3 模型如何发现和选择 Agent
系统把所有已注册 Agent 的名字、描述、工具列表注入到 Agent 工具的 description 中:
Available agent types and the tools they have access to:- general-purpose: General-purpose agent for researching... (Tools: *)- Explore: Fast agent specialized for exploring codebases... (Tools: All except Agent, Edit, Write)- my-reviewer: Code review specialist (Tools: Read, Grep, Glob)
模型根据每个 Agent 的 description(即 frontmatter 中的 description 字段)自主判断何时调用哪个 Agent。这不是规则匹配,而是模型的推理。
优化细节: Agent 列表曾嵌入在工具 description 中,但这导致每次 MCP/插件变化都会使工具缓存失效(占 fleet cache_creation tokens 的 ~10.2%)。现在可通过
tengu_agent_list_attach将列表改为 attachment 消息注入,保持工具 description 静态。
3.5.4 调用时机与具体例子
何时调用由模型自主决定——和使用 Read/Bash 工具一样,模型在对话中判断需要委派子任务时会调用 Agent 工具。
例 1:写完代码后跑测试
用户: "帮我写一个检查质数的函数"主模型 → 自己用 Write 写代码主模型 → 判断:"代码写完了,该跑测试"主模型 → Agent(subagent_type="test-runner", prompt="Run tests for the isPrime function...")test-runner → 独立执行测试,返回结果主模型 → "All tests pass."
例 2:Fork 自身做调查
用户: "这个分支还有什么没做完?"主模型 → 判断:"调查性问题,不需要中间输出留在我的上下文"主模型 → Agent( ← 不指定 subagent_type name="ship-audit", prompt="Audit what's left: uncommitted changes, commits ahead of main, tests, CI config. Punch list, under 200 words.")fork agent → 继承完整上下文,独立运行主模型 → 收到通知后汇总回复
例 3:指定自定义 Agent 做独立审查
用户: "帮我找个独立视角看看这个迁移安全吗"主模型 → Agent(subagent_type="code-reviewer", ← 自定义 Agent prompt="Review migration 0042_user_schema.sql. Adding NOT NULL to 50M-row table with backfill. Is it safe under concurrent writes?")code-reviewer → 用自己独立的 system prompt 和工具集审查主模型 → 综合自己分析 + reviewer 意见回复用户
3.5.5 Fork vs 指定 subagent_type
┌─────────────────┬───────────────────────┐ │ 不指定(Fork) │ 指定 subagent_type │ ┌─────────────── ┼─────────────────┼───────────────────────┤ │ 上下文 │ 继承父级完整对话 │ 零上下文,全新启动 │ │ 缓存 │ 共享父级 cache │ 独立缓存 │ │ Prompt 风格 │ 写"指令" │ 写"完整汇报" │ │ 适用场景 │ 调查、研究 │ 需要独立视角/特定角色 │ └─────────────── ┴─────────────────┴───────────────────────┘
行为约束(注入到主模型 system prompt):
• “Never delegate understanding”:不写”根据你的发现修复 bug”,而要自己先理解、给出具体指示 • “Don’t peek”:不要在 fork 运行中读取其输出文件,等通知即可 • “Don’t race”:fork 返回前不要猜测或编造结果
4. System Prompt 拼接机制:模型看到的”世界观”
这是理解 Claude Code 最重要的部分之一。每次调用 Claude API 时,System Prompt 决定了模型的行为边界。
4.1 总体结构
发送给 Claude API 的请求长这样:
{ "model": "claude-sonnet-4-...", "system": [ { "type": "text", "text": "静态内容...", "cache_control": { "scope": "global" } }, { "type": "text", "text": "动态内容...", "cache_control":null } ], "messages": [ { "role": "user", "content": "<system-reminder>CLAUDE.md 内容...</system-reminder>" }, { "role": "user", "content": "帮我修复 bug" }, { "role": "assistant", "content": "让我看看..." }, // ... 更多对话 ]}
注意两个关键设计:
-
1. System Prompt 是一个数组(不是单个字符串),每个元素可以有不同的缓存策略 -
2. CLAUDE.md 的内容不在 System Prompt 里,而是作为第一条 User Message 注入
4.2 System Prompt 组装流程
源码位置: src/constants/prompts.ts → getSystemPrompt()┌─────────────────────────────────────────────────────────┐│ System Prompt 数组 ││ ││ ┌─────────────────────────────────────────────────────┐ ││ │ 静态部分(全局可缓存,scope: 'global') │ ││ │ │ ││ │ 1. 身份介绍(你是 Claude Code...) │ ││ │ 2. 系统规则(权限、工具使用说明) │ ││ │ 3. 编码指南(代码风格、安全注意事项) │ ││ │ 4. 行为准则(可逆/不可逆操作的处理) │ ││ │ 5. 工具使用偏好(Read > cat, Edit > sed) │ ││ │ 6. 语气和风格 │ ││ │ 7. 输出效率指南 │ ││ ├─────────────────────────────────────────────────────┤ ││ │ ⚡ SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分界线 ⚡ │ ││ ├─────────────────────────────────────────────────────┤ ││ │ 动态部分(标记为动态,scope: null 不缓存) │ ││ │ ⚠️ 注意:虽然标记为"动态",但绝大部分在会话内不变 │ ││ │ 因为全部通过 memoize 缓存,只在首次调用时计算一次 │ ││ │ │ ││ │ 8. Session 指引(可用技能、Agent 工具说明) │ ││ │ 9. Memory 行为规则 ← 见下方详解 │ ││ │ 10. 环境信息(OS、Shell、日期、模型名) │ ││ │ 11. 语言偏好 │ ││ │ 12. 输出样式配置 │ ││ │ 13. MCP 服务器指令 ← DANGEROUS_uncached! 唯一变量 │ ││ │ 14. 暂存区指令 │ ││ │ 15. Token 预算信息 │ ││ └─────────────────────────────────────────────────────┘ ││ ││ + System Context(git status 等,追加在最后) ││ ││ ⚠️ MEMORY.md 的实际内容不在这里! ││ → 它和 CLAUDE.md 一起作为第一条 User Message 注入 ││ → 详见 [§4.6 易混淆点](#46-易混淆点memory-内容到底在哪) │└─────────────────────────────────────────────────────────┘
关于第 9 项 “Memory 行为规则” 的说明:这里注入的不是 MEMORY.md 文件的内容,而是一段固定的行为指引模板(约 3K tokens),包括:记忆的 4 种类型定义(user/feedback/project/reference)、如何保存记忆、何时访问记忆、什么不该保存等规则。具体内容详见 §5.2.2 四种记忆类型。MEMORY.md 的实际内容通过另一条路径注入,见 §4.6。
4.3 缓存策略:为什么要分”静态”和”动态”
这是一个性能优化设计。Anthropic API 支持 Prompt Caching:
┌────────────────────────────────┐│ cacheScope: 'global' │ ← 所有用户共享缓存,只要静态内容相同│ 包含:身份、规则、编码指南等 │ 就不需要重新处理(省钱省时间)├────────────────────────────────┤│ cacheScope: null │ ← 不通过 API 缓存│ 包含:Memory规则、环境、MCP 等 │ 但通过 memoize 在会话内保持不变└────────────────────────────────┘
代码实现:
// 源码位置: src/utils/api.ts → splitSysPromptPrefix()function splitSysPromptPrefix(systemPrompt: string[]): SystemPromptBlock[] { // 找到分界线 const boundaryIndex = systemPrompt.findIndex(s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY ) if (boundaryIndex >= 0) { return [ // Block 1: 计费头(不缓存) { text: attributionHeader, cacheScope: null }, // Block 2: CLI 前缀(不缓存) { text: cliPrefix, cacheScope: null }, // Block 3: 分界线之前 → 全局缓存 { text: staticContent, cacheScope: 'global' }, // Block 4: 分界线之后 → 不缓存 { text: dynamicContent, cacheScope: null }, ] }}
4.4 动态 Section 的两种类型
// 源码位置: src/constants/systemPromptSections.ts// 类型1:缓存型 —— 计算一次,缓存到 /clear 或 /compactsystemPromptSection('memory', () => loadMemoryPrompt())// 返回固定的行为规则模板,不读 MEMORY.md 内容,会话内值不变// 类型2:易变型 —— 每个 turn 重新计算(会破坏缓存!)DANGEROUS_uncachedSystemPromptSection('mcp_instructions', () => getMcpInstructions())// 为什么 MCP 要用这种?因为 MCP 服务器可能在两个 turn 之间连接/断开
4.5 User Context 注入方式
CLAUDE.md 的内容不在 System Prompt 里,而是作为一条 “假的” User Message 注入:
// 源码位置: src/utils/api.ts → prependUserContext()function prependUserContext(messages: Message[], userContext: object): Message[] { // 把 CLAUDE.md 内容包装在 <system-reminder> 标签中 const contextText = Object.entries(userContext) .map(([key, value]) => `# ${key}\n${value}`) .join('\n\n') const reminderMessage = createUserMessage({ content: `<system-reminder>As you answer the user's questions, you can use the following context:${contextText} IMPORTANT: this context may or may not be relevant to your tasks.</system-reminder>`, isMeta: true // 标记为元数据消息,不是真正的用户输入 }) return [reminderMessage, ...messages]}
为什么要这样做? 因为 System Prompt 缓存是有限的。把 CLAUDE.md 放在消息里可以利用消息级缓存,而且 CLAUDE.md 内容可能很大(包含整个项目的规则),放在 System Prompt 里会干扰全局缓存。
4.6 易混淆点:Memory 内容到底在哪
这是一个非常容易搞混的地方。”Memory” 相关的信息分散在两个不同位置注入,职责完全不同:
┌───────────────────── 位置 1: System Prompt 动态部分 ─────────────────────┐│ ││ systemPromptSection('memory', () => loadMemoryPrompt()) ││ └→ 调用 buildMemoryLines() ││ └→ 返回 ~3K tokens 的固定模板文本 ││ ││ 包含内容(全是规则,不含任何用户数据): ││ ├── "你有一个持久化记忆系统在 ~/.claude/projects/.../memory/" ││ ├── 4 种记忆类型定义(user/feedback/project/reference) ││ ├── 如何保存记忆(两步流程:写文件 + 更新索引) ││ ├── 什么不该保存(代码模式、git历史、临时任务等) ││ ├── 何时访问记忆 ││ └── 记忆验证规则(过期处理、先验证再使用) ││ ││ ⚡ 这部分通过 systemPromptSection memoize 缓存,会话内只计算一次 ││ ⚡ 会话内每个 turn 的值完全相同 │└─────────────────────────────────────────────────────────────────────────┘┌───────────── 位置 2: 第一条 User Message(和 CLAUDE.md 一起)────────────┐│ ││ getUserContext() ← memoize,整个会话只调用一次 ││ └→ getMemoryFiles() ← 也是 memoize ││ └→ 读取 ~/.claude/projects/.../memory/MEMORY.md (type: AutoMem)││ └→ 读取 CLAUDE.md, .claude/rules/*.md 等 ││ └→ 全部拼成一个字符串,包裹在 <system-reminder> 中 ││ ││ 包含内容(真实的用户数据): ││ ├── CLAUDE.md 的全部内容(项目规则) ││ ├── MEMORY.md 的全部内容(记忆索引,≤200行) ││ └── 当前日期 ││ ││ ⚡ 也是 memoize,会话内每个 turn 值相同 ││ ⚡ 即使后台 extractMemories 写了新记忆,本会话也看不到(下次会话生效) │└──────────────────────────────────────────────────────────────────────────┘
为什么要这样分?
|
|
|
|
|---|---|---|
| 内容 |
|
|
| 大小 |
|
|
| 缓存价值 |
|
|
| 放在 System Prompt 的代价 |
|
|
简单来说:规则告诉模型”怎么用记忆”,数据告诉模型”记忆里有什么”。规则放 System Prompt,数据放 User Message。
5. Memory 模块深度解析(重点)
Memory 系统是 Claude Code 最精巧的模块之一。它让 AI 能”记住”跨会话的信息——你的身份、偏好、项目上下文等。
5.1 Memory 系统的六层架构
┌─────────────────────────────────────────────────────────────┐│ 层级 1: Auto Memory ││ (跨会话持久化) ││ ││ 目录: ~/.claude/projects/<项目路径>/memory/ ││ ││ ├── MEMORY.md ← 索引文件(注入第一条 User Message)││ ├── user_role.md ← 用户相关记忆 ││ ├── feedback_testing.md ← 反馈相关记忆 ││ ├── project_goals.md ← 项目相关记忆 ││ └── reference_linear.md ← 外部引用记忆 ││ ││ 触发:每次查询结束后,后台 fork 子 Agent 自动提取 ││ 核心代码: src/services/extractMemories/ │├─────────────────────────────────────────────────────────────┤│ 层级 2: Session Memory ││ (单会话内持久化) ││ ││ 文件: ~/.claude/session-<id>/memory/MEMORY.md ││ ││ 一个结构化的 Markdown 文件,包含 9 个 Section: ││ - Session Title, Current State, Task Spec, Files, ... ││ ││ 触发:达到 token 阈值时,后台 fork 子 Agent 自动更新 ││ 用途:同时服务上下文保持 + auto-compact 压缩基底 ││ 核心代码: src/services/SessionMemory/ │├─────────────────────────────────────────────────────────────┤│ 层级 3: AutoDream ││ (跨会话离线巩固) ││ ││ 触发条件: 距离上次 ≥24h 且 ≥5 个新 session ││ 行为: 后台 fork 子 Agent,读取多 session 的 transcript ││ 和现有 memory,做更高层次的 consolidation ││ ││ 核心代码: src/services/autoDream/ │├─────────────────────────────────────────────────────────────┤│ 层级 4: Agent Memory ││ (角色分域持久记忆) ││ ││ 三种 scope: ││ ├── user → ~/.claude/agent-memory/<type>/ (跨项目) ││ ├── project → <cwd>/.claude/agent-memory/<type>/ (可共享) ││ └── local → <cwd>/.claude/agent-memory-local/<type>/ ││ ││ 核心代码: src/tools/AgentTool/agentMemory.ts │├─────────────────────────────────────────────────────────────┤│ 层级 5: Team Memory ││ (团队共享记忆) ││ ││ 目录: ~/.claude/projects/<项目路径>/memory/team/ ││ 同步: checksum 增量上传 ↔ Anthropic API ││ 安全: 40+ 条 gitleaks 规则做 secret 扫描 ││ ││ 核心代码: src/services/teamMemorySync/ │├─────────────────────────────────────────────────────────────┤│ 层级 6: CLAUDE.md ││ (用户手动维护) ││ ││ 文件: ~/.claude/CLAUDE.md(全局) ││ .claude/CLAUDE.md(项目级) ││ CLAUDE.local.md(本地私有) ││ ││ 内容作为 User Context 注入到第一条消息中 ││ 核心代码: src/utils/claudemd.ts, src/context.ts │└─────────────────────────────────────────────────────────────┘
从时间尺度看,这六层覆盖了完全不同的节拍:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5.2 Auto Memory 系统详解
External build 说明:Auto Memory 系统涉及两条写入路径。路径 A(模型直接写文件)在所有 build 中可用;路径 B(extractMemories 后台提取)受编译时
EXTRACT_MEMORIESflag 门控,在 external build 中被 DCE 删除。Team Memory 同步(§5.10)同理受TEAMMEMflag 门控,external build 中不存在。Session Memory(§5.3)和 SM Compact(§5.3.5)是独立系统,由 GrowthBook 运行时 flag 控制,不依赖 Auto Memory 的编译 flag。
5.2.1 目录结构
~/.claude/projects/<sanitized-git-root>/memory/├── MEMORY.md # 索引文件(≤200行,≤25000字节)├── user_role.md # type: user — 可以有多个├── user_frontend_level.md # type: user — 同一类型的另一个文件├── feedback_testing.md # type: feedback├── feedback_pr_style.md # type: feedback├── project_auth_rewrite.md # type: project├── reference_linear.md # type: reference└── ... # 按主题拆分,数量不限(扫描上限200个)
重要:四种类型(user/feedback/project/reference)是通过每个文件 frontmatter 的
type:字段标注的分类标签,不是”每种类型一个文件”。同一类型可以有任意多个文件,每个文件聚焦一个具体主题。findRelevantMemories扫描的是目录下所有.md文件(最多 200 个),不依赖 MEMORY.md 索引。
每个记忆文件的格式(Frontmatter + 正文):
---name: 用户是高级后端工程师description: 用户是一名有10年Go经验的高级工程师,第一次接触React前端type: user---用户是一名高级后端工程师,深耕Go语言十年。目前第一次接触项目的React前端部分。解释前端概念时,应该用后端类比来帮助理解。比如把组件生命周期类比为请求处理中间件链。
MEMORY.md 索引文件的格式:
- [用户是高级后端工程师](user_role.md) — Go专家,React新手,用后端类比解释前端- [测试必须用真实数据库](feedback_testing.md) — 不要 mock 数据库,曾因此出过生产事故- [Auth中间件重写](project_auth_rewrite.md) — 法务合规驱动,不是技术债清理- [Linear项目INGEST](reference_linear.md) — pipeline bug 在这里追踪
5.2.2 四种记忆类型
四种类型是语义分类标签,每种类型可以有任意多个文件:
|
|
|
|
|
|---|---|---|---|
| user |
|
|
|
| feedback |
|
|
|
| project |
|
|
|
| reference |
|
|
|
类型与文件的关系:一个 memory 文件通过 frontmatter 中的 type: user/feedback/project/reference 字段声明自己属于哪种类型。比如可以同时存在 user_role.md(type: user)、user_frontend_level.md(type: user)、user_timezone.md(type: user)三个 user 类型文件,分别记录不同主题。
MEMORY.md 索引与实际文件的关系:
-
• MEMORY.md 是一个人工可读的目录,每行指向一个 memory 文件,注入 system prompt(≤200 行) -
• findRelevantMemories扫描时读取目录下所有.md文件(最多 200 个),不依赖 MEMORY.md -
• 即使某个文件没有在 MEMORY.md 中索引,仍会被 findRelevantMemories扫描到 -
• MEMORY.md 的价值在于让模型快速浏览有哪些记忆,判断是否需要用 Read 查看完整内容
扫描范围:哪些记忆会被搜到?
findRelevantMemories 对 getAutoMemPath() 做递归扫描(readdir(memoryDir, { recursive: true })),而 Team Memory 是 auto memory 的子目录(getAutoMemPath()/team/),所以:
~/.claude/projects/<sanitized-git-root>/memory/ ← getAutoMemPath()├── MEMORY.md ← 被排除(basename 过滤)├── user_role.md ← ✓ 会被扫到├── feedback_testing.md← ✓ 会被扫到├── team/ ← getTeamMemPath(),是子目录│ ├── MEMORY.md ← 被排除│ ├── ci_gotchas.md ← ✓ 也会被扫到(递归)│ └── deploy_rules.md← ✓ 也会被扫到└── ...
各层记忆是否在扫描范围内:
-
• Auto Memory(私有):会 — 就是扫描的根目录 -
• Team Memory:会 — 是根目录的 team/子目录,递归覆盖 -
• Agent Memory:不会 — 路径完全独立( ~/.claude/agent-memory/<agentType>/) -
• Session Memory:不会 — 是内存中的会话摘要,不参与文件检索 -
• AutoDream 产物:会 — 巩固后写回的仍是 auto memory 目录中的文件
本质:
findRelevantMemories只搜索项目级的跨 session 持久记忆目录(含 team 子目录),是一次全量递归扫描 → Sonnet 筛选 → 注入当前回合。
什么不应该保存(源码中的硬规则):
源码位置: src/memdir/memoryTypes.ts (WHAT_NOT_TO_SAVE_SECTION)❌ 代码模式、架构、文件路径 → 可以通过读代码获得❌ Git 历史、最近变更 → git log / git blame 是权威来源❌ 调试方案或修复配方 → 修复在代码里,commit message 有上下文❌ CLAUDE.md 中已记录的内容 → 不要重复❌ 临时任务细节 → 用 Task 系统,不要写进长期记忆
5.2.3 Memory 写入的两条路径
Claude Code 的长期记忆写入并不是只有一条链路,而是两条独立路径并存:
═════════════════════════════════════════════════════ 路径 A:主模型直接写═════════════════════════════════════════════════════ 驱动方式: System Prompt 中的 memory 行为指令 开关: isAutoMemoryEnabled() 执行者: 主 Agent 自己(在正常对话过程中) 特点: 用户说 "记住我喜欢 tabs" → 主模型直接调用 Write 写 memory 文件═════════════════════════════════════════════════════ 路径 B:后台 extractMemories 子 Agent═════════════════════════════════════════════════════ 驱动方式: stopHooks.ts → extractMemories() 开关: tengu_passport_quail feature flag 执行者: Fork 出的子 Agent(受限权限) 特点: 回合结束后,后台自动分析对话, 提炼值得保存的信息写入记忆
两条路径互斥但互补:
-
• 如果主模型在本轮已经写过 memory 文件(hasMemoryWritesSince 检测),路径 B 会跳过,避免冲突 -
• 路径 A 只在用户明确要求时触发(”记住这个”);路径 B 是自动提炼 -
• tengu_passport_quail关闭只会禁用路径 B,路径 A(主模型直接写)不受影响 -
• 完全禁用所有 memory 写入需要 isAutoMemoryEnabled() = false
5.2.4 路径 B 的完整执行流程
用户问了一个问题 │ ▼Claude 回答完毕(没有更多工具调用) │ ▼queryLoop 退出,调用 handleStopHooks() │ ▼stopHooks.ts → 触发 extractMemories (fire-and-forget 不阻塞用户) │ ▼┌──────────────────────────────────────────────────┐│ extractMemories.ts → runExtraction() ││ ││ 1. 检查 Gate(5重门禁): ││ ✓ 是主 Agent(不是子 Agent) ││ ✓ Feature flag tengu_passport_quail = true ││ ✓ Auto Memory 功能已启用 ││ ✓ 不在远程模式 ││ ✓ 没有正在进行的提取(互斥锁) ││ ││ 2. 计算新消息数量(基于游标 lastMemoryMessageUuid)││ ││ 3. 检查主 Agent 是否已写过 Memory(互斥,避免冲突)││ ││ 4. 节流检查(见下文详解) ││ ││ 5. 扫描现有记忆文件,构建清单 ││ ││ 6. 构建提取 Prompt ││ ││ 7. Fork 子 Agent 执行提取(见下文详解) ││ - 权限:只能读代码 + 只能写 memory 目录 ││ - 高效策略:第1轮读文件,第2轮写文件 ││ ││ 8. 更新游标,记录分析数据 │└──────────────────────────────────────────────────┘
节流机制详解(步骤 4):
// 源码位置: src/services/extractMemories/extractMemories.ts 约第 374 行// tengu_bramble_lintel 是一个 GrowthBook 远程配置值,类型为整数,默认值 1// 含义:"每 N 个符合条件的 turn 才执行一次提取"// 默认值 1 = 每个 eligible turn 都执行(无节流)// 设为 2 = 每隔一个 turn 执行// 设为 3 = 每三个 turn 执行一次const throttle = getGrowthBookValue('tengu_bramble_lintel', 1)if (extractionCount % throttle !== 0) { return // 本轮跳过}// 注意:会话结束时的 trailing run(尾随执行)不受节流限制// 这保证了即使设了高节流值,最后一轮对话的重要信息不会被漏掉
Fork 子 Agent 的轮次限制详解(步骤 7):
// 源码位置: src/services/extractMemories/extractMemories.ts 约第 415 行await runForkedAgent({ maxTurns: 5, // 硬上限:最多 5 轮工具调用 // ...})// 为什么是 5?// - 正常的 memory 提取只需要 2-4 轮:// · 第 1 轮:并行读取所有可能需要更新的 memory 文件// · 第 2 轮:并行写入/编辑 memory 文件// · 偶尔第 3-4 轮:更新 MEMORY.md 索引等//// - 5 轮硬上限防止子 Agent 陷入"验证兔子洞":// 比如子 Agent 想去 grep 源码确认某个模式是否存在,// 然后又去读更多文件验证...// Prompt 中已经明确告诉它"不要浪费 turn 去验证内容",// 但 5 轮上限是最后一道安全网
5.2.5 提取 Prompt 的完整内容
这是发送给子 Agent 的指令(简化版):
源码位置: src/services/extractMemories/prompts.ts═══════════════════════════════════════════════════发送给子 Agent 的 Prompt(示意):═══════════════════════════════════════════════════"你现在是记忆提取子代理。分析上方最近约 {newMessageCount} 条消息,用它们更新你的持久化记忆系统。可用工具:FileRead、Grep、Glob、只读Bash、以及仅限 memory 目录的FileEdit/FileWrite。Bash rm 不允许。你的 turn 预算有限。FileEdit 需要先 FileRead 同一文件,所以高效策略是:第1轮——并行读取所有可能更新的文件;第2轮——并行写入/编辑所有文件。不要跨轮交替读写。你只能使用最近 {newMessageCount} 条消息中的内容来更新记忆。不要浪费 turn 去验证内容——不要 grep 源代码、不要读代码确认模式。## 记忆类型[完整的 4 种类型定义,含示例]## 什么不应该保存[排除规则]## 如何保存步骤1:写记忆文件(带 frontmatter 格式)步骤2:更新 MEMORY.md 索引## 现有记忆清单[当前 memory 目录中的文件列表,含描述和修改时间]## 用户行为指导[何时保存、如何使用、过期记忆处理]"═══════════════════════════════════════════════════
5.2.6 子 Agent 的权限限制
// 源码位置: src/services/extractMemories/extractMemories.ts → createAutoMemCanUseTool()function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn { return async (tool, input) => { // ✅ 允许:FileRead, Grep, Glob(读任何文件) if (tool.name in ['FileRead', 'Grep', 'Glob']) return { allowed: true } // ✅ 允许:Bash(但只能只读命令:ls, find, cat, stat, wc, head, tail) if (tool.name === 'Bash' && isReadOnly(input.command)) return { allowed: true } // ✅ 允许:FileEdit / FileWrite(但只能写 memory 目录内的文件) if (tool.name in ['FileEdit', 'FileWrite']) { if (isAutoMemPath(input.file_path, memoryDir)) return { allowed: true } return { allowed: false, reason: '只能写 memory 目录内的文件' } } // ❌ 拒绝:所有其他工具(Agent、MCP、可写 Bash 等) return { allowed: false } }}
5.3 Session Memory 系统详解
Session Memory 是单次会话内的结构化笔记系统,主要在**自动压缩(compact)**时使用,帮助模型在压缩上下文后仍能保持连贯性。
5.3.1 Session Memory 的模板结构
# Session Title_A short and distinctive 5-10 word descriptive title for the session_# Current State_What is actively being worked on right now? Pending tasks not yet completed._# Task specification_What did the user ask to build? Any design decisions or other explanatory context_# Files and Functions_What are the important files? In short, what do they contain?_# Workflow_What bash commands are usually run and in what order?_# Errors & Corrections_Errors encountered and how they were fixed. What approaches failed?_# Codebase and System Documentation_What are the important system components? How do they work/fit together?_# Learnings_What has worked well? What has not? What to avoid?_# Key results_If the user asked a specific output, repeat the exact result here_# Worklog_Step by step, what was attempted, done? Very terse summary_
5.3.2 Session Memory 触发条件
// 源码位置: src/services/SessionMemory/sessionMemory.ts// 三个阈值(通过 GrowthBook 远程配置):const config = { minimumMessageTokensToInit: 10_000, // 第一次提取前至少要有 10K tokens minimumTokensBetweenUpdate: 5_000, // 两次更新间至少增长 5K tokens toolCallsBetweenUpdates: 3, // 两次更新间至少有 3 次工具调用}function shouldExtractMemory(messages): boolean { // 条件1(必须):token 数超过阈值 const tokenThresholdMet = currentTokens > lastExtractionTokens + threshold // 条件2(必须):工具调用次数超过阈值 或者 最后一轮没有工具调用(安全窗口) const toolCallThresholdMet = toolCallsSinceLastExtraction >= 3 || lastTurnHadNoToolCalls return tokenThresholdMet && toolCallThresholdMet}
通俗理解:Session Memory 不是每轮都更新的——它等到对话积累了足够多的新内容,并且在一个”安静”的时刻(没有工具在跑)才触发更新。
5.3.3 Session Memory 更新流程
模型返回响应 │ ▼ Post-Sampling Hook 触发 │ ▼ shouldExtractMemory() 检查 ├── Token 增长 < 5K? → 跳过 ├── 工具调用 < 3次? → 跳过 └── 都满足 → 继续 │ ▼ Fork 子 Agent(不阻塞主流程) │ ▼ ┌───────────────────────────────────┐ │ 子 Agent 只能做一件事: │ │ 用 FileEdit 更新 session memory │ │ 文件的各个 section │ │ │ │ 规则: │ │ - 不能修改/删除 section 标题 │ │ - 不能修改斜体描述行 │ │ - 只能更新描述行下方的内容 │ │ - 每个 section ≤ 2000 tokens │ │ - 总计 ≤ 12000 tokens │ │ - 必须更新 "Current State" │ └───────────────────────────────────┘
5.3.4 lastSummarizedMessageId:Session Memory 的游标机制
Session Memory 系统维护一个内存变量 lastSummarizedMessageId,记录”summary.md 已经覆盖到哪条消息为止”。
源码位置: src/services/SessionMemory/sessionMemoryUtils.ts// 内存变量(非持久化,随会话存在)let lastSummarizedMessageId: string | undefined// 每次 Session Memory 提取成功后更新function updateLastSummarizedMessageIdIfSafe(messages) { // 安全检查:最后一条助手消息不能有工具调用 // (避免拆开 tool_use / tool_result 对) if (!hasToolCallsInLastAssistantTurn(messages)) { lastSummarizedMessageId = messages[messages.length - 1].uuid }}
工作原理图示:
对话时间线: msg-001 用户: "帮我看看 auth 模块" msg-002 助手: [调工具] [回复] msg-003 用户: "改一下这个 bug" msg-004 助手: [调工具] [回复] ↑ │ Session Memory 被触发(token 增长 > 5K + 工具 ≥ 3 次) │ Fork 子 Agent → 读取整段对话 → 更新 summary.md │ lastSummarizedMessageId = "msg-004" │ msg-005 用户: "再加个单元测试" msg-006 助手: [调工具] [回复] msg-007 用户: "跑一下测试" msg-008 助手: [调工具] [回复] ↑ │ Session Memory 再次触发 │ 子 Agent 能看到 msg-001 到 msg-008 的完整对话 │ 增量编辑 summary.md(不是追加,是原地更新各 section) │ lastSummarizedMessageId = "msg-008"
关键点:Session Memory 的子 Agent 每次都能看到从对话开头到当前的完整消息历史(通过 forkContextMessages: messages 继承)。它的任务不是”只看新消息做摘要”,而是”基于整段对话,增量编辑 summary.md 的各个 section”,所以 summary.md 始终是对整段对话的结构化笔记。
5.3.5 Session Memory Compaction 与 Full Compact 的分界
当对话接近 context window 上限时,autoCompactIfNeeded() 会先尝试 Session Memory Compaction,失败再降级到传统 Full Compact:
autoCompact 触发 │ ▼trySessionMemoryCompaction() ← 先尝试 │ ├── 成功 → 用 summary.md 替代旧消息,保留尾部原文 ✅ │ (便宜、快、不需要额外 API 调用) │ └── 返回 null → 降级到 compactConversation() ❌ (贵、慢、但能压得更狠——让模型 重新读整段历史写精简 summary)
trySessionMemoryCompaction 返回 null 的 6 种情况:
┌─────┬─────────────────────────────────────┬──────────────────────────────────┐│ # │ 条件 │ 含义 │├─────┼─────────────────────────────────────┼──────────────────────────────────┤│ 1 │ Feature flags 没同时开 │ tengu_session_memory + ││ │ │ tengu_sm_compact 必须都为 true │├─────┼─────────────────────────────────────┼──────────────────────────────────┤│ 2 │ summary.md 文件不存在 │ 对话太短,还没触发过提取 │├─────┼─────────────────────────────────────┼──────────────────────────────────┤│ 3 │ summary.md 是空模板 │ 触发过但没提取出有价值的内容 │├─────┼─────────────────────────────────────┼──────────────────────────────────┤│ 4 │ lastSummarizedMessageId 找不到 │ 消息被外部修改过,边界不可确定 │├─────┼─────────────────────────────────────┼──────────────────────────────────┤│ 5 │ 压缩后 token 数 ≥ autoCompactThreshold │ 关键条件!summary + 保留的 ││ │ │ 消息尾巴仍然太大,只能用更 ││ │ │ 激进的传统压缩 │├─────┼─────────────────────────────────────┼──────────────────────────────────┤│ 6 │ 任何异常 │ catch 兜底 │└─────┴─────────────────────────────────────┴──────────────────────────────────┘
第 5 条是最核心的降级条件:
源码位置: src/services/compact/sessionMemoryCompact.ts// Session Memory Compaction 保留消息的策略:const config = { minTokens: 10_000, // 至少保留 10K tokens 的尾部消息 minTextBlockMessages: 5, // 至少保留 5 条有文本的消息 maxTokens: 40_000, // 最多保留 40K tokens}// 切割逻辑(利用 lastSummarizedMessageId 做边界)://// msg-001 ─┐// msg-002 │── 被 summary.md 覆盖 → 丢弃,替换为 summary 内容// msg-003 │// msg-004 ─┘ ← lastSummarizedMessageId//// msg-005 ─┐// msg-006 │── 尚未被摘要覆盖 → 保留原文// msg-007 │ (向前扩展直到满足 minTokens + minTextBlockMessages)// msg-008 ─┘// 如果 summary + 保留的消息 ≥ autoCompactThreshold:if (postCompactTokenCount >= autoCompactThreshold) { return null // 压不下来 → 降级到传统 Full Compact}
一句话总结:不是”记忆多不新鲜”决定走哪条路,而是”summary.md + 保留的消息尾巴能否把 token 数压到阈值以下”。这个判断跟 §5.5 中 memoryAge 的”新鲜度”是完全独立的两个机制。
5.4 Memory 信息注入的三条路径
Memory 相关信息通过三条独立路径注入到模型的上下文中(详见 §4.6):
路径 A:行为规则 → System Prompt 动态部分
getSystemPrompt() └→ systemPromptSection('memory', () => loadMemoryPrompt()) ← memoize,只执行一次 └→ loadMemoryPrompt() └→ buildMemoryLines() ← 不读任何文件,只返回固定模板文本 返回 ~3K tokens: ├── "你有一个持久化记忆系统在 /path/to/memory/" ├── 4 种记忆类型定义(user/feedback/project/reference) ├── 如何保存记忆(两步流程) ├── 什么不该保存 ├── 何时访问记忆 └── 过期记忆验证规则
路径 B:MEMORY.md 实际内容 → 第一条 User Message
getUserContext() ← memoize,只执行一次 └→ getMemoryFiles() ← memoize,只读一次磁盘 ├── 读取 /etc/claude-code/CLAUDE.md (Managed) ├── 读取 ~/.claude/CLAUDE.md (User) ├── 读取 项目/CLAUDE.md, .claude/rules/*.md (Project) ├── 读取 项目/CLAUDE.local.md (Local) └── 读取 ~/.claude/projects/<slug>/memory/MEMORY.md (AutoMem) ← 这里! ├── 内容截断到 200 行 / 25,000 字节 └── 和 CLAUDE.md 一起拼入 <system-reminder> 包裹的 User Message
关键结论:
-
• 路径 A 和 B 都被 memoize,整个会话内只执行一次,每个 turn 返回相同的值 -
• 路径 C 每轮都执行,是唯一能在会话中动态引入新记忆的路径 -
• 后台 extractMemories 子 Agent 即使在 Turn 3 写了新记忆,路径 A/B 在本会话的 Turn 4+ 看不到 -
• 但路径 C 可以选中新写入的记忆文件(因为它每轮重新扫描 memory 目录) -
• /compact清除缓存后路径 A/B 也会重新加载
三条路径的对比总览
|
|
|
|
|
|---|---|---|---|
| 注入位置 |
|
|
<system-reminder> |
| 内容 |
|
|
|
| 执行频率 |
|
|
每轮都执行 |
| 能否感知新记忆 |
|
|
是
|
| 核心代码 | memdir.ts: buildMemoryLines() |
claudemd.ts: getMemoryFiles() |
findRelevantMemories.ts |
| 详细机制 |
|
|
|
System Prompt 中注入的 Memory 规则示例
以下是 buildMemoryLines() 返回的实际内容(简化版):
═══════════════════════════════════════════════════# auto memoryYou have a persistent, file-based memory system at`/root/.claude/projects/-workspace-myproject/memory/`.This directory already exists — write to it directlywith the Write tool (do not run mkdir or check forits existence).## Types of memory<types><type> <name>user</name> <description>用户角色、目标、偏好...</description> ...</type><type> <name>feedback</name> <description>用户对工作方式的指导...</description> ...</type>...</types>## What NOT to save in memory- Code patterns, architecture, file paths...- Git history, recent changes...- Debugging solutions or fix recipes...## How to save memoriesStep 1: 写文件(带 frontmatter)Step 2: 更新 MEMORY.md 索引## When to access memories- When memories seem relevant...- You MUST access memory when the user explicitly asks...═══════════════════════════════════════════════════注意:这里面没有任何用户特定的数据!MEMORY.md 的实际内容(如 "用户是Go专家")在第一条 User Message 中。
5.5 Memory 过期与新鲜度机制
Claude Code 为每条记忆定义了”新鲜度”——基于文件最后修改时间(mtime)计算天数,超过阈值时自动附加过期警告。
5.5.1 新鲜度计算
// 源码位置: src/memdir/memoryAge.ts// 计算记忆文件的"年龄"(天数,向下取整)// 参数是 mtimeMs(毫秒时间戳),不是 Date 对象function memoryAgeDays(mtimeMs: number): number { return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000)) // 0 = 今天写的,1 = 昨天写的,2+ = 更早 // 负值(时钟偏差)clamp 到 0}// 人类可读的年龄描述// 模型不擅长日期计算,"47 days ago" 比 ISO 时间戳更能触发过期推理function memoryAge(mtimeMs: number): string { const d = memoryAgeDays(mtimeMs) if (d === 0) return 'today' if (d === 1) return 'yesterday' return `${d} days ago`}
5.5.2 过期阈值:>1 天
// 源码位置: src/memdir/memoryAge.ts// 关键阈值: d <= 1 视为"新鲜"(今天/昨天),d >= 2 触发过期警告function memoryFreshnessText(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.` )}// 包装成 <system-reminder> 标签的版本function memoryFreshnessNote(mtimeMs: number): string { const text = memoryFreshnessText(mtimeMs) if (!text) return '' return `<system-reminder>${text}</system-reminder>\n`}
为什么要这样做? 源码注释说得很清楚:用户反馈过”过期的代码状态记忆(带 file:line 引用)被模型当成事实断言”的问题。带行号引用的过期信息反而让错误看起来更权威。所以需要主动提醒”这记的是 X 天前的事,代码可能已经变了”。
5.5.3 新鲜度如何注入到记忆中
记忆被召回(findRelevantMemories 选中) │ ▼读取完整文件内容 + 计算 mtime │ ▼memoryHeader(path, mtimeMs) 构造头部: │ ├── 如果 d <= 1(新鲜): │ → "Memory (saved today): /path/to/file.md:" │ └── 如果 d >= 2(过期): → "This memory is 47 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. Memory: /path/to/file.md:" │ ▼包裹在 <system-reminder> 标签中 → 注入当前轮消息
// 源码位置: src/utils/attachments.ts// 注意: header 在创建附件时一次性计算并缓存// 不会在渲染时重新计算 memoryAge(mtimeMs)// 因为 Date.now() 每次不同 → "3 days ago" 变 "4 days ago"// → 不同字节 → prompt cache 失效!export function memoryHeader(path: string, mtimeMs: number): string { const staleness = memoryFreshnessText(mtimeMs) return staleness ? `${staleness}\n\nMemory: ${path}:` // 过期: 警告在前 : `Memory (saved ${memoryAge(mtimeMs)}): ${path}:` // 新鲜: 简洁}
5.5.4 System Prompt 中的过期行为规则
除了注入时的 freshnessNote,System Prompt 本身也包含两条硬编码的过期处理规则:
源码位置: src/memdir/memoryTypes.ts═══ 规则 1: MEMORY_DRIFT_CAVEAT(召回时的漂移警告)═══"Memory records can become stale over time. Use memory as contextfor what was true at a given point in time. Before answering theuser or building assumptions based solely on information in memoryrecords, verify that the memory is still correct and up-to-dateby reading the current state of the files or resources. If arecalled memory conflicts with current information, trust whatyou observe now — and update or remove the stale memory."═══ 规则 2: MEMORY_TRUST_SECTION(信任度指导)═══"A memory that names a specific function, file, or flag is a claimthat it existed *when the memory was written*. It may have beenrenamed, removed, or never merged. Before recommending it:- If the memory names a file path: check the file exists.- If the memory names a function or flag: grep for it.- If the user is about to act on your recommendation, verify first.'The memory says X exists' is not the same as 'X exists now.'A memory that summarizes repo state (activity logs, architecturesnapshots) is frozen in time. If the user asks about *recent* or*current* state, prefer git log or reading the code over recallingthe snapshot."
这两条规则的核心思想:记忆是时间点快照,不是实时状态。用记忆做起点,但行动前要验证。
5.6 Memory 相关路径安全验证
// 源码位置: src/memdir/paths.ts// 安全检查:防止恶意路径function validateMemoryPath(dir: string): boolean { // ❌ 拒绝相对路径 if (!path.isAbsolute(dir)) return false // ❌ 拒绝根目录或接近根目录的路径(防止 ~/.ssh 泄露) if (dir === '/' || dir === '/home') return false // ❌ 拒绝 Windows UNC 路径和驱动器根目录 if (dir.startsWith('\\\\') || /^[A-Z]:\\$/i.test(dir)) return false // ❌ 拒绝包含 null 字节的路径(可能绕过系统调用) if (dir.includes('\0')) return false return true}
5.7 Memory 主动召回机制(Active Recall)
前面 §5.4 提到路径 A 和 B 都是每 session 一次的静态注入。那问题来了:会话进行到一半时,如果用户问了一个和之前某条记忆相关的问题,系统怎么”想起来”?
答案就是主动召回(Active Recall)——每轮查询开始时的动态记忆检索机制。
5.7.1 触发时机
用户输入新消息 │ ▼query loop 启动(src/query.ts) │ ├── [并行] 预取工具描述 ├── [并行] 预取附件 ◄──── attachments.ts 在这里调用 findRelevantMemories() └── [并行] ...其他预取 │ ▼等待所有预取完成 → 组装消息 → API 调用
每轮用户输入都会触发一次主动召回。这是一个 prefetch 操作,和其他预取任务并行执行,不阻塞主流程。
核心代码位置:src/utils/attachments.ts 约第 2217 行
5.7.2 完整召回流程
findRelevantMemories(userInput, memoryDirs, signal, recentTools, alreadySurfaced) │ ▼Step 1: 扫描 memory 目录 │ scanMemoryFiles() → 读取所有 .md 文件的 frontmatter │ ⚠ 注意:扫描整个目录,不只是 MEMORY.md 索引中的文件! │ 上限: MAX_MEMORY_FILES = 200 │ 排序: 按修改时间,最新优先 │ ▼Step 2: 去重过滤 │ 过滤掉 alreadySurfaced 集合中的文件 │ (之前 turn 已选过的不会再选) │ ▼Step 3: 构建候选清单 │ formatMemoryManifest() → 格式化为: │ "- [user] user_role.md (2025-01-15): 用户是Go专家,React新手" │ "- [feedback] testing.md (2025-01-14): 不要mock数据库" │ ... │ ▼Step 4: Sonnet sideQuery 打分选择 │ 把候选清单 + 用户当前输入 → 发给 Claude Sonnet │ Sonnet 根据相关性选出 ≤5 个最相关的文件 │ 返回 JSON: ["user_role.md", "testing.md"] │ ▼Step 5: 读取完整内容 + 注入 │ 读取选中文件的完整 markdown 内容 │ 附加 freshnessNote(过期提醒) │ 作为 <system-reminder> 附件注入当前轮消息 │ ▼Step 6: 更新 alreadySurfaced 把本轮选中的文件加入集合,下轮不再重复选
5.7.3 Sonnet 的选择 Prompt
源码位置: src/memdir/findRelevantMemories.ts — SELECT_MEMORIES_SYSTEM_PROMPT"You are a memory selection assistant. Given a user query and a list ofavailable memory files (with filename and description), select up to 5memories that are most relevant to the current query.Be selective — only pick memories that are clearly relevant. Don't includetool API documentation for tools the user has recently used (they'realready in context).Return a JSON array of selected filenames."
几个设计决策值得注意:
-
• 用 Sonnet 而不是 Opus:这只是一个检索排序任务,Sonnet 更快更便宜 -
• 只看 frontmatter 摘要:不会读完整文件内容来匹配,所以 frontmatter 里 description字段的质量直接决定召回效果 -
• 过滤 recentTools:如果用户最近刚用了 Bash 工具,关于 Bash API 的记忆就不会被选中(已在上下文中)
5.7.4 注入到模型的形式
选中的记忆以 <system-reminder> 标签包裹,附加到当前轮次的消息中:
<system-reminder>Note: Memory file "feedback_testing.md" was recalled as potentially relevant.[Freshness: 2 days old — verify before acting on it]---name: 不要在测试中mock数据库description: 用户要求测试必须使用真实数据库连接,不要mocktype: feedback---用户明确要求:所有测试必须连接真实数据库(哪怕是 test 库)。原因是之前因为 mock 数据库遗漏了一个 schema 变更,导致生产事故。</system-reminder>
5.7.5 去重机制详解
alreadySurfaced 是一个跨 turn 累积的 Set<string>:Turn 1: 用户问 "帮我写个测试" → Sonnet 选中: feedback_testing.md, project_goals.md → alreadySurfaced = {feedback_testing.md, project_goals.md}Turn 2: 用户问 "用什么测试框架" → 候选列表已移除 feedback_testing.md 和 project_goals.md → Sonnet 从剩余文件中选: reference_testing_framework.md → alreadySurfaced = {feedback_testing.md, project_goals.md, reference_testing_framework.md}Turn 3: 用户问 "今天天气怎么样" → Sonnet 判断没有相关记忆 → 返回空数组 → alreadySurfaced 不变效果:同一条记忆在一个会话中最多被注入一次,避免浪费 token
5.7.6 检索范围:哪些 Memory 会被搜、哪些不会
Claude Code 有 5 种 Memory,但主动召回默认只扫其中一个目录:
源码位置: src/utils/attachments.ts → getRelevantMemoryAttachments()// 1. 检查用户消息中有没有 @agent-xxxconst memoryDirs = extractAgentMentions(input).flatMap(mention => { const agentDef = agents.find(...) return agentDef?.memory ? [getAgentMemoryDir(agentType, agentDef.memory)] : []})// 2. 如果提到了 Agent → 搜那个 Agent 的 memory 目录// 如果没提到 → 搜 Auto Memory 目录(递归,包含 team/ 子目录)const dirs = memoryDirs.length > 0 ? memoryDirs : [getAutoMemPath()]
┌──────────────────────┬────────────────────────────────────┬───────────────┐│ Memory 类型 │ 路径 │ 被召回检索? │├──────────────────────┼────────────────────────────────────┼───────────────┤│ Auto Memory │ ~/.claude/projects/<slug>/memory/ │ ││ ├── 个人记忆文件 │ ├── user_role.md 等 │ ✅ 被扫描 ││ ├── MEMORY.md │ ├── MEMORY.md │ ❌ 排除 ││ │ │ │ │ (走路径B直接 ││ │ │ │ │ 加载到 SP) ││ └── Team Memory │ └── team/*.md │ ✅ 一起扫 │├──────────────────────┼────────────────────────────────────┼───────────────┤│ Agent Memory │ ~/.claude/agent-memory/<type>/ │ ❌ 默认不扫 ││ │ .claude/agent-memory/<type>/ │ ※ @mention ││ │ .claude/agent-memory-local/<type>/ │ 时切换到 ││ │ │ 该目录 │├──────────────────────┼────────────────────────────────────┼───────────────┤│ Session Memory │ ~/.claude/projects/<slug>/<sid>/ │ ❌ 完全不扫 ││ │ session-memory/summary.md │ (只用于 ││ │ │ compact) │└──────────────────────┴────────────────────────────────────┴───────────────┘
关键逻辑:
-
• 默认情况下只搜 getAutoMemPath()一个目录,递归扫描,所以team/子目录自动包含 -
• @mention Agent 时(如用户输入 “@agent-reviewer …”),检索目标切换到该 Agent 的 memory 目录,不搜 Auto Memory -
• MEMORY.md 被 scanMemoryFiles()按 basename 排除——它通过路径 B 直接拼入 system prompt,不参与动态召回 -
• Session Memory 路径完全不同(带 sessionId),从不参与检索,只参与 compact
5.7.7 完整的 Memory 生命周期图
用户与 Claude Code 交互 ════════════════════════ Turn 1 (会话开始) ┌──────────────────────────────────────────────────────┐ │ System Prompt: 路径A — 行为规则(固定模板) │ │ User Message: 路径B — MEMORY.md 索引内容 │ │ 附件: 路径C — Sonnet 选出的 ≤5 个记忆文件 │ └──────────────────────────────────────────────────────┘ │ ▼ Claude 回答 │ ▼ stopHook: extractMemories() 后台子 Agent 可能写入新记忆文件 │ Turn 2 ▼ ┌──────────────────────────────────────────────────────┐ │ System Prompt: 路径A — 同上(cached) │ │ User Message: 路径B — 同上(memoized) │ │ 附件: 路径C — 重新扫描目录,新记忆也能被选中 │ │ (但排除 Turn 1 已选过的文件) │ └──────────────────────────────────────────────────────┘ │ ▼ Claude 回答 ... ════════════════════════ 下次新会话: 路径A/B 重新加载,alreadySurfaced 也重置为空
5.8 AutoDream:离线记忆巩固
如果说 extractMemories 是每轮即时写回,那么 AutoDream 就是跨 session 的离线巩固——类似人类睡眠期间的记忆整理。
5.8.0 Auto Memory 与 AutoDream 的关系
Auto Memory 和 AutoDream 不是两种独立的 memory 存储,而是同一片存储区域上的两个写入时间尺度:
Auto Memory 目录 ~/.claude/projects/<slug>/memory/ │ ┌──────────┴──────────┐ │ │ 回合级写入 跨 session 离线整理 (extractMemories) (AutoDream) │ │ 每轮对话结束后 满足门槛后触发: fork 子 agent 提取 - 距上次 ≥ 24 小时 当前对话中值得记的 - 累积 ≥ 5 个新 session 信息,写入 memory 文件 - 获取文件锁(防并发) │ │ ▼ ▼ 写 user_role.md 读已有 memory 文件 写 feedback_testing.md + grep session transcripts (JSONL) 更新 MEMORY.md 索引 → 合并重复、纠正矛盾、删除过期 → 精简 MEMORY.md 索引
-
• 操作同一个目录: AutoDream的memoryRoot = getAutoMemPath(),和extractMemories写入的目录完全相同 -
• 共用同一套权限沙箱:AutoDream 直接复用 createAutoMemCanUseTool(memoryRoot) -
• 职责互补:extractMemories 负责”快速写入新信息”,AutoDream 负责”延迟整合、去噪、纠错” -
• 类比:extractMemories 是”即时记笔记”,AutoDream 是”睡一觉后整理笔记本”
5.8.1 为什么需要离线巩固
即时写回有一个固有问题:
-
• 每轮提取的信息往往带有局部噪声(部分事实、暂时误判、未验证结论) -
• 如果全部高权重固化为长期知识,系统会越来越”自信地错”
AutoDream 的思路是:在线阶段允许粗粒度记录,离线阶段再做更高层次的整理。
5.8.2 触发条件与门控
stopHook 执行 executeAutoDream()(每轮 fire-and-forget) │ ▼Gate 1: 是否启用? ├── 不在 KAIROS 模式 ├── 不在 remote 模式 ├── Auto Memory 已启用 └── Feature Flag 'tengu_onyx_plover' 开启 │ ▼Gate 2: 时间门槛 │ 距离上次 consolidation ≥ 24 小时(默认 minHours: 24) │ 通过 lock 文件的 mtime 判断 │ ▼Gate 3: 扫描节流 │ SESSION_SCAN_INTERVAL_MS = 10 分钟 │ 时间门槛通过但 session 门槛不满足时, │ 10 分钟内不重复扫描 │ ▼Gate 4: Session 数量 │ listSessionsTouchedSince(lastConsolidatedAt) │ 过滤掉当前 session → 剩余 ≥ 5 个(默认 minSessions: 5) │ ▼Gate 5: 获取锁 │ tryAcquireConsolidationLock() │ PID 写入 .consolidate-lock 文件 │ 如果已有锁且 PID 存活 → 放弃 │ 如果锁 >1h 且 PID 已死 → 回收锁 │ ▼所有 Gate 通过 → fork 子 Agent 执行 consolidation
5.8.3 Consolidation Prompt(完整版)
源码位置: src/services/autoDream/consolidationPrompt.ts# Dream: Memory ConsolidationYou are performing a dream — a reflective pass over your memory files.Synthesize what you've learned recently into durable, well-organizedmemories so that future sessions can orient quickly.Memory directory: `<memoryRoot>`Session transcripts: `<transcriptDir>` (large JSONL files — grep narrowly)---## Phase 1 — Orient- ls memory 目录,了解现有内容- Read MEMORY.md 索引- Skim 现有 topic files,避免创建重复## Phase 2 — Gather recent signal按优先级搜索新信息:1. Daily logs (logs/YYYY/MM/YYYY-MM-DD.md) 如果存在2. 现有记忆中与当前代码矛盾的内容3. Transcript 搜索(grep 窄关键词,不要完整读取)## Phase 3 — Consolidate- 合并新信号到现有 topic files(不要创建近似重复)- 把相对日期("yesterday")转换为绝对日期- 删除被证伪的旧记忆## Phase 4 — Prune and index- 更新 MEMORY.md,保持 <200 行 / <25KB- 移除过时指针- 精简过长条目(>200 字符的内容移到 topic file)- 解决矛盾(两个文件冲突时,修复错误的那个)---附加上下文: Bash 限制为只读命令。Sessions since last consolidation (N): [session IDs]
5.8.4 执行流程
1. 注册 DreamTask(UI 底部显示进度条)2. buildConsolidationPrompt() 构建 prompt3. runForkedAgent() — 和 extractMemories 共用 forked agent 机制 ├── 共享 prompt cache(上下文隔离 + 缓存复用) ├── canUseTool = createAutoMemCanUseTool(memoryRoot) │ └── Read/Grep/Glob 可用,Bash 只读,Edit/Write 限 memory 目录 ├── querySource = 'auto_dream' └── skipTranscript = true(不记录 dream 本身的 transcript)4. 进度追踪: ├── phase: 'starting' → 'updating'(首次 Edit 时转换) ├── touchedPaths: 记录被 Edit/Write 修改的文件 └── 显示 "Improved N memory files" 完成消息5. 成功 → completeDreamTask + logEvent 失败 → failDreamTask + rollbackConsolidationLock(恢复 mtime)
5.8.5 与 extractMemories 的对比
|
|
|
|
|---|---|---|
| 触发频率 |
|
|
| 输入范围 |
|
|
| 目标 |
|
|
| 类比 |
|
|
| 核心代码 | extractMemories.ts |
autoDream.ts |
5.9 Agent Memory:角色分域记忆
Claude Code 支持自定义 Agent(通过 .claude/agents/ 目录定义),每个 Agent 可以拥有独立的 Memory 作用域。
5.9.1 三种 Scope
源码位置: src/tools/AgentTool/agentMemory.ts┌─────────────────────────────────────────────────────────┐│ Scope: user ││ 路径: ~/.claude/agent-memory/<agentType>/ ││ 特点: 跨项目持久化,所有项目共享同一份角色记忆 ││ 适用: 通用写作偏好、全局工作习惯 ││ ││ Prompt 指导: "Since this memory is user-scope, ││ keep learnings general since they apply across ││ all projects" │├─────────────────────────────────────────────────────────┤│ Scope: project ││ 路径: <cwd>/.claude/agent-memory/<agentType>/ ││ 特点: 项目级,可提交到 VCS 供团队共享 ││ 适用: 项目特定的工作流、约定 ││ ││ Prompt 指导: "Since this memory is project-scope ││ and shared with your team via version control, ││ tailor your memories to this project" │├─────────────────────────────────────────────────────────┤│ Scope: local ││ 路径: <cwd>/.claude/agent-memory-local/<agentType>/ ││ 特点: 本机私有,不进入版本控制 ││ 适用: 本机环境路径、个人调试经验 ││ ││ Prompt 指导: "Since this memory is local-scope ││ (not checked into version control), tailor your ││ memories to this project and machine" │└─────────────────────────────────────────────────────────┘
5.9.2 Memory 如何注入 Agent
Agent 定义文件(.claude/agents/my-agent.md) │ frontmatter 中声明: memory: project │ ▼loadAgentsDir.ts 解析 Agent 定义 │ ▼AgentTool.tsx 启动 Agent 时: │ selectedAgent.getSystemPrompt({ toolUseContext }) │ ▼getSystemPrompt() 内部: │ if (isAutoMemoryEnabled() && memory) { │ const memoryPrompt = loadAgentMemoryPrompt(agentType, memory) │ return systemPrompt + '\n\n' + memoryPrompt ← 拼接! │ } │ ▼loadAgentMemoryPrompt() 实际工作: ├── 解析 scope → 确定目录路径 ├── ensureMemoryDirExists(memoryDir) ← fire-and-forget ├── buildMemoryPrompt() ← 和 Auto Memory 共用,会读取 MEMORY.md 内容 └── 注入 scope-specific 指导语
关键设计:当 Agent 声明了 memory 但其 tools 列表没有 Read/Edit/Write 时,系统会自动注入这三个工具,确保 Agent 有能力读写记忆文件。
5.9.3 对多 Agent 系统的启示
Research Agent (user scope) ─── 跨项目记住你的写作偏好 │Repo Planner (project scope) ─── 记住这个仓库的特殊流程 │Local Ops (local scope) ─── 记住本机的环境和路径经验
这意味着:角色定义 + 角色记忆 是绑定在一起加载的,不需要单独的 “agent memory service”。
5.10 Team Memory:团队共享记忆
Team Memory 是 Memory 系统从单用户扩展到团队协作的关键一层。
5.10.1 解决什么问题
当多个开发者在同一 repo 上与 Claude Code 协作时:
-
• CI 的坑、特定服务的隐含约束、团队约定 → 这些不是个人私有信息 -
• 如果每个人都要各自”教会”Claude 一遍,效率很低 -
• Team Memory 让这些经验变成 repo 级别的共享知识
Team 的定义:
源码位置: src/services/teamMemorySync/index.ts + watcher.tsTeam 由两个要素确定:1. GitHub repo 的 remote URL getGithubRepo() → 解析 git remote origin → "owner/repo" 例如: "anthropics/claude-code" API 端点: GET /api/claude_code/team_memory?repo=anthropics/claude-code2. OAuth 认证 必须使用 Anthropic 第一方 OAuth(isUsingOAuth()) 需要 claude_ai_inference + claude_ai_profile 两个 scope结果: 同一个 GitHub repo 的所有认证协作者共享同一份 Team Memory
限制:只支持 github.com remote。如果 repo 没有 github.com 的 remote origin(比如 GitLab、自建 Git 服务器),Team Memory 同步直接跳过(watcher 不会启动)。
5.10.2 目录结构
~/.claude/projects/<hash>/memory/ ← Auto Memory(私有)~/.claude/projects/<hash>/memory/team/ ← Team Memory(共享) │ ├── MEMORY.md ├── ci_gotchas.md ├── deploy_checklist.md └── ...
Team Memory 是 Auto Memory 的子目录,但有独立的 MEMORY.md 索引。
5.10.3 同步机制
本地文件 Anthropic API │ │ │ ┌── pull ──────────────────────┐ │ │ │ GET /api/claude_code/ │ │ │ │ team_memory?repo=... │ │ │ │ │ │ │ │ 使用 ETag 条件请求: │ │ │ │ If-None-Match → ETag │ │ │ │ 304 Not Modified → 跳过 │ │ │ │ 200 → server 内容覆盖本地 │ │ │ └──────────────────────────────┘ │ │ │ │ ┌── push ──────────────────────┐ │ │ │ 只上传 checksum 不同的 key: │ │ │ │ for (key, localHash): │ │ │ │ if serverHash != localHash│ │ │ │ → delta[key] = content │ │ │ │ │ │ │ │ PUT /api/claude_code/ │ │ │ │ team_memory?repo=... │ │ │ │ If-Match: <ETag> (乐观锁) │ │ │ │ │ │ │ │ 412 冲突 → 刷新 hash → │ │ │ │ 重算 delta → 重试 │ │ │ │ (最多 2 次冲突重试) │ │ │ └──────────────────────────────┘ │ │ │ ▼ ▼fs.watch() 监听文件变更 │ DEBOUNCE_MS = 2000(2秒防抖) └→ 自动触发 push
保守策略:
-
• Pull = server wins per-key:服务端每个 key 的内容直接覆盖本地对应文件 -
• Push = local wins on conflict:同一个 key 本地和服务端都改了时,本地版本覆盖服务端(不做内容合并) -
• 删除不传播:删除本地文件不会删除服务端的对应条目,下次 pull 会把它恢复回来 -
• 超大 PUT 分批:单次请求 body 限 200KB(网关 413 阈值 ~256-512KB),超出自动拆成多个顺序 PUT(upsert 语义保证安全) -
• 永久失败抑制:如果 push 因不可恢复的原因(无 OAuth、403、413 超条目数)失败,watcher 会抑制后续所有 push 尝试,直到用户删除文件(清理条目数)或重启会话(修复认证)
5.10.4 Secret 扫描(PSR M22174)
Team Memory 共享给所有 repo 协作者,所以有严格的 secret 保护:
写入 Team Memory 文件时(FileWriteTool / FileEditTool): │ ▼checkTeamMemSecrets(filePath, content) │ ├── scanForSecrets(content) ← 40+ 条 gitleaks 规则 │ ├── AWS keys (AKIA...) │ ├── Anthropic API keys (sk-ant-api03-...) │ ├── GitHub PAT (ghp_..., github_pat_...) │ ├── Slack tokens (xoxb-..., xoxp-...) │ ├── PEM private keys │ └── ... 30+ 其他规则 │ ├── 检测到 secret → 阻止写入 + 返回错误消息: │ "Content contains potential secrets and cannot be │ written to team memory. Team memory is shared with │ all repository collaborators." │ └── push 时也会跳过含 secret 的文件(不上传)
5.10.5 大小限制
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5.10.6 启动流程
Claude Code 启动 │ ▼feature('TEAMMEM') && isTeamMemoryEnabled() │ ├── 需要: auto memory 已启用 │ ├── 需要: tengu_herring_clock feature flag │ └── 需要: 有 github.com remote + OAuth 认证 │ ▼startTeamMemoryWatcher() ├── 1. 初始 pull(启动前先同步一次) ├── 2. 启动 fs.watch() 递归监听 team/ 目录 └── 3. 文件变更 → 2 秒防抖 → 自动 push
5.11 Memory 各环节的长度限制与截断策略
Claude Code 在 Memory 的存储、召回、同步、压缩各环节都设有明确的限制。以下是完整汇总:
5.11.1 MEMORY.md 入口文件
|
|
|
|
|
|---|---|---|---|
|
|
|
memdir.ts:35MAX_ENTRYPOINT_LINES |
|
|
|
|
memdir.ts:38MAX_ENTRYPOINT_BYTES |
|
两个限制先后应用(先行数、再字节)。设计考虑:200 行 × ~125 字符/行 ≈ 25KB,字节限制兜底那些”每行很长”的 MEMORY.md(实际观察到 197KB 在 200 行内的极端案例)。
5.11.2 Memory 文件扫描
|
|
|
|
|
|---|---|---|---|
|
|
|
memoryScan.ts:21MAX_MEMORY_FILES |
|
|
|
|
memoryScan.ts:22FRONTMATTER_MAX_LINES |
|
5.11.3 Memory 召回注入(Attachment)
当相关记忆被选中注入当前回合时的限制:
|
|
|
|
|
|---|---|---|---|
|
|
|
attachments.ts:269MAX_MEMORY_LINES |
|
|
|
|
attachments.ts:277MAX_MEMORY_BYTES |
|
|
|
|
attachments.ts:288MAX_SESSION_BYTES |
|
设计考虑:
-
• 单文件 4KB 上限 × 每轮最多注入约 5 个文件 = 每轮最多 20KB -
• 60KB 会话上限 ≈ 3 次完整注入;之后最相关的记忆通常已在上下文中 -
• Compact 会自然重置累计计数(旧 attachment 从上下文消失)
5.11.4 Session Memory
|
|
|
|
|
|---|---|---|---|
|
|
|
SessionMemory/prompts.ts:8MAX_SECTION_LENGTH |
|
|
|
|
SessionMemory/prompts.ts:9MAX_TOTAL_SESSION_MEMORY_TOKENS |
CRITICAL 警告
|
|
|
|
prompts.tstruncateSessionMemoryForCompact() |
|
5.11.5 Team Memory
|
|
|
|
|
|---|---|---|---|
|
|
|
teamMemorySync/index.ts:75MAX_FILE_SIZE_BYTES |
|
|
|
|
teamMemorySync/index.ts:89MAX_PUT_BODY_BYTES |
|
|
|
|
teamMemorySync/index.ts:113serverMaxEntries |
|
|
|
|
teamMemorySync/index.ts:71 |
|
|
|
|
teamMemorySync/index.ts:91MAX_CONFLICT_RETRIES |
|
5.11.6 AutoDream 巩固
|
|
|
|
|
|---|---|---|---|
|
|
|
consolidationPrompt.ts:55 |
|
|
|
|
consolidationPrompt.ts:55 |
|
|
|
|
consolidationPrompt.ts |
- [Title](file.md) — one-line hook 格式 |
5.11.7 限制之间的协作
Memory 文件写入 │ ├── MEMORY.md: 200行 / 25KB 硬截断 ├── topic files: 无硬性限制(依赖 prompt 指导) │ ▼ 被召回时Memory Recall │ ├── 扫描: 最多 200 个文件 × 前 30 行 frontmatter ├── 注入: 每文件 200行 / 4KB,每轮 ~5 文件,会话 60KB 总量 │ ▼ 参与压缩时Session Memory → Compact │ ├── 每 Section ≤2000 chars, 总计 ≤12K tokens └── Compact 时进一步截断到 8K chars/section
设计哲学:限制从松到紧——写入时相对宽松(topic files 无硬性限制),召回注入时逐层收紧(扫描 200 文件 → 注入 4KB/文件 → 会话 60KB 上限),确保 context window 预算始终可控。
6. Context Window 管理与自动压缩
6.1 为什么需要压缩
Claude 模型有上下文窗口限制(比如 200K tokens)。一次完整的编码会话——文件读取、工具调用、搜索结果——很容易在几十个回合内积累到 100K+ tokens。如果不做任何处理,对话会撞上上下文上限,导致 API 报错或模型无法生成足够长的回复。
Claude Code 的压缩机制需要同时兼顾几个约束:
-
• 腾出空间:为后续对话和模型输出预留 token 预算 -
• 保留关键信息:当前任务状态、未完成的工具调用链、用户的最新指令不能丢 -
• 尽量复用 Prompt Cache:Anthropic 服务端对 system prompt 和对话前缀有 1 小时 TTL 的缓存;压缩策略如果破坏缓存前缀,会导致后续请求成本翻倍 -
• 不阻塞用户:压缩应尽量在后台完成,不让用户等待
这些约束互相矛盾——激进压缩省 token 但丢信息、破坏缓存;保守压缩保信息但空间不够。Claude Code 因此设计了四级渐进式流水线(§6.3),从几乎零开销的裁剪逐步升级到全量 LLM 摘要。
6.2 压缩阈值
// 源码位置: src/services/compact/autoCompact.tsconst THRESHOLDS = { AUTOCOMPACT_BUFFER: 13_000, // 留 13K 给自动压缩的空间 WARNING_BUFFER: 20_000, // 警告阈值(再多就危险了) ERROR_BUFFER: 20_000, // 错误阈值 MANUAL_COMPACT_BUFFER: 3_000, // 手动 /compact 的空间 MAX_SUMMARY_TOKENS: 20_000, // 摘要最多 20K tokens MAX_CONSECUTIVE_FAILURES: 3, // 连续失败3次就放弃}// 计算有效上下文窗口function getEffectiveContextWindowSize(model: string): number { const contextWindow = getContextWindowForModel(model) // e.g., 200_000 const reservedForOutput = Math.min(getMaxOutputTokens(model), 20_000) return contextWindow - reservedForOutput // e.g., 180_000}// 触发自动压缩的阈值function getAutoCompactThreshold(model: string): number { return getEffectiveContextWindowSize(model) - 13_000 // e.g., 167_000}
6.3 四级渐进式压缩流水线
Claude Code 在每次 API 调用前按固定顺序依次执行一条四级流水线。各级不互斥,可以在同一个 query 内依次运行:
每次 query() 调用前 │ ┌───── Level 1 ─────────────┤ │ Snip Compact │ 从头部裁剪最旧消息 │ (≈0 API 开销) │ 返回 snipTokensFreed └───────────┬───────────────┘ │ ┌───── Level 2 ─────────────┤ │ Micro Compact │ 清除旧工具结果内容 │ (0 或极低 API 开销) │ 两条路径:Time-based / Cached └───────────┬───────────────┘ │ ┌───── Level 3 ─────────────┤ │ Context Collapse │ 读取时投影折叠(ANT-only) │ (独立 ctx-agent 生成摘要) │ 不修改原始消息 └───────────┬───────────────┘ │ ┌───── Level 4 ─────────────┤ │ Auto Compact / Reactive │ 全量摘要压缩 │ (Fork Agent + LLM 调用) │ 不可逆地替换旧消息 └───────────────────────────┘
源码位置: 流水线编排在
src/query.ts的 query() 函数中(约 396-470 行),各级别按顺序调用。
6.3.1 Level 1 — Snip Compact(裁剪历史)
做什么:从对话历史的头部直接删除最旧的消息,几乎零 API 开销。
核心机制:
-
• 采用 protected tail 策略——最后一条 assistant 消息及其之后的消息永远不被裁剪 -
• 裁剪数量按 token 预算计算(不是固定条数),返回精确的 tokensFreed -
• 裁剪位置插入 snip boundary marker(边界标记消息) -
• 被裁剪的消息 UUID 存入 snipMetadata.removedUuids -
• 不修改 REPL 的完整滚动历史(UI 侧仍可查看),只影响发给 API 的消息数组
触发时机:每次 query 发起前自动运行 snipCompactIfNeeded(),也可通过 /snip 命令手动触发。
tokensFreed 的传递:裁剪释放的 token 数被显式传递到下游的 autocompact 阈值判断:
// src/services/compact/autoCompact.tsconst tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
这是因为 tokenCountWithEstimation 从 protected-tail assistant 的 usage 统计中读取 token 数(该消息没被裁剪),所以必须手动减去 snipTokensFreed 才能反映真实的剩余空间。
Feature flag:
HISTORY_SNIP(ANT-only,源文件snipCompact.ts在 external builds 中被 DCE 移除)
6.3.2 Level 2 — Micro Compact(压缩工具结果)
做什么:清除旧的工具调用结果内容,把大块输出替换为 [Old tool result content cleared],保留消息结构。
可压缩的工具(COMPACTABLE_TOOLS):Read、Bash/PowerShell、Grep、Glob、WebSearch、WebFetch、Edit、Write。
两条路径:
路径 A — Time-based Microcompact(基于时间间隙,优先执行):
当前时间 - 最后 assistant 消息时间 ≥ 60 分钟? │ ├── 是 → 清除旧工具结果(保留最近 keepRecent=5 个) │ 替换内容为 '[Old tool result content cleared]' │ 重置 Cached MC 状态(服务端缓存已过期) │ └── 否 → 跳过,交给路径 B
-
• 为什么是 60 分钟?Anthropic 服务端 prompt cache TTL 为 1 小时,超过后缓存必定过期,此时清理不会浪费缓存 -
• 在 API 调用前直接修改本地消息内容 -
• GrowthBook 配置: tengu_slate_heron
路径 B — Cached Microcompact(基于缓存编辑 API):
只在 Time-based 未触发时运行 │ ├── 计算哪些工具结果超过阈值需要删除 ├── 构建 cache_edits 块: { type: 'delete', cache_reference: tool_use_id } ├── 通过 API 的 cache_edits 指令让服务端删除缓存中的工具结果 ├── 本地消息内容不修改(删除在服务端完成) └── pinCacheEdits() 记录已提交的删除,后续请求重新插入
-
• 利用 Anthropic 的 cache_editsAPI,不需要重新写入整个缓存前缀 -
• Feature flag: CACHED_MICROCOMPACT
源码位置:
src/services/compact/microCompact.ts(531 行),src/services/compact/timeBasedMCConfig.ts
6.3.3 Level 3 — Context Collapse(上下文折叠)
做什么:这是最独特的一层——读取时投影系统,不修改持久化的对话历史,而是在生成 API prompt 时动态”折叠”旧消息段,用摘要占位符替代。
类比:像 IDE 中的代码折叠——折叠后看到一行摘要,但展开后原文还在。
核心设计——”投影”而非”改写”:
原始消息: [A, B, C, D, E, F, G, H] ← REPL 数组始终完好 ↓ 折叠 A-DCommit log: [collapse_1: A→D 摘要为 "S1"]投影视图: [S1, E, F, G, H] ← API 实际看到的
-
• 维护 append-only commit log(代号 marble_origami) -
• 每个 commit 记录:被归档的消息范围(firstArchivedUuid → lastArchivedUuid)+ 摘要内容 -
• 每次 query 前, projectView()重放 commit log,动态生成折叠后的视图 -
• 使用独立的 ctx-agent(代号 marble_origami)生成摘要
触发阈值(双级):
-
• 90% 有效上下文窗口:将旧消息段入队(staged),暂不折叠 -
• 95% 有效上下文窗口:强制触发 ctx-agent 立即执行 staged 队列中的折叠 -
• API 413 恢复: recoverFromOverflow()可立即 drain 所有 staged 折叠并重试
与 Auto Compact 的关键区别:
Context Collapse: - 不修改原始消息数组 → 系统层面可恢复 - 可折叠部分消息段,保留其余 → 更精细 - 原始消息在 session 文件和 REPL 内存中完好保留Auto Compact: - 直接替换原始消息数组 → 不可逆 - 压缩几乎所有旧消息为一个摘要块 → 更粗放 - 原始消息永久丢失
互斥规则:当 Context Collapse 开启时,shouldAutoCompact() 直接返回 false——因为 Collapse 管理 90-95% 区间,Auto Compact 在 ~93% 触发,两者会竞争同一块上下文空间。
Feature flag:
CONTEXT_COLLAPSE(ANT-only,可通过CLAUDE_CONTEXT_COLLAPSE环境变量覆盖)源码位置:
src/services/contextCollapse/index.js(ANT-only,external builds 中被 DCE 移除)
6.3.4 Level 4 — Auto Compact 与 Reactive Compact(全量压缩)
当前三级都不足以将 token 控制在阈值内时,进入全量压缩。这里有两种模式:
模式 A — Auto Compact(主动式,公开功能)
在 API 调用前检测 token 超阈值并压缩:
tokenCount > effectiveWindow - 13K ? │ ├── Step A: trySessionMemoryCompaction() │ 前提: session summary 存在且有效 │ ① 读取 session memory 文件内容 │ ② truncateSessionMemoryForCompact │ (每 section ≤2000 tokens, 总计 ≤12000 tokens) │ ③ 用 lastSummarizedMessageId 确定边界 │ ④ 至少保留: │ - minTokens: 10,000 │ - minTextBlockMessages: 5 │ - maxTokens: 40,000 (上限) │ ⑤ 不拆分 tool_use/tool_result 对 │ 优势: 不需要额外 API 调用! │ ├── Step A 失败? → Step B: compactConversation() │ ├── Fork 子 Agent 生成对话摘要(≤20K tokens) │ ├── 替换所有旧消息(不可逆) │ ├── 恢复最多 5 个关键文件(≤5K/个) │ └── 恢复活跃 Skill 定义(≤25K) │ └── 后处理: ├── 重新加载 CLAUDE.md 指令 ├── 清除 System Prompt Section 缓存 ├── resetContextCollapse()(如果 Collapse 开启) └── 触发 PostCompact Hook 断路器: 连续失败 3 次 → 停止重试
模式 B — Reactive Compact(反应式,ANT-only)
在 API 调用后,收到 413 prompt_too_long 才压缩:
直接发送 API 请求(不提前压缩) │ ├── 成功 → 正常继续 │ └── 收到 413 prompt_too_long 或 media_size_error │ ├── "扣住"错误(withheld),不暴露给用户/SDK │ ├── 步骤 1: Context Collapse drain(如果开启) │ → 提交所有 staged 折叠,缩减投影视图 │ → 成功? → 标记 collapse_drain_retry, 重试请求 │ ├── 步骤 2: tryReactiveCompact() │ → Fork Agent 生成摘要(同 Auto Compact 的压缩基础设施) │ → 返回 CompactionResult → buildPostCompactMessages() │ → 标记 hasAttemptedReactiveCompact = true, 重试请求 │ → 额外能力:可处理媒体过大错误(剥离过大图片/PDF) │ └── 都失败 → 放弃,报错给用户 hasAttemptedReactiveCompact 防止无限重试循环
Reactive vs Auto 的核心差异:
-
• 最大化上下文利用:不提前压缩,让模型尽可能多看到完整对话 -
• 让服务端做最终判断:客户端 token 估算不一定准,API 知道真实限制 -
• 更精细的重试策略:Reactive 从尾部渐进剥离(”peels from the tail”),Auto 从头部粗暴砍 -
• 处理更多错误类型:还能处理 media_size_error(自动剥离过大图片/PDF 后重试)
当启用 Reactive-only 模式(GrowthBook tengu_cobalt_raccoon)时,shouldAutoCompact() 返回 false,完全禁用主动压缩,只靠 Reactive Compact 兜底。
Feature flag:
REACTIVE_COMPACT(ANT-only)源码位置:
src/services/compact/reactiveCompact.js(ANT-only,external builds 中被 DCE 移除)
6.3.5 各级协作关系
query() 每次调用的完整流程: ① Snip Compact → snipCompactIfNeeded(messages) → 裁剪最旧消息,记录 snipTokensFreed ↓ ② Micro Compact → microcompactMessages(messages) → 先尝试 Time-based(60min gap?)→ 否则 Cached(cache_edits API) ↓ ③ Context Collapse(如果开启) → applyCollapsesIfNeeded(messages) → 90% 入队,95% 执行/drain ↓ ④ Autocompact Gate → shouldAutoCompact(messages, snipTokensFreed) → 如果 Collapse 开启 → return false(不触发 autocompact) → 如果 Reactive-only 模式 → return false → 否则正常判断阈值 ↓ ⑤ API 调用 → 如果 413: → Collapse drain → Reactive Compact → 放弃
各级之间的关键协作规则:
-
• Snip + Micro 不互斥:两者处理不同层面——Snip 删消息,Micro 清工具结果内容 -
• Micro 在 Snip 之后:先删消息再清内容,避免对已删消息做无用清理 -
• Collapse 在 Micro 之后:Collapse 看到的已经是清理过工具结果的消息 -
• Collapse 抑制 Autocompact:Collapse 管 90-95% 区间,避免两者竞争同一块上下文空间 -
• Snip 的 tokensFreed 传给 Autocompact:让 autocompact 的阈值判断反映真实剩余空间 -
• Time-based MC 重置 Cached MC:缓存过期后 Cached MC 的状态作废,需重置 -
• Collapse 状态在 Autocompact 后被重置:因为旧的 commit log 引用的 UUID 已不存在
ANT-only 说明:ANT 是 Anthropic 的内部代号。标记为 ANT-only 的功能只在 Anthropic 内部构建中存在,通过
bun:bundle的feature()机制 +excluded-strings.txt做编译时 Dead Code Elimination(DCE),external builds 中连代码和字符串都不会出现。
External build(外部公开版本)中压缩流水线的实际可用情况:
级别 功能 External Build 中 Level 1 Snip Compact ✗ DCE 删除( HISTORY_SNIP)Level 2 路径 A Time-based Microcompact ✓ 可用 Level 2 路径 B Cached Microcompact ✗ DCE 删除( CACHED_MICROCOMPACT)Level 3 Context Collapse ✗ DCE 删除( CONTEXT_COLLAPSE)Level 4 SM Compact 取决于 GrowthBook 运行时 flag Level 4 Full Compact ✓ 始终可用 Level 4 Reactive Compact ✗ DCE 删除( REACTIVE_COMPACT)
也就是说,外部用户实际可用的压缩路径只有 Time-based Microcompact + SM Compact(如果服务端开启)+ Full Compact。 文档中标注为 ANT-only 的功能(Snip、Collapse、Reactive、Cached MC)在外部 build 中物理不存在。
6.3.6 Compact 后哪些上下文会被恢复
两种 Compact 路径在恢复”被压缩掉的上下文”时行为不完全相同:
|
|
|
|
|---|---|---|
|
|
processSessionStartHooks('compact') |
|
|
|
createPlanAttachmentIfNeeded() |
|
| Skill 定义 | ✓createSkillAttachmentIfNeeded() |
✗ 不调用 |
|
|
postCompactFileAttachments |
|
|
|
|
|
Skill 差异详解:
Skill 在对话中同时存在于两个地方:
-
1. 消息流中的 user message —— 当用户运行 /commit时,SKILL.md 内容作为 user message 注入 messages 数组(processSlashCommand.tsx第 885 行) -
2. STATE.invokedSkillsMap —— 同时调用addInvokedSkill(name, path, content, agentId)将完整内容存入内存状态
Full Compact 通过 createSkillAttachmentIfNeeded() 从 STATE.invokedSkills 读取,以 attachment 形式重新注入压缩后的消息流(按最近使用排序,每 Skill ≤5K tokens,总预算 25K tokens)。SM Compact 不做这一步:
// sessionMemoryCompact.ts 第 484-485 行const planAttachment = createPlanAttachmentIfNeeded(agentId)const attachments = planAttachment ? [planAttachment] : []// ← 没有 createSkillAttachmentIfNeeded()
三个缓解因素:
-
1. messagesToKeep覆盖近期 Skills:SM Compact 保留最近 10K-40K tokens 的消息。如果 Skill 在最近几轮调用,其 user message 在保留范围内,自然存活。 -
2. STATE.invokedSkills永不被清除:postCompactCleanup.ts明确注释 “We intentionally do NOT clear invoked skill content here”——数据留在内存,等下次 Full Compact 恢复。 -
3. SM Compact 失败 → fallback 到 Full Compact:压缩后 token 仍超标 → 返回 null → Full Compact 接手 → Skills 被恢复。
真正丢失 Skills 的场景——需同时满足:
-
• Skill 在较早时调用(不在 messagesToKeep窗口内) -
• SM Compact 成功(没有触发 fallback) -
• 后续没有再次触发 Full Compact
此时 Skill 的 SKILL.md 指令从消息流中消失。STATE.invokedSkills 仍持有内容,但没有机制将其重新注入。这是 SM Compact 相对于 Full Compact 的一个代码路径缺失。
Session Memory Compaction 的核心洞察:
Memory 不仅能帮助”回忆”,还能帮助”遗忘得更优雅”。如果系统已经维护了高质量的 session summary,那么用它来压缩比临时让 LLM 总结整段历史更稳定、更快、还更省钱(不需要额外 API 调用)。
6.4 Token 状态计算
// 源码位置: src/services/compact/autoCompact.tsfunction calculateTokenWarningState(tokenUsage: number, model: string) { const threshold = getAutoCompactThreshold(model) // e.g., 167_000 return { percentLeft: ((threshold - tokenUsage) / threshold) * 100, isAboveWarningThreshold: tokenUsage > threshold - 20_000, // > 147K isAboveErrorThreshold: tokenUsage > threshold - 20_000, // > 147K isAboveAutoCompactThreshold: tokenUsage > threshold, // > 167K isAtBlockingLimit: tokenUsage > actualContextWindow - 3_000, // > 197K }}
7. Hooks 系统:可编程的行为拦截器
7.1 什么是 Hooks
Hooks 允许你在 Claude Code 的关键时刻插入自定义逻辑——就像 Git Hooks,但更强大。
7.2 Hook 事件类型(25+)
┌────────────────────────────────────────────────────────────┐│ 工具相关 ││ ├── PreToolUse → 工具执行前(可阻止执行) ││ ├── PostToolUse → 工具执行后(可检查结果) ││ ├── PostToolUseFailure → 工具执行失败后 ││ ├── PermissionDenied → 权限被拒绝后 ││ └── PermissionRequest → 权限请求时 ││ ││ 会话生命周期 ││ ├── UserPromptSubmit → 用户提交消息时 ││ ├── SessionStart → 会话开始 ││ ├── SessionEnd → 会话结束 ││ ├── Stop → 模型完成回答 ││ └── StopFailure → Stop hook 自己失败了 ││ ││ 压缩相关 ││ ├── PreCompact → 压缩前 ││ └── PostCompact → 压缩后 ││ ││ 子 Agent 相关 ││ ├── SubagentStart → 子 Agent 启动 ││ └── SubagentStop → 子 Agent 停止 ││ ││ 配置变更 ││ ├── ConfigChange → 配置变更 ││ ├── CwdChanged → 工作目录变更 ││ └── FileChanged → 文件变更 │└────────────────────────────────────────────────────────────┘
7.3 Hook 配置示例
// ~/.claude/settings.json{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "echo '工具 $TOOL_NAME 即将执行: $TOOL_INPUT' >> ~/claude-audit.log" } ] } ], "Stop": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "notify-send 'Claude 完成了回答'" } ] } ] }}
7.4 Hook 执行机制
Hook 触发 │ ▼按来源排序(优先级从低到高): 1. userSettings(全局) 2. projectSettings(项目共享) 3. localSettings(本地私有) 4. policySettings(企业管理) 5. pluginHooks(插件注册) 6. sessionHooks(会话临时) │ ▼执行 Hook(有超时保护,默认10分钟) │ ├── 退出码 0 → 成功 ├── 退出码 2 → 阻塞性错误(可能阻止工具执行) └── 其他退出码 → 非阻塞性警告
7.5 四种 Hook 类型
|
|
|
|
|---|---|---|
| command |
|
"command": "eslint --fix" |
| prompt |
|
"prompt": "检查 $ARGUMENTS 是否安全" |
| agent |
|
|
| http |
|
"url": "https://audit.example.com/hook" |
8. CLAUDE.md 机制:项目级指令注入
8.1 加载优先级(从低到高)
/etc/claude-code/CLAUDE.md ← 企业管理级(全局所有用户)~/.claude/CLAUDE.md ← 用户级(你的全局偏好)项目根目录/CLAUDE.md ← 项目级(团队共享,可 commit)项目根目录/.claude/CLAUDE.md ← 项目级(隐藏目录版本)项目根目录/.claude/rules/*.md ← 项目规则目录(所有 .md 文件)项目根目录/CLAUDE.local.md ← 本地级(gitignore,个人偏好)
后加载的会覆盖先加载的(优先级更高)。
8.2 @include 指令
CLAUDE.md 支持引用其他文件:
# 我的项目规则@./docs/coding-standards.md ← 相对路径@~/global-rules.md ← Home 目录@/etc/shared-rules.md ← 绝对路径## 编码规范始终使用 TypeScript strict 模式...
支持 100+ 种文件类型(.md, .txt, .json, .yaml, .py, .js, .ts, .go, .rs 等)。
8.3 注入方式
CLAUDE.md 的内容不在 System Prompt 中,而是作为对话的第一条 User Message 注入:
{ "role": "user", "content": [ { "type": "text", "text": "<system-reminder>\nAs you answer the user's questions, you can use the following context:\n# claudeMd\n[CLAUDE.md 完整内容]\n\n# currentDate\nToday's date is 2026-03-31.\n\n IMPORTANT: this context may or may not be relevant to your tasks.\n</system-reminder>" } ]}
8.4 InstructionsLoaded Hook
当 CLAUDE.md 被加载时,会触发 Hook:
{ "event": "InstructionsLoaded", "metadata": { "file_path": "/home/user/project/CLAUDE.md", "memory_type": "Project", "load_reason": "session_start" }}
load_reason 可能是:
-
• session_start— 会话开始 -
• nested_traversal— 遍历子目录发现的 -
• path_glob_match— paths: 模式匹配 -
• include— 被 @include 引用 -
• compact— 压缩后重新加载
9. 完整 Prompt 拼接示例
下面是一个完整的、端到端的示例,展示当用户输入 “帮我看看 auth 模块的 bug” 时,Claude Code 实际发送给 API 的请求是什么样的。
9.1 发送给 Claude API 的完整请求结构
{ "model": "claude-sonnet-4-6-20260301", "max_tokens": 16384, "stream":true, "system": [ // ═══ Block 1: 计费头(不缓存)═══ { "type": "text", "text": "x-anthropic-billing-header: claude-code-v1", "cache_control":null }, // ═══ Block 2: 静态内容(全局缓存)═══ { "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude.\nYou are an interactive agent that helps users with software engineering tasks...\n\n# System\n - All text you output outside of tool use is displayed to the user...\n\n# Doing tasks\n - The user will primarily request you to perform software engineering tasks...\n - In general, do not propose changes to code you haven't read...\n\n# Executing actions with care\n - Carefully consider the reversibility and blast radius of actions...\n\n# Using your tools\n - Do NOT use the Bash to run commands when a relevant dedicated tool is provided...\n - Use the Agent tool with specialized agents when...\n\n# Tone and style\n - Only use emojis if the user explicitly requests it...\n - Your responses should be short and concise...", "cache_control": { "scope": "global" } }, // ═══ Block 3: 动态内容(不缓存,每次可能不同)═══ { "type": "text", "text": "# auto memory\n\nYou have a persistent auto memory directory at `/root/.claude/projects/-home-user-myproject/memory/`. This directory already exists — write to it directly with the Write tool.\n\nAs you work, consult your memory files to build on previous experience.\n\n## How to save memories:\n- Organize memory semantically by topic, not chronologically\n- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated\n...\n\n## Current MEMORY.md contents:\n- [用户是高级后端工程师](user_role.md) — Go专家\n- [不要mock数据库](feedback_testing.md)\n\n---\n\n# Environment\nYou have been invoked in the following environment:\n - Primary working directory: /home/user/myproject\n - Is a git repository: true\n - Platform: linux\n - Shell: bash\n - OS Version: Linux 6.1.0\n - You are powered by claude-sonnet-4-6\n\nAssistant knowledge cutoff is January 2025.\n\n---\n\n# Available Skills\n- commit, review-pr, simplify, ...\n\n# System Context\ngitStatus: On branch main, 2 files changed", "cache_control":null } ], "messages": [ // ═══ Message 0: CLAUDE.md 作为 User Context 注入 ═══ { "role": "user", "content": "<system-reminder>\nAs you answer the user's questions, you can use the following context:\n# claudeMd\n\n# Project Rules\n\nThis is a Go + React monorepo.\n\n## Backend\n- Use Go 1.21+\n- All handlers must have integration tests\n\n## Frontend\n- Use React 18 with TypeScript strict\n- Use pnpm, not npm\n\n# currentDate\nToday's date is 2026-03-31.\n\n IMPORTANT: this context may or may not be relevant to your tasks.\n</system-reminder>" }, // ═══ Message 1: 用户的实际输入 ═══ { "role": "user", "content": "帮我看看 auth 模块的 bug" } ], "tools": [ { "name": "Read", "description": "Reads a file from the local filesystem...", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" }, "offset": { "type": "number" }, "limit": { "type": "number" } }, "required": ["file_path"] } }, { "name": "Bash", "description": "Executes a given bash command...", "input_schema": { "..." } }, { "name": "Edit", "description": "Performs exact string replacements in files...", "input_schema": { "..." } } // ... 30+ 个工具定义 ]}
9.2 模型返回后的循环示例
Turn 1: 用户: "帮我看看 auth 模块的 bug" 模型返回: text: "让我先查看 auth 相关的文件。" tool_use: Grep(pattern="auth", path="src/") tool_use: Glob(pattern="**/auth*") → 并发执行两个工具 → 收集结果 → 追加到消息历史 → 继续循环(有工具调用)Turn 2: 消息历史: [用户输入, 模型text+工具调用, Grep结果, Glob结果] 模型返回: text: "找到了几个 auth 文件,让我读取核心的那个。" tool_use: Read(file_path="src/auth/middleware.go") → 执行 Read → 继续循环Turn 3: 消息历史: [..., Read结果] 模型返回: text: "我发现了问题。在 middleware.go 的第 42 行..." (无工具调用) → 没有工具调用 → 准备退出循环 → 执行 Stop Hooks → 后台触发 extractMemories(如果有值得记住的信息) → 返回给用户
9.3 记忆提取的实际 Prompt 与完整机制
External build 说明:extractMemories(路径 B)受编译时
EXTRACT_MEMORIESflag 门控,在 external build 中被 DCE 删除。以下分析基于完整源码。
9.3.1 触发条件(5 道门)
每次 query loop 结束时,handleStopHooks 中的 executeExtractMemories() 依次检查:
stopHooks 触发 │ ├── ① 是主 Agent?(subagent 不提取) ├── ② tengu_passport_quail 运行时 flag = true? ├── ③ isAutoMemoryEnabled() = true?(检查环境变量 + settings.json) ├── ④ 不是 remote 模式? └── ⑤ 没有正在进行的提取?(overlap guard) │ 全部通过 → 进入节流检查
源码位置:
extractMemories.ts第 527-567 行
9.3.2 节流与互斥
节流(tengu_bramble_lintel):内部计数器 turnsSinceLastExtraction 每轮 +1,达到配置值(默认 1 = 每轮都执行)才真正执行。trailing run(补跑)跳过节流。
互斥(hasMemoryWritesSince()):扫描上次游标之后的 assistant 消息,如果发现有 Edit/Write 工具调用目标是 auto-memory 路径,说明主模型已经直接写过 memory 了(路径 A),就跳过 extractMemories(路径 B),只推进游标。
节流 + 互斥检查 │ ├── turnsSinceLastExtraction < N → 跳过 ├── 主模型已写过 memory → 跳过 + 推进游标 └── 通过 → 计算 newMessageCount → 构建 prompt → fork
9.3.3 Prompt 的完整结构
fork 子 Agent 收到的消息由两部分拼接:
initialMessages = [...forkContextMessages, ...promptMessages] ↑ 当前完整 messages 数组 ↑ 一条 user message
forkContextMessages:等于 context.messages,即当前完整的消息数组(如果发生过 compact,就是 compact 后的版本)。这样设计是为了复用 prompt cache——fork agent 的 system prompt、tools、model、message 前缀和父对话完全一致,API 请求可以命中缓存。
promptMessages:一条 user message,结构如下(源码 prompts.ts 第 29-44 行 opener() 函数):
You are now acting as the memory extraction subagent. Analyze themost recent ~{newMessageCount} messages above and use them to updateyour persistent memory systems.Available tools: FileRead, Grep, Glob, read-only Bash (ls/find/cat/stat/wc/head/tail and similar), and FileEdit/FileWrite for pathsinside the memory directory only. Bash rm is not permitted. All othertools — MCP, Agent, write-capable Bash, etc — will be denied.You have a limited turn budget. FileEdit requires a prior FileRead ofthe same file, so the efficient strategy is: turn 1 — issue allFileRead calls in parallel for every file you might update; turn 2 —issue all FileWrite/FileEdit calls in parallel. Do not interleavereads and writes across multiple turns.You MUST only use content from the last ~{newMessageCount} messages toupdate your persistent memories. Do not waste any turns attempting toinvestigate or verify that content further — no grepping source files,no reading code to confirm a pattern exists, no git commands.## Existing memory files- [feedback] user_prefers_terse.md (2026-03-28T10:00:00.000Z): ...- [project] auth_module_notes.md (2026-03-25T...): ...## Memory types<type> <name>user</name> <description>Personal information about the user...</description> <when_to_save>When you learn something about the user...</when_to_save> <how_to_use>Check before responding to questions about...</how_to_use> <examples>...</examples></type>[feedback / project / reference 同理,每种都有完整的 XML 块]## What not to save- Code patterns, conventions, architecture, file paths, project structure- Git history, recent changes, who-changed-what- Debugging solutions or fix recipes- Anything already in CLAUDE.md files- Ephemeral task details## How to saveStep 1: Write each memory to its own file (with frontmatter)Step 2: Add pointer to MEMORY.md index (if tengu_moth_copse flag off)
源码位置:
prompts.ts第 29-94 行(opener+buildExtractAutoOnlyPrompt),记忆类型定义在memoryTypes.ts第 14-195 行
9.3.4 newMessageCount 是动态计算的
prompt 中的 ~{newMessageCount}不是固定的 5,而是由游标动态决定的:
// extractMemories.ts 第 340-343 行const newMessageCount = countModelVisibleMessagesSince( messages, lastMemoryMessageUuid, // 上次提取成功时记录的游标)
isModelVisibleMessage 只计 user 和 assistant 类型(排除 system、progress、attachment 等)。
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
9.3.5 Compact 后的容错
如果在两次提取之间发生了 compact,lastMemoryMessageUuid 指向的消息可能被删了:
// extractMemories.ts 第 82-110 行function countModelVisibleMessagesSince( messages: Message[], sinceUuid: string | undefined,): number { if (sinceUuid === null || sinceUuid === undefined) { return count(messages, isModelVisibleMessage) // 计所有 } let foundStart = false let n = 0 for (const message of messages) { if (!foundStart) { if (message.uuid === sinceUuid) { foundStart = true } continue } if (isModelVisibleMessage(message)) { n++ } } // 游标 UUID 没找到(被 compact 删了)→ 回退到计所有 // 而不是返回 0(那会永久禁用本 session 的提取) if (!foundStart) { return count(messages, isModelVisibleMessage) } return n}
回退意味着什么:fork agent 看到的 forkContextMessages 是 compact 后的消息数组(摘要 + 保留的尾部消息),newMessageCount 等于这些消息中所有 user + assistant 的数量。prompt 会告诉子 Agent “分析上方最近 ~N 条消息”——N 可能很大,但子 Agent 的 maxTurns 仍是 5,实际能处理的信息量有硬上限。
9.3.6 游标推进与错误恢复
|
|
|
|---|---|
|
|
messages.at(-1)?.uuid |
|
|
|
|
|
不推进
|
|
|
pendingContext,当前提取结束后立即启动 trailing run |
9.3.7 子 Agent 的行为模式
子 Agent 有严格受限的工具权限(createAutoMemCanUseTool(memoryRoot))和 maxTurns=5 的硬上限:
-
1. 读取现有 memory 文件(并行 Read) -
2. 分析对话中是否有值得记住的新信息 -
3. 如果有 → 创建或更新 memory 文件(并行 Write/Edit) -
4. 如果没有 → 什么都不做(完全正常) -
5. 写入完成后,主对话中会出现一条系统消息 “Saved N memories” 通知用户
10. 关键源码文件索引
10.1 核心运行时
|
|
|
|
|---|---|---|
src/main.tsx |
|
|
src/QueryEngine.ts |
|
|
src/query.ts |
|
|
src/Tool.ts |
|
|
src/tools.ts |
|
|
src/tools/AgentTool/AgentTool.tsx |
|
|
src/tools/AgentTool/prompt.ts |
|
|
src/tools/AgentTool/builtInAgents.ts |
|
|
src/tools/AgentTool/forkSubagent.ts |
|
|
src/context.ts |
|
|
src/replLauncher.tsx |
|
|
10.2 Prompt 系统
|
|
|
|
|---|---|---|
src/constants/prompts.ts |
|
|
src/constants/systemPromptSections.ts |
|
|
src/utils/api.ts |
|
|
src/utils/systemPrompt.ts |
|
|
10.3 Memory 系统
|
|
|
|
|---|---|---|
src/memdir/memdir.ts |
|
|
src/memdir/paths.ts |
|
|
src/memdir/memoryTypes.ts |
|
|
src/memdir/memoryScan.ts |
|
|
src/memdir/memoryAge.ts |
|
|
src/memdir/findRelevantMemories.ts |
|
|
src/memdir/teamMemPaths.ts |
|
|
src/services/extractMemories/extractMemories.ts |
|
|
src/services/extractMemories/prompts.ts |
|
|
src/services/SessionMemory/sessionMemory.ts |
|
|
src/services/SessionMemory/prompts.ts |
|
|
src/services/autoDream/autoDream.ts |
|
|
src/services/autoDream/consolidationPrompt.ts |
|
|
src/services/autoDream/consolidationLock.ts |
|
|
src/services/teamMemorySync/index.ts |
|
|
src/services/teamMemorySync/secretScanner.ts |
|
|
src/services/teamMemorySync/watcher.ts |
|
|
src/tools/AgentTool/agentMemory.ts |
|
|
src/tools/AgentTool/loadAgentsDir.ts |
|
|
src/commands/memory/memory.tsx |
|
|
src/components/memory/MemoryFileSelector.tsx |
|
|
src/components/memory/MemoryUpdateNotification.tsx |
|
|
10.4 Context Window 管理
|
|
|
|
|---|---|---|
src/services/compact/autoCompact.ts |
|
|
src/services/compact/compact.ts |
|
|
src/services/compact/microCompact.ts |
|
|
src/services/compact/timeBasedMCConfig.ts |
|
|
src/services/compact/sessionMemoryCompact.ts |
|
|
src/services/compact/postCompactCleanup.ts |
|
|
src/services/compact/snipCompact.ts |
|
|
src/services/compact/reactiveCompact.js |
|
|
src/services/contextCollapse/index.js |
|
|
src/query/stopHooks.ts |
|
|
10.5 Hooks 与设置
|
|
|
|
|---|---|---|
src/utils/hooks/hooksConfigManager.ts |
|
|
src/utils/hooks/hooksSettings.ts |
|
|
src/utils/settings/settings.ts |
|
|
src/utils/claudemd.ts |
|
|
src/schemas/hooks.ts |
|
|
附录 A:Feature Flags(功能开关)
Claude Code 大量使用 GrowthBook 进行功能开关控制:
|
|
|
|---|---|
tengu_session_memory |
|
tengu_sm_config |
|
tengu_sm_compact_config |
|
tengu_passport_quail |
|
tengu_bramble_lintel |
|
tengu_moth_copse |
|
tengu_coral_fern |
|
tengu_cobalt_raccoon |
|
tengu_onyx_plover |
|
tengu_herring_clock |
|
tengu_slate_heron |
|
EXTRACT_MEMORIES |
|
HISTORY_SNIP |
|
CACHED_MICROCOMPACT |
|
CONTEXT_COLLAPSE |
|
REACTIVE_COMPACT |
|
KAIROS |
|
TEAMMEM |
|
附录 B:环境变量
|
|
|
|---|---|
CLAUDE_CODE_DISABLE_AUTO_MEMORY |
|
CLAUDE_CODE_SIMPLE |
|
CLAUDE_CODE_REMOTE |
|
CLAUDE_CODE_REMOTE_MEMORY_DIR |
|
CLAUDE_COWORK_MEMORY_PATH_OVERRIDE |
|
CLAUDE_CONFIG_DIR |
|
CLAUDE_CODE_AUTO_COMPACT_WINDOW |
|
CLAUDE_CONTEXT_COLLAPSE |
|
附录 C:完整的记忆文件格式参考
记忆文件(user_role.md)
---name: 用户是高级后端工程师description: 用户是一名有10年Go经验的高级工程师,第一次接触React前端type: user---用户是一名高级后端工程师,深耕Go语言十年。目前第一次接触项目的React前端部分。解释前端概念时,应该用后端类比来帮助理解。比如把组件生命周期类比为请求处理中间件链。
索引文件(MEMORY.md)
- [用户是高级后端工程师](user_role.md) — Go专家,React新手,用后端类比- [测试必须用真实数据库](feedback_testing.md) — 不要mock,曾因此出事故- [Auth中间件重写](project_auth_rewrite.md) — 法务合规驱动- [Linear项目INGEST](reference_linear.md) — pipeline bug 追踪
反馈记忆文件(feedback_testing.md)
---name: 集成测试必须使用真实数据库description: 不要在集成测试中mock数据库,团队曾因mock测试通过但生产迁移失败而受损type: feedback---集成测试必须使用真实数据库连接,不要mock。**Why:** 上个季度发生过一次事故——mock测试全部通过,但实际的生产数据库迁移失败了。Mock和真实数据库的行为差异掩盖了一个破坏性的迁移bug。**How to apply:** 所有涉及数据库操作的测试文件中,使用 TestDB helper 连接测试数据库实例,不要使用 mockDB 或 interface mock。
附录 D:术语表
|
|
|
|---|---|
| Turn |
|
| Query |
|
| System Prompt |
system 参数,定义模型行为 |
| User Context |
|
| System Context |
|
| Compact |
|
| Snip |
|
| Fork |
|
| Stop Hook |
|
| Cursor |
|
| Gate |
|
| Fire-and-forget |
|
| Circuit Breaker |
|
| Prompt Caching |
|
| system-reminder |
|
| AutoDream |
|
| Consolidation |
|
| Team Memory |
|
| Agent Memory |
|
| Scope |
|
| sideQuery |
|
往期推荐
Claude Code 源码逆向工程与系统性分析!Harness Engineering: 基于 Claude Code 的完全指南
加入Claude code源码交流群

都看到这了,点个关注再走吧🧐~
夜雨聆风
