自动记忆与 Dreams
Claude Code 如何默默地从每次训练中提取记忆,并在您睡觉时巩固它们。
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
提取记忆
每次最终响应后,分叉代理都会审查新消息并将主题文件写入磁盘。
汽车梦
经过 24 小时 + 5 个会话后,整合代理会合并、修剪内存目录并重新索引。
相关召回
Sonnet 侧查询最多选择 5 个与当前查询匹配的主题文件并注入它们。
所有持久内存都位于由以下计算的路径下 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.md 是 index。 它始终加载到系统提示符中(截断为 200 行/25 KB)。 每行都是一个短指针:
- [Title](file.md) — one-line hook。 实际内容位于主题文件中。
记忆仅限于定义的封闭分类法 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.
CLAUDE.md。 即使用户明确要求,这些排除也适用。 理由:代码始终是实时可读的; 对它的记忆会变得陈旧并产生虚假的权威。
在每个查询循环结束时(当模型在没有工具调用的情况下生成最终响应时),
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 }
萃取流程
分叉代理的工具权限
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)有一个刻意的效率指令,直接映射到转弯预算:
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"
梦想系统运行 相同的分叉代理模式 但节奏要长得多。 它在查询循环结束时触发(通过 executeAutoDream())仅当三个门按顺序通过时,最便宜的首先:
时间之门
小时以来 lastConsolidatedAt ≥ minHours (默认值:24)。 费用:一 stat() 每回合呼叫。
会话门
使用 mtime 转录文件 > lastConsolidatedAt ≥ minSessions (默认值:5)。 成本:一次目录扫描,限制为每 10 分钟一次。
锁门
中间合并没有其他过程。 通过 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) } }
在查询时,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."
当 TEAMMEM 启用功能标志后,内存系统将在私有目录旁边获得第二个目录。 组合模式使用 buildExtractCombinedPrompt()
这增加了 <scope> 每个内存类型块的标签。 路由指南被纳入每个类型定义中,而不是单独的路由部分。
每种类型的范围规则
- user - 始终是私人的。 个人资料不应被共享。
- feedback — 默认私有; 仅当指导是项目范围的惯例(测试策略、构建不变性)而不是个人风格偏好时才团队。
- project ——私人或团队,强烈偏向团队。 共享工作背后的上下文属于共享存储。
- reference ——通常是团队。 外部系统指针对所有协作者都有用。
敏感数据(API 密钥、凭据)绝不能保存到团队内存中 - 组合提取提示包含明确的禁止。
在长期助理会话中(功能标志 KAIROS),内存模型从实时索引转变为 仅附加每日日志。 代理写信至
logs/YYYY/MM/YYYY-MM-DD.md 因为它有效,而不是维护 MEMORY.md 直接地。 单独的每晚 /dream 技能将日志提取到主题文件并更新
MEMORY.md.
日志路径模式存储在提示中,没有今天的文字日期 - 提示由以下方式缓存 systemPromptSection('memory', ...) 并且不得在午夜展期时失效。 该模型从以下数据中得出当前日期 date_change 午夜到来时附加的附件,而不是来自提示本身。
选择分叉代理模式正是因为它 共享父级的提示缓存。 分叉代理获得与主对话相同的系统提示和消息历史前缀,因此这些令牌已经被缓存——分叉只为超出缓存边界的新令牌付费。
提取代码在每次运行时记录缓存指标:
// 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>标签是路由机制。
知识检查
createAutoMemCanUseTool() 返回一个在运行时拒绝不允许的操作的函数。.consolidate-lock 文件存储最后的合并时间戳。 具体在哪里?.consolidate-lock IS lastConsolidatedAt。 主体只保存PID。 读取最后的合并时间正好花费 1 stat().stat() 无需解析即可调用。hasMemoryWritesSince() 扫描针对自动存储路径的编辑/写入工具调用的辅助消息。 如果找到,则跳过分叉并且光标前进——主代理和后台代理每回合都是互斥的。hasMemoryWritesSince() — 如果主代理在新消息中写入内存文件,则跳过分叉提取。 这可以防止两条路径重复写入同一回合。recentTools 包含模型正在积极使用的工具,其参考/API 文档未显示 - 实时对话已经显示了工作用法。 仍然包含有关这些工具的警告和陷阱。