Claude Code 源码分析第 40 课 · 第 03
第 40 课

自动记忆与 Dreams

Claude Code 如何默默地从每次训练中提取记忆,并在您睡觉时巩固它们。

01 Overview

Claude Code 有两个后台进程与大多数用户从未见过的每个对话一起运行。 第一个运行在 每个查询的结尾:分叉的子代理扫描记录并将持久事实写入磁盘。 第二次运行 跨会话:整合代理在足够的时间和会话积累后醒来,读取内存文件,并将它们重写为更紧凑的内容。 它们一起形成了两层内存生命周期——提取和整合——在 services/extractMemories/ and services/autoDream/.

覆盖源文件
services/extractMemories/extractMemories.ts · services/extractMemories/prompts.ts · services/autoDream/autoDream.ts · services/autoDream/config.ts · services/autoDream/consolidationLock.ts · services/autoDream/consolidationPrompt.ts · memdir/memdir.ts · memdir/memoryTypes.ts · memdir/paths.ts · memdir/memoryScan.ts · memdir/findRelevantMemories.ts · memdir/memoryAge.ts
第 1 层 — 每回合

提取记忆

每次最终响应后,分叉代理都会审查新消息并将主题文件写入磁盘。

第 2 层 — 跨会话

汽车梦

经过 24 小时 + 5 个会话后,整合代理会合并、修剪内存目录并重新索引。

在查询时

相关召回

Sonnet 侧查询最多选择 5 个与当前查询匹配的主题文件并注入它们。

02 内存目录

所有持久内存都位于由以下计算的路径下 getAutoMemPath() in memdir/paths.ts。 决议顺序为:

// memdir/paths.ts — resolution order (first defined wins)
1. CLAUDE_COWORK_MEMORY_PATH_OVERRIDE  // env var — Cowork space-scoped mount
2. getSettingsForSource('localSettings').autoMemoryDirectory  // settings.json
3. `~/.claude/projects/<sanitized-git-root>/memory/`  // default

路径是 键入规范的 git root — 同一存储库的所有工作树共享一个内存目录。 该函数被记忆在 getProjectRoot() 因为渲染路径调用者会触发每个工具使用的重新渲染和底层逻辑(四个 getSettingsForSource 每个调用都涉及 realpathSync)不是免费的。

安全说明
projectSettings (committed .claude/settings.json)被有意排除在外 autoMemoryDirectory 覆盖。 否则,恶意存储库可能会默默地将 Claude 的写入重定向到 ~/.ssh.

目录结构

~/.claude/projects/<slug>/memory/
  MEMORY.md            # index — always injected into system prompt
  user_role.md         # topic files — one memory per file
  feedback_testing.md
  project_auth.md
  .consolidate-lock    # mtime == lastConsolidatedAt
  logs/                 # KAIROS/assistant-mode only: append-only daily logs
    2026/03/2026-03-31.md

MEMORY.mdindex。 它始终加载到系统提示符中(截断为 200 行/25 KB)。 每行都是一个短指针: - [Title](file.md) — one-line hook。 实际内容位于主题文件中。

03 四种记忆类型

记忆仅限于定义的封闭分类法 memdir/memoryTypes.ts。 分类法的存在是为了防止内容 derivable 从当前项目状态来看,由于污染存储,代码模式、架构和 git 历史记录被明确排除。

类型:用户

用户资料

角色、目标、知识水平、沟通偏好。 帮助克劳德针对这个特定的人定制未来的回应。

类型:反馈

行为指导

更正和确认。 存储规则 + Why: + 如何申请: 所以可以判断边缘情况,而不是盲目跟随。

类型: 项目

项目背景

正在进行的工作、截止日期、事件。 无法从代码或 git 派生。 快速衰退——“为什么”行告诉未来的你,记忆是否仍然可以承受。

类型:参考

外部指针

在外部系统中查找信息的位置:线性项目、Grafana 仪表板、Slack 通道。

每个主题文件都使用 YAML frontmatter:

---
name: feedback_no_db_mocks
description: Integration tests must use a real DB — mocks masked a migration failure
type: feedback
---

# Don't mock the database in integration tests

**Why:** Prior incident where mock/prod divergence let a broken migration slip to production.
**How to apply:** Any test that exercises a DB query path must use a real (test) database.
什么不该保存
代码模式、架构、文件路径、git 历史记录、调试修复方法或任何已有内容 CLAUDE.md。 即使用户明确要求,这些排除也适用。 理由:代码始终是实时可读的; 对它的记忆会变得陈旧并产生虚假的权威。
04 第一层——提取记忆

在每个查询循环结束时(当模型在没有工具调用的情况下生成最终响应时), handleStopHooks fires executeExtractMemories() from services/extractMemories/extractMemories.ts。 它使用 分叉代理模式 — 共享父级提示缓存的主要对话的完美副本。

闭包范围状态

所有可变状态都存在于内部 initExtractMemories() — 不在模块级别。 这与使用的模式相同 confidenceRating.ts。 测试通话 initExtractMemories() in beforeEach 以获得新的关闭。 关键的状态变量是:

let lastMemoryMessageUuid: string | undefined  // cursor — only new messages since last run
let inProgress: boolean                          // overlap guard
let pendingContext: ...                           // stash for trailing run
let turnsSinceLastExtraction: number             // throttle counter

重叠合并

如果在新一轮完成时提取已经在运行,则新上下文将存储在 pendingContext。 当正在运行的提取完成时,它会使用隐藏的上下文触发一次尾随运行 - 因此不会丢失任何提取,但运行绝不会重叠。 只有 latest 隐藏的上下文很重要(它具有最多的消息),因此重复合并会覆盖隐藏的内容。

与主代理互斥

主代理的系统提示始终包含完整的内存保存指令。 当主代理写入内存路径本身时(通过检测到 hasMemoryWritesSince()),分叉提取被完全跳过——将光标​​推进到该范围之外,这样两条路径就不会重复写入同一回合。

// extractMemories.ts — mutual exclusion check
if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
  // advance cursor, log event, return early
  logEvent('tengu_extract_memories_skipped_direct_write', { message_count })
  return
}

萃取流程

flowchart TD A["handleStopHooks fires\nexecuteExtractMemories()"] --> B{"inProgress?"} B -->|"yes"| C["Stash context in pendingContext\nlogEvent coalesced"] B -->|"no"| D{"hasMemoryWritesSince\n(main agent wrote)?"} D -->|"yes"| E["Advance cursor\nSkip fork — main agent handled it"] D -->|"no"| F["scanMemoryFiles()\nBuild manifest of existing files"] F --> G["buildExtractAutoOnlyPrompt()\nor buildExtractCombinedPrompt()"] G --> H["runForkedAgent()\nmaxTurns=5, skipTranscript=true"] H --> I["Turn 1: parallel Read calls\nfor files to update"] I --> J["Turn 2: parallel Write/Edit calls"] J --> K["Advance lastMemoryMessageUuid cursor"] K --> L["extractWrittenPaths() from messages"] L --> M{"pendingContext\nstashed?"} M -->|"yes"| N["Run trailing extraction\n(isTrailingRun=true, skip throttle)"] M -->|"no"| O["appendSystemMessage:\n'Saved N memories'"]

分叉代理的工具权限

createAutoMemCanUseTool() 返回 extractMemories 和 autoDream 共享的权限函数。 它强制执行严格的允许列表:

// extractMemories.ts — createAutoMemCanUseTool()
if (tool.name === FILE_READ_TOOL_NAME || GREP || GLOB) return allow()
if (tool.name === BASH_TOOL_NAME && tool.isReadOnly(parsed.data)) return allow()
if ((EDIT || WRITE) && isAutoMemPath(input.file_path)) return allow()
return denyAutoMemTool(tool, reason) // logs + fires analytics event

提取提示

发送到分叉代理的提示(来自 prompts.ts)有一个刻意的效率指令,直接映射到转弯预算:

及时设计——两回合策略
“第 1 课 回合 - 对您可能更新的每个文件并行发出所有读取调用。第 2 课 回合 - 并行发出所有写入/编辑调用。不要在多个回合中交错读取和写入。” 这种对交错的硬性限制就是为什么 maxTurns: 5 对于表现良好的运行来说已经足够了。

该提示还预先注入内存目录清单(来自 scanMemoryFiles())所以代理不会花费一个回合 ls。 清单格式:

// memoryScan.ts — formatMemoryManifest()
// Output: one line per file
"- [feedback] feedback_no_db_mocks.md (2026-03-30T12:00:00Z): Integration tests must use a real DB"
05 第 2 层 — Auto Dream(整合)

梦想系统运行 相同的分叉代理模式 但节奏要长得多。 它在查询循环结束时触发(通过 executeAutoDream())仅当三个门按顺序通过时,最便宜的首先:

1 号登机口 — 最便宜

时间之门

小时以来 lastConsolidatedAtminHours (默认值:24)。 费用:一 stat() 每回合呼叫。

2号门

会话门

使用 mtime 转录文件 > lastConsolidatedAtminSessions (默认值:5)。 成本:一次目录扫描,限制为每 10 分钟一次。

3号门

锁门

中间合并没有其他过程。 通过 PID 文件实现,其 mtime 是时间戳.

锁文件设计

锁定文件是 .consolidate-lock 内存目录里面。 它的设计优雅:主体是持有者的PID; 这 文件 IS 的 mtime lastConsolidatedAt。 这意味着阅读“我们上次整合是什么时候?” 成本正好是一 stat().

// consolidationLock.ts — acquire sequence
const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
// If holder PID is still running AND lock isn't stale (>1h) → bail
if (isProcessRunning(holderPid)) return null
// Otherwise reclaim: write our PID, verify we won the race
await writeFile(path, String(process.pid))
const verify = await readFile(path, 'utf8')
if (parseInt(verify) !== process.pid) return null // lost the race

失败时, rollbackConsolidationLock(priorMtime) 通过倒回时间 utimes(),恢复预获取时间戳。 崩溃时,可以检测到失效的 PID,并且下一个进程会回收锁。

整合提示——四个阶段

梦境提示(来自 consolidationPrompt.ts)将工作分为四个阶段。 该代理作为分叉代理运行,具有相同的 createAutoMemCanUseTool() permissions:

第一阶段

Orient

ls 内存目录,读取 MEMORY.md,浏览主题文件以避免创建重复项。

第二阶段

收集最近的信号

检查每日日志(KAIROS 模式),狭义地查找特定上下文的 grep 记录。 成绩单是大型 JSONL — 切勿读取整个文件。

第三阶段

Consolidate

编写或更新主题文件。 合并近似重复项。 将相对日期(“昨天”)转换为绝对日期,以便记忆保持可解释性。

第四阶段

修剪和索引

Update MEMORY.md — 将其控制在 200 行/25 KB 以下。 删除陈旧的指针。 将详细索引行降级为主题文件。

梦想进度追踪

与每轮提取器不同,梦想代理注册一个 DreamTask 在应用程序状态下,允许 UI 显示实时进度。 makeDreamProgressWatcher() 从 fork 流式传输每条助理消息,提取文本块以供显示,并从编辑/写入工具调用中收集文件路径以获取完成摘要。

// autoDream.ts — progress watcher
function makeDreamProgressWatcher(taskId, setAppState) {
  return msg => {
    for (const block of msg.message.content) {
      if (block.type === 'text') text += block.text
      if (block.type === 'tool_use') toolUseCount++
      if (EDIT || WRITE) touchedPaths.push(input.file_path)
    }
    addDreamTurn(taskId, { text, toolUseCount }, touchedPaths, setAppState)
  }
}
06 回忆——findRelevantMemories

在查询时,Claude Code 不会将所有内存文件注入到上下文中,这会导致提示中出现陈旧或不相关的事实。 反而, findRelevantMemories() in memdir/findRelevantMemories.ts 使用十四行诗 side-query 选择最多五个与当前查询相关的主题文件。

两步召回流程

// findRelevantMemories.ts
const memories = (await scanMemoryFiles(memoryDir, signal))
  .filter(m => !alreadySurfaced.has(m.filePath))

const selectedFilenames = await selectRelevantMemories(
  query, memories, signal, recentTools
)
// sideQuery → Sonnet, max_tokens: 256, JSON schema output
// Returns: { selected_memories: string[] }

选择器系统提示有一个值得注意的例外:如果模型是 积极使用 一个工具(例如 mcp__X__spawn 是在 recentTools),该工具的参考文档记忆被抑制 - 对话已经包含工作用法,并且显示文档是噪音。 关于这些工具的警告和陷阱仍然浮出水面。

陈旧信号

memdir/memoryAge.ts 计算人类可读的年龄字符串,并在内存文件超过一天时注入过时警告:

// memoryAge.ts — freshnessText for memories > 1 day old
"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."
漂移警告(来自 memoryTypes.ts)
系统提示包括:“命名特定函数、文件或标志的内存是其存在的声明 当记忆被写入时。 它可能已被重命名、删除或从未合并。 推荐之前:检查文件是否存在; grep 查找该函数。”节标题是“在根据记忆进行推荐之前”——比“相信你记得的内容”评估得更好,因为它在决策点而不是抽象地触发。
07 完整的内存生命周期
flowchart LR subgraph "During conversation" A["User query"] --> B["Main agent responds"] B --> C["handleStopHooks"] C --> D["executeExtractMemories\n(fire-and-forget)"] C --> E["executeAutoDream\n(fire-and-forget)"] end subgraph "Extract Memories fork (maxTurns=5)" D --> F["countModelVisibleMessagesSince\ncursor check"] F --> G["scanMemoryFiles → manifest"] G --> H["runForkedAgent\nbuildExtractAutoOnlyPrompt"] H --> I["Read existing files\n(turn 1, parallel)"] I --> J["Write/Edit topic files\n(turn 2, parallel)"] J --> K["Advance cursor\nappendSystemMessage"] end subgraph "Auto Dream fork (conditional)" E --> L{"Time gate\n≥24h?"} L -->|"no"| M["Return"] L -->|"yes"| N{"Session gate\n≥5 sessions?"} N -->|"no"| M N -->|"yes"| O{"Lock\navailable?"} O -->|"no"| M O -->|"yes"| P["runForkedAgent\nbuildConsolidationPrompt\n4-phase dream"] P --> Q["completeDreamTask\nappendSystemMessage 'Improved N memories'"] end subgraph "Memory directory" K --> R[("~/.claude/projects/slug/memory/\nMEMORY.md + topic files")] Q --> R end subgraph "At query time" S["New user query"] --> T["findRelevantMemories\nscanMemoryFiles → Sonnet selector"] R --> T T --> U["Inject up to 5 topic files\n+ staleness caveat if >1 day"] U --> S end
08 团队记忆(TEAMMEM 标志)

TEAMMEM 启用功能标志后,内存系统将在私有目录旁边获得第二个目录。 组合模式使用 buildExtractCombinedPrompt() 这增加了 <scope> 每个内存类型块的标签。 路由指南被纳入每个类型定义中,而不是单独的路由部分。

每种类型的范围规则
  • user - 始终是私人的。 个人资料不应被共享。
  • feedback — 默认私有; 仅当指导是项目范围的惯例(测试策略、构建不变性)而不是个人风格偏好时才团队。
  • project ——私人或团队,强烈偏向团队。 共享工作背后的上下文属于共享存储。
  • reference ——通常是团队。 外部系统指针对所有协作者都有用。

敏感数据(API 密钥、凭据)绝不能保存到团队内存中 - 组合提取提示包含明确的禁止。

09 KAIROS / 助手模式

在长期助理会话中(功能标志 KAIROS),内存模型从实时索引转变为 仅附加每日日志。 代理写信至 logs/YYYY/MM/YYYY-MM-DD.md 因为它有效,而不是维护 MEMORY.md 直接地。 单独的每晚 /dream 技能将日志提取到主题文件并更新 MEMORY.md.

日志路径模式存储在提示中,没有今天的文字日期 - 提示由以下方式缓存 systemPromptSection('memory', ...) 并且不得在午夜展期时失效。 该模型从以下数据中得出当前日期 date_change 午夜到来时附加的附件,而不是来自提示本身。

10 缓存效率

选择分叉代理模式正是因为它 共享父级的提示缓存。 分叉代理获得与主对话相同的系统提示和消息历史前缀,因此这些令牌已经被缓存——分叉只为超出缓存边界的新令牌付费。

提取代码在每次运行时记录缓存指标:

// extractMemories.ts — cache hit logging
const hitPct = ((cache_read_tokens / totalInput) * 100).toFixed(1)
logForDebugging(`[extractMemories] cache: read=${cache_read} create=${cache_create} (${hitPct}% hit)`)

分叉代理的工具列表必须与主代理的工具列表相匹配,缓存共享才能正常工作 - 这些工具是缓存键的一部分。 这就是为什么 createAutoMemCanUseTool() 使用运行时权限拒绝而不是向分支提供不同的工具列表。

要点

  • 提取在每个查询回合结束时作为分叉子代理运行 - 分叉共享提示缓存,因此它很便宜。 光标(lastMemoryMessageUuid)确保每次运行仅考虑新消息。
  • 锁定文件的 mtime is the lastConsolidatedAt 时间戳——没有单独的元数据存储。 阅读我们上次合并的时间正好花费一 stat().
  • 四类分类法(user, feedback, project, reference)的存在是为了使内存存储免受从实时代码派生的内容的影响——内存应该捕获以下内容的上下文: cannot 被重新派生。
  • 召回是有选择性的:具有 256 个令牌预算的 Sonnet 侧查询每次查询最多选取 5 个相关主题文件,从而抑制当前正在使用的工具的参考文档。
  • 系统中的“在从记忆中推荐之前”部分标题提示优于评估中的抽象变体 - 决策点的位置和框架对于合规性至关重要。
  • 团队内存范围是通过提示指导而不是文件系统权限来强制执行的 - 提示的 <scope> 标签是路由机制。

知识检查

1. 内存提取系统使用什么技术来确保分叉代理可以共享父代理的提示缓存?
正确的! 工具列表是提示缓存键的一部分。 为 fork 提供不同的工具列表会破坏缓存共享。 反而, createAutoMemCanUseTool() 返回一个在运行时拒绝不允许的操作的函数。
不完全是。 关键的见解是工具列表是缓存键的一部分 - 更改它会破坏缓存共享。 分叉使用与父级相同的工具,并通过运行时权限拒绝来强制执行限制。
2. The .consolidate-lock 文件存储最后的合并时间戳。 具体在哪里?
正确的! 的时间 .consolidate-lock IS lastConsolidatedAt。 主体只保存PID。 读取最后的合并时间正好花费 1 stat().
不完全是。 时间戳存储在文件的 mtime 中,而不是其正文或单独的文件中。 这意味着阅读它需要花费一 stat() 无需解析即可调用。
3. 为什么代码模式、架构和 git 历史记录被明确排除在内存分类之外?
正确的! 核心原则是:保存不可重新派生的上下文。 代码始终可以通过 grep/git 实时读取。 对它的记忆变得陈旧,并产生具有虚假权威的断言——引用被重命名或删除的函数。
不完全是。 核心原因是代码状态始终可以从实时存储库中重新派生。 陈旧的代码记忆会产生听起来权威但错误的声明。 该分类法侧重于没有其他来源的背景。
4. 每轮提取代理何时跳过运行并前进光标?
Correct! hasMemoryWritesSince() 扫描针对自动存储路径的编辑/写入工具调用的辅助消息。 如果找到,则跳过分叉并且光标前进——主代理和后台代理每回合都是互斥的。
不完全是。 关键检查是 hasMemoryWritesSince() — 如果主代理在新消息中写入内存文件,则跳过分叉提取。 这可以防止两条路径重复写入同一回合。
5. 回忆系统要求 Sonnet 选择相关的记忆。 它从选择中抑制了什么,为什么?
正确的! 如果 recentTools 包含模型正在积极使用的工具,其参考/API 文档未显示 - 实时对话已经显示了工作用法。 仍然包含有关这些工具的警告和陷阱。
不完全是。 抑制是为了当前正在使用的工具的参考/API 文档。 当您已经在使用某个工具时,显示其文档是噪音 - 但有关它的警告和陷阱仍然会出现。