文章比较长,电脑端也可以直接打开 cc.windliang.wang 查看
上一篇的 Skill 把一份工作方法按需放进上下文;Memory 解决的是另一件事:一段对话已经结束后,下一次打开 Claude Code,哪些过去的信息还值得找回来。
第六篇的 Compact 会把当前会话收束成摘要,适合保留「现在做到哪里」;但摘要会压缩细节,新会话也不会继承它。长期有效的用户偏好、项目决策和外部系统入口,需要有一层不参与压缩的持久存储。当前源码中的 memdir 就把这类信息保存为文件。
假设 /Users/me/shop 曾经留下过一条测试约束和一条项目背景。自动记忆目录中有三份文件:
~/.claude/projects/-Users-me-shop/memory/├── MEMORY.md├── feedback-real-database.md└── project-auth-migration.mdMEMORY.md 很短,只保存索引;真正的细节留在独立文件中:
<!-- ~/.claude/projects/-Users-me-shop/memory/MEMORY.md -->- [真实数据库测试](feedback-real-database.md) — 登录模块的集成测试不得 mock 数据库- [认证重构](project-auth-migration.md) — 认证重构由合规要求推动<!-- ~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md -->---description: 登录模块的集成测试不得 mock 数据库type: feedback---登录模块的集成测试必须连接真实数据库。原因:曾经出现过 mock 测试通过、生产迁移失败的情况。现在启动 Claude Code,并输入:
修复 src/auth/login.integration.test.ts 的失败测试,保留现有测试方式。最终,下一轮模型请求中会多出这条隐藏消息。下面是运行时消息对象,不是独立函数:
// 运行时消息对象:由 normalizeAttachmentForAPI() 创建{type: 'user',isMeta: true,message: {role: 'user',content: `<system-reminder>Memory (saved 2 days ago): ~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md:登录模块的集成测试必须连接真实数据库。原因:曾经出现过 mock 测试通过、生产迁移失败的情况。</system-reminder>`, },}这篇追踪这条消息从磁盘回到后续请求的过程:Claude Code 先保留索引,再用一次独立模型调用选文件,最后把选中的正文作为附件写进会话。

1. 先进入上下文的是索引,不是全部记忆
第六篇追过 getMemoryFiles():启动时它会读取用户级和项目级的 CLAUDE.md。自动记忆启用时,同一个函数还会把 MEMORY.md 加入返回列表。
源码位置:src/utils/claudemd.ts:790-992
// src/utils/claudemd.ts:790-992exportconst getMemoryFiles = memoize(async (forceIncludeExternal = false) => {// 前面省略:读取用户级、项目级 CLAUDE.md 的逻辑。const result = []if (isAutoMemoryEnabled()) {const { info: memdirEntry } = await safelyReadMemoryFileAsync( getAutoMemEntrypoint(),'AutoMem', )// getAutoMemEntrypoint() =>// '~/.claude/projects/-Users-me-shop/memory/MEMORY.md'// memdirEntry.content =>// '- [真实数据库测试](feedback-real-database.md) — 登录模块的集成测试不得 mock 数据库'if (memdirEntry) { result.push(memdirEntry) } }return result })之后 getClaudeMds() 会像处理 CLAUDE.md 一样,把这份 AutoMem 文件格式化进 userContext.claudeMd。这里读到的是一份 MEMORY.md 文件,文件内有两条索引记录:真实数据库测试和认证重构。模型开始处理任务时能看到这两条摘要,但还没有收到两个正文文件的完整内容。
getMemoryFiles() 读出的数组会立刻沿着下面这条链路被消费,并没有停留在启动阶段:
// src/context.ts:155-184exportconst getUserContext = memoize(async () => {const claudeMd = getClaudeMds( filterInjectedMemoryFiles(await getMemoryFiles()), )// getMemoryFiles() => [// {// path: '~/.claude/projects/-Users-me-shop/memory/MEMORY.md',// type: 'AutoMem',// content: '- [真实数据库测试](feedback-real-database.md) — 登录模块的集成测试不得 mock 数据库\n- [认证重构](project-auth-migration.md) — 认证重构由合规要求推动',// },// ]// claudeMd => "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of ~/.claude/projects/-Users-me-shop/memory/MEMORY.md (user's auto-memory, persists across conversations):\n\n- [真实数据库测试](feedback-real-database.md) — 登录模块的集成测试不得 mock 数据库\n- [认证重构](project-auth-migration.md) — 认证重构由合规要求推动"return { claudeMd,currentDate: "Today's date is 2026-08-10.", }})// src/query.ts:659-664forawait (const message of deps.callModel({messages: prependUserContext(messagesForQuery, userContext),// messagesForQuery => [// {// type: 'user',// message: {// role: 'user',// content: '修复 src/auth/login.integration.test.ts 的失败测试,保留现有测试方式。',// },// },// ]// userContext.claudeMd 会被 prependUserContext() 包成第一条隐藏 User Message,// 再与用户输入一起发送给模型。})) {yield message}其中 getClaudeMds() 只做文本拼装:逐个读取数组中的 file.path、file.type 和 file.content,生成 claudeMd 字符串。prependUserContext() 再把这个字符串包装为 isMeta: true 的隐藏 User Message,放在 messagesForQuery 前面。因此第一节读取的 MEMORY.md 索引,实际使用位置就是这次模型调用的 messages 参数。
getMemoryFiles() 的读取范围不只包括这份自动 Memory 索引。对应设置启用且文件存在时,它还会读取:
受管控的 CLAUDE.md与.claude/rules/*.md;用户级 ~/.claude/CLAUDE.md与~/.claude/rules/*.md;从文件系统根目录到当前 cwd沿途的CLAUDE.md、.claude/CLAUDE.md、.claude/rules/*.md和CLAUDE.local.md;--add-dir指定目录中的同类项目指令;启用 Team Memory 时的 Team MEMORY.md索引。
自动 Memory 在这一步只读取当前项目对应目录中的 MEMORY.md,不会扫描 feedback-real-database.md 这类正文文件。正文要等第 3 节的相关性筛选选中后才读取;子目录中的 CLAUDE.md 则由第六篇追过的文件附件路径按需补入。
这层分法让索引始终很小。真正的记忆文件可以随时间增加,初始请求不需要把每条历史偏好都附上。
从代码结构可以直接看出两层数据的职责不同:MEMORY.md 用于「发现」,独立 .md 文件用于「提供依据」。这样写的代价是后面需要多一次筛选和读取;换来的是记忆数量增长时,固定进入上下文的部分仍然可控。给 Memory 写 description 因而不只是给人看的说明,它也是后面筛选阶段唯一稳定可见的线索之一。
isAutoMemoryEnabled() 默认返回 true,但 CLAUDE_CODE_DISABLE_AUTO_MEMORY=1、--bare,或设置中的 autoMemoryEnabled: false 都会关掉这套机制。
源码位置:src/memdir/paths.ts:20-53
第一节和下一节的两份数据的形状也不同:这节 getMemoryFiles() 在自动 Memory 部分返回的是 MEMORY.md 这一份入口文件;下一节预取要的是每个正文文件的 filePath、Frontmatter description、type 和 mtimeMs,以便挑选后再读取。
这份索引先给模型的是长期信息的「摘要目录」。本例一开始修复登录测试时,模型已经能看到「登录集成测试不得 mock 数据库」和「认证重构由合规要求推动」两条约束;第一条已足以影响它是否选择 mock。后面的预取再根据当前任务,从两个正文文件中选出 feedback-real-database.md,把原因和细节补入后续请求。
索引注入提供的是第一轮请求前就确定存在的长期信息。后边要讲的流程 startRelevantMemoryPrefetch() 虽然与第一轮模型调用并行启动,但 queryLoop() 只在一次迭代结束后检查它是否已经完成;没有完成就直接跳过,不会等待。于是 feedback-real-database.md 的正文不保证赶上第一轮,而 MEMORY.md 中「不得 mock 数据库」这条摘要一定已经在第一轮消息里。
2. queryLoop() 开始时,后台启动一次相关性筛选
用户消息已经进入 state.messages 后,queryLoop() 创建 pendingMemoryPrefetch。这一步发生在主循环开始前,因此不会等到模型回答结束才开始检索。
源码位置:src/query.ts:301-304
// src/query.ts:241-312asyncfunction* queryLoop(params, consumedCommandUuids) {const state = {messages: params.messages,toolUseContext: params.toolUseContext,turnCount: 1, }// 前面省略:其余跨轮状态的初始化。 using pendingMemoryPrefetch = startRelevantMemoryPrefetch( state.messages, state.toolUseContext, )// state.messages 最后一条真实用户消息 =>// '修复 src/auth/login.integration.test.ts 的失败测试,保留现有测试方式。'// pendingMemoryPrefetch.promise =>// Promise<Attachment[]>,后台正在筛选相关 Memory;此刻尚未读取结果// 后面进入 while (true) 处理每一轮模型调用和 Tool 调用。}using 表示这份预取任务会在 queryLoop() 退出时自动释放;用户中断、正常结束或抛出异常都会取消尚未完成的检索。这是一项和模型请求并行的预取工作。
之前也从来没用过 using 这个语法,查了下可以类比成下面的 try / finally:
const pendingMemoryPrefetch = startRelevantMemoryPrefetch( state.messages, state.toolUseContext,)try {// queryLoop() 后续的模型调用与 Tool 调用。} finally { pendingMemoryPrefetch[Symbol.dispose]()// => controller.abort(),取消尚未完成的 Memory 检索。}using 会在当前作用域退出时自动调用对象的 Symbol.dispose();这里避免每个正常结束、中断和异常分支各写一次取消逻辑。
startRelevantMemoryPrefetch() 先排除几个不值得检索的情况:自动记忆或 Feature Flag 未开启、没有真实用户输入、输入只有一个词、或者本会话已经注入的记忆达到总预算。
源码位置:src/utils/attachments.ts:2361-2427
readFileState 是 ToolUseContext 中的文件状态缓存。键是文件路径,值保存该文件被读取时的内容、时间、分页位置等信息。此刻 feedback-real-database.md 还没有被模型读过,所以 readFileState.has() 返回 false;后面选出 Memory 时,它用来排除模型已经看过的文件。
源码位置:src/utils/fileStateCache.ts:5-92
// src/utils/attachments.ts:2361-2427exportfunctionstartRelevantMemoryPrefetch(messages, toolUseContext) {// 前面省略:自动记忆和 Feature Flag 的开关判断。const lastUserMessage = messages.findLast(message => message.type === 'user' && !message.isMeta, )// lastUserMessage.message.content =>// '修复 src/auth/login.integration.test.ts 的失败测试,保留现有测试方式。'const input = getUserMessageText(lastUserMessage)// => '修复 src/auth/login.integration.test.ts 的失败测试,保留现有测试方式。'const surfaced = collectSurfacedMemories(messages)// surfaced.paths => []// 本轮运行值:// toolUseContext.readFileState.has(// '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// ) => falseconst controller = createChildAbortController( toolUseContext.abortController, )const promise = getRelevantMemoryAttachments( input, toolUseContext.options.agentDefinitions.activeAgents, toolUseContext.readFileState, collectRecentSuccessfulTools(messages, lastUserMessage), controller.signal, surfaced.paths, )// => Promise<Attachment[]>;完成后可能得到// [{// type: 'relevant_memories',// memories: [{// path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// content: '---\\ndescription: 登录模块的集成测试不得 mock 数据库\\ntype: feedback\\n---\\n\\n登录模块的集成测试必须连接真实数据库。',// }],// }]const handle = { promise,settledAt: null,consumedOnIteration: -1, [Symbol.dispose]() { controller.abort() }, }// handle.settledAt => null;Promise 完成后会写入完成时间。void promise.finally(() => { handle.settledAt = Date.now() })return handle}这里的输入只有当前用户问题,不会把完整会话再次交给检索器。目录、已经展示过的路径和本轮成功使用过的 Tool 则作为辅助条件,避免把相同记忆反复塞回上下文。
这段安排把「是否需要回忆」放到模型调用和 Tool 执行并行的时间里。源码只消费已经完成的 Promise,不 await 尚未结束的检索;检索慢不会卡住正常任务,提前完成的结果则会在后续模型请求前加入消息。
3. sideQuery() 根据目录挑选文件
预取调用 getRelevantMemoryAttachments(),其内部的 findRelevantMemories() 先扫描记忆目录中所有 .md 文件的前 30 行,读到 Frontmatter 的 description、type 和修改时间;最多保留 200 个文件头,并按更新时间倒序排列。
// src/utils/attachments.ts:2196-2242asyncfunctiongetRelevantMemoryAttachments( input, agents, readFileState, recentTools, signal, alreadySurfaced,) {// input => '修复 src/auth/login.integration.test.ts 的失败测试,保留现有测试方式。'const memoryDirs = extractAgentMentions(input).flatMap(mention => {const agentType = mention.replace('agent-', '')const agentDef = agents.find(agent => agent.agentType === agentType)return agentDef?.memory ? [getAgentMemoryDir(agentType, agentDef.memory)] : [] })// memoryDirs => [],本例没有 @ 提及子 Agent。const dirs = memoryDirs.length > 0 ? memoryDirs : [getAutoMemPath()]// dirs => ['~/.claude/projects/-Users-me-shop/memory/']const allResults = awaitPromise.all( dirs.map(dir => findRelevantMemories( input, dir, signal, recentTools, alreadySurfaced, ).catch(() => []), ), )// allResults => [[{// path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// mtimeMs: 1785922200000,// }]]const selected = allResults .flat() .filter(memory => !readFileState.has(memory.path)) .slice(0, 5)// selected => allResults[0]const memories = await readMemoriesForSurfacing(selected, signal)return memories.length === 0 ? [] : [{ type: 'relevant_memories', memories }]}// src/memdir/findRelevantMemories.ts:39-75exportasyncfunctionfindRelevantMemories( query, memoryDir, signal, recentTools = [], alreadySurfaced = new Set(),) {const memories = (await scanMemoryFiles(memoryDir, signal) ).filter(memory => !alreadySurfaced.has(memory.filePath))// memories => [// {// filename: 'feedback-real-database.md',// description: '登录模块的集成测试不得 mock 数据库',// type: 'feedback',// }, {// filename: 'project-auth-migration.md',// description: '认证重构由合规要求推动',// type: 'project',// },// ]if (memories.length === 0) {return [] }const selectedFilenames = await selectRelevantMemories( query, memories, signal, recentTools, )// => ['feedback-real-database.md']const byFilename = newMap( memories.map(memory => [memory.filename, memory]), )const selected = selectedFilenames .map(filename => byFilename.get(filename)) .filter(memory => memory !== undefined)return selected.map(memory => ({path: memory.filePath,mtimeMs: memory.mtimeMs, }))// => [{// path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// mtimeMs: 1785922200000,// }]}源码位置:src/memdir/memoryScan.ts:35-94
// src/memdir/memoryScan.ts:35-94exportasyncfunctionscanMemoryFiles(memoryDir, signal) {// 外层 try / catch 省略:目录读取失败时返回 []。const entries = await readdir(memoryDir, { recursive: true })// entries => ['MEMORY.md', 'feedback-real-database.md', 'project-auth-migration.md']const mdFiles = entries.filter(filename => filename.endsWith('.md') && basename(filename) !== 'MEMORY.md', )// => ['feedback-real-database.md', 'project-auth-migration.md']const headerResults = awaitPromise.allSettled( mdFiles.map(async filename => {const filePath = join(memoryDir, filename)const { content, mtimeMs } = await readFileInRange( filePath,0,30,undefined, signal, )const { frontmatter } = parseFrontmatter(content, filePath)// feedback-real-database.md 的 frontmatter => {// description: '登录模块的集成测试不得 mock 数据库',// type: 'feedback',// }return { filename, filePath, mtimeMs,description: frontmatter.description || null,type: parseMemoryType(frontmatter.type), } }), )return headerResults .filter( (result): result is PromiseFulfilledResult<MemoryHeader> => result.status === 'fulfilled', ) .map(result => result.value) .sort((left, right) => right.mtimeMs - left.mtimeMs) .slice(0, 200)// => [{// filename: 'feedback-real-database.md',// filePath: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// description: '登录模块的集成测试不得 mock 数据库',// type: 'feedback',// }, {// filename: 'project-auth-migration.md',// filePath: '~/.claude/projects/-Users-me-shop/memory/project-auth-migration.md',// description: '认证重构由合规要求推动',// type: 'project',// }]}扫描结果不会直接按关键词匹配,也不会传入 embedding 向量库。selectRelevantMemories() 把「当前问题 + 文件名 + 描述」交给 sideQuery(),通过一次 Sonnet 调用从目录中选出可用文件名。
源码位置:src/memdir/findRelevantMemories.ts:77-137
// src/memdir/findRelevantMemories.ts:77-137asyncfunctionselectRelevantMemories(query, memories, signal, recentTools) {// query => '修复 src/auth/login.integration.test.ts 的失败测试,保留现有测试方式。'// memories => [{// filename: 'feedback-real-database.md',// filePath: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// description: '登录模块的集成测试不得 mock 数据库',// type: 'feedback',// }, {// filename: 'project-auth-migration.md',// filePath: '~/.claude/projects/-Users-me-shop/memory/project-auth-migration.md',// description: '认证重构由合规要求推动',// type: 'project',// }]// signal => AbortSignal { aborted: false }// recentTools => []const validFilenames = newSet(memories.map(memory => memory.filename))const manifest = formatMemoryManifest(memories)// => '- [feedback] feedback-real-database.md (2026-08-05T09:30:00.000Z): 登录模块的集成测试不得 mock 数据库\n- [project] project-auth-migration.md (2026-08-01T08:00:00.000Z): 认证重构由合规要求推动'const result = await sideQuery({model: getDefaultSonnetModel(),// => 'claude-sonnet-4-6'messages: [{role: 'user',content: `Query: ${query}\\n\\nAvailable memories:\\n${manifest}`, }],max_tokens: 256,output_format: {type: 'json_schema',schema: {type: 'object',properties: {selected_memories: {type: 'array',items: { type: 'string' }, }, },required: ['selected_memories'], }, }, signal,querySource: 'memdir_relevance', })const textBlock = result.content.find(block => block.type === 'text')const selected = jsonParse(textBlock.text).selected_memories// selected => ['feedback-real-database.md']const selectedExistingMemories = selected.filter(filename => validFilenames.has(filename), )// selectedExistingMemories => ['feedback-real-database.md']return selectedExistingMemories}选择器最多返回 5 个文件名。它也会避开已经展示过的文件;如果模型近期已经成功使用 Read,选择器不会再把「如何使用 Read」这类参考记忆捞回来,但仍可以选择包含警告或已知问题的记忆。
这条路径先把两个文件的目录交给一次独立调用,再由它选中 feedback-real-database.md,最后才读取正文。随着记忆文件增加,初始请求中的固定部分仍然较小。
这种实现还留下一个很实际的约束:检索质量首先取决于文件名和 Frontmatter 描述,而不是全文的语义向量。对 Memory 来说,「登录模块的集成测试不得 mock 数据库」比「测试经验」更容易被当前问题准确选中。文件正文可以写详细,索引和描述则应当短且能说明何时有用。
4. 选中后才读取正文,并限制大小
回到第 3 节的 getRelevantMemoryAttachments():selected 是选择器给出的路径和修改时间。函数紧接着把它交给 readMemoriesForSurfacing(),再包装成附件:
// src/utils/attachments.ts:2233-2242,位于 getRelevantMemoryAttachments()asyncfunctiongetRelevantMemoryAttachments( input, agents, readFileState, recentTools, signal, alreadySurfaced,) {// 前面省略:findRelevantMemories() 选出文件,并过滤重复路径。const selected = [{path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',mtimeMs: 1785922200000, }]const memories = await readMemoriesForSurfacing(selected, signal)// memories => [{// path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// header: 'Memory (saved 2 days ago): ~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md:',// content: '---\ndescription: 登录模块的集成测试不得 mock 数据库\ntype: feedback\n---\n\n登录模块的集成测试必须连接真实数据库。',// }]return [{ type: 'relevant_memories', memories }]}readMemoriesForSurfacing() 接到选择器的路径后,才读取文件正文。每个文件最多读取 200 行和 4096 字节;任一限制触发时,正文后面会加上完整文件路径,模型仍可用普通 Read Tool 查看余下内容。
源码位置:src/utils/attachments.ts:2279-2323
// src/utils/attachments.ts:2279-2323exportasyncfunctionreadMemoriesForSurfacing(selected, signal) {const results = awaitPromise.all( selected.map(async ({ path: filePath, mtimeMs }) => {try {const result = await readFileInRange( filePath,0,200,4096, signal, { truncateOnByteLimit: true }, )// filePath =>// '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md'// result.content =>// '---\ndescription: 登录模块的集成测试不得 mock 数据库\ntype: feedback\n---\n\n登录模块的集成测试必须连接真实数据库。\n\n原因:曾经出现过 mock 测试通过、生产迁移失败的情况。'const truncated = result.totalLines > 200 || result.truncatedByBytesconst content = truncated ? result.content +`\n\n> This memory file was truncated (${result.truncatedByBytes ? '4096 byte limit' : 'first 200 lines'}). Use the Read tool to view the complete file at: ${filePath}` : result.content// truncated => false// content => result.contentreturn {path: filePath, content, mtimeMs,header: memoryHeader(filePath, mtimeMs), } } catch {returnnull } }), )return results.filter(result => result !== null)}getRelevantMemoryAttachments() 将这些结果装进 relevant_memories 附件。此时还没有修改模型请求;它只是把可能进入下一轮的数据准备好。
自动带入后续请求的每个 Memory 最多只有 200 行、4096 字节。假如 feedback-real-database.md 有 500 行且前 200 行没有超过 4096 字节,模型收到的是前 200 行,以及一句「完整文件在 ~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md」;4096 字节先到时,实际带入的行数会更少。
如果前 200 行已经足够,模型直接继续任务;只有确实需要后面的细节时,才会再调用 Read 读取这个路径。这样不会把每份长记忆都自动塞进上下文,也不会让被截掉的内容找不回来。
5. 预取不阻塞主任务,完成后加入后续请求
模型请求和 Tool 调用照常进行。例如模型先读失败测试和登录实现:
// 模型输出:运行时对象,不是源码中的独立函数{type: 'tool_use',id: 'toolu_read_test_01',name: 'Read',input: {file_path: '/Users/me/shop/src/auth/login.integration.test.ts', },}// Read Tool Result 已写入本轮 messages{type: 'tool_result',tool_use_id: 'toolu_read_test_01',content: 'describe(\'login\', () => { /* 当前测试使用真实 test database */ })',}这期间,记忆选择的 Promise 可能已经完成,也可能仍在运行。queryLoop() 在 Tool 结果收集完成后只检查 settledAt;还没结束就跳过,下一次循环再检查,不会为了等待记忆而停住 Agent。
模型调用与 Memory 检索并行进行。模型输出和本轮 Tool 结果处理完后,queryLoop() 才运行下面这段代码:已经完成的检索结果会加入 toolResults,随后随下一次模型请求发送;尚未完成则跳过,等循环继续时再检查。
源码位置:src/query.ts:1578-1622
// src/query.ts:241-1622,位于 queryLoop()asyncfunction* queryLoop(params, consumedCommandUuids) {// 前面省略:本轮模型调用、Tool 结果收集和 assistantMessages 的处理。if ( pendingMemoryPrefetch && pendingMemoryPrefetch.settledAt !== null && pendingMemoryPrefetch.consumedOnIteration === -1 ) {const memoryAttachments = filterDuplicateMemoryAttachments(await pendingMemoryPrefetch.promise, toolUseContext.readFileState, )// memoryAttachments => [{// type: 'relevant_memories',// memories: [{// path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// content: '---\ndescription: 登录模块的集成测试不得 mock 数据库\ntype: feedback\n---\n登录模块的集成测试必须连接真实数据库。',// }],// }]for (const memoryAttachment of memoryAttachments) {const message = createAttachmentMessage(memoryAttachment)yield message toolResults.push(message) } pendingMemoryPrefetch.consumedOnIteration = turnCount - 1 }}filterDuplicateMemoryAttachments() 在加入前再检查一次 readFileState。若模型已经自己通过 Read 打开同一文件,或本会话先前已经注入过它,附件会被过滤掉;保留下来的路径也会记录进 readFileState,后续轮次不再重复出现。
磁盘上的 Memory 是跨会话的持久数据,readFileState 则是本次会话的临时去重状态。把两者分开后,记忆文件不会因为本轮已经展示过一次而失效;反过来,同一轮也不会因为循环继续而重复膨胀相同正文。
接着 createAttachmentMessage() 调用 normalizeAttachmentForAPI(),把 relevant_memories 格式化为 isMeta: true 的隐藏 User Message。这条消息和 Tool Result 一起进入下一轮 callModel()。
源码位置:src/utils/messages.ts:3708-3724
// src/utils/messages.ts:3453-3730exportfunctionnormalizeAttachmentForAPI(attachment) {// attachment => {// type: 'relevant_memories',// memories: [// {// path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// mtimeMs: 1786147200000,// header: 'Memory (saved 2 days ago): ~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md:',// content: '---\ndescription: 登录模块的集成测试不得 mock 数据库\ntype: feedback\n---\n\n登录模块的集成测试必须连接真实数据库。\n\n原因:曾经出现过 mock 测试通过、生产迁移失败的情况。',// },// ],// }// 前面省略:其他 Attachment 类型的格式化。switch (attachment.type) {case'relevant_memories': {return wrapMessagesInSystemReminder( attachment.memories.map(memory => {const header = memory.header ?? memoryHeader( memory.path, memory.mtimeMs, )return createUserMessage({content: `${header}\n\n${memory.content}`,isMeta: true, }) }), ) } }}// return => [// {// type: 'user',// isMeta: true,// message: {// role: 'user',// content: `<system-reminder>// Memory (saved 2 days ago): ~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md://// ---// description: 登录模块的集成测试不得 mock 数据库// type: feedback// ---//// 登录模块的集成测试必须连接真实数据库。//// 原因:曾经出现过 mock 测试通过、生产迁移失败的情况。// </system-reminder>`,// },// },// ]随后模型既能看到测试文件,也能看到这条历史约束。接下来它可以据此修复断言或测试数据,并把「不得 mock 数据库」作为约束。
6. 记忆是长期信息,还需要写入和整理
自动记忆目录中的内容用于跨会话保留信息。源码把可保存内容分成 user、feedback、project、reference 四类,并明确排除可以从代码、Git 历史或目录结构重新推导的事实。
源码位置:src/memdir/memoryTypes.ts:1-34
// src/memdir/memoryTypes.ts:1-34,模块顶层导出exportconst MEMORY_TYPES = ['user','feedback','project','reference',]// feedback 例子 =>// '登录模块的集成测试不得 mock 数据库'// 不适合保存 =>// 'src/auth/login.ts 存在'// 因为后者可以随时通过 Glob、Grep 或 Read 得到。四类类型对应的是四种不同的长期信息:
user | ||
feedback | ||
project | ||
reference |
同一会话中的 messages、Todo、当前 Tool Result,以及第六篇的 Session Memory Compact,解决的是「这次任务还没结束时如何续上」。它们会随会话结束、压缩边界或上下文预算变化。Memory 只保存以后重新启动 Claude Code 仍可能影响判断的信息。
buildMemoryLines() 放进模型上下文的写入约束要求两步:先把详细内容写入独立文件,再在 MEMORY.md 写一行指针。这样,写入结构和本篇读取结构正好对应:正文保留原因与使用方式,索引承担发现责任。
源码位置:src/memdir/memdir.ts:199-266
用户输入「记住:登录模块的集成测试不得 mock 数据库」后,这句话先作为普通 User Message 进入 queryLoop()。loadMemoryPrompt() 在启动时已经把 Memory 保存规则放进 System Prompt,其中包含「用户明确要求记住时立即保存」以及「正文文件 + MEMORY.md 指针」两步要求。
源码位置:src/memdir/memdir.ts:199-266、src/memdir/memdir.ts:419-490
// src/memdir/memdir.ts:419-490exportasyncfunctionloadMemoryPrompt() {const autoEnabled = isAutoMemoryEnabled()const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse',false, )// 前面省略:团队 Memory 与 daily-log 两条分支。if (autoEnabled) {const autoDir = getAutoMemPath()// => '~/.claude/projects/-Users-me-shop/memory/'await ensureMemoryDirExists(autoDir)return buildMemoryLines('auto memory', autoDir,undefined, skipIndex, ).join('\n')// 返回的 Prompt 包含:// 'If the user explicitly asks you to remember something, save it immediately'// 'Step 1 — write the memory to its own file'// 'Step 2 — add a pointer to that file in MEMORY.md' }returnnull}模型随后沿第四篇的正常 Tool 路径返回写文件请求;没有额外的「记住」专用 Tool。下面是这次输入可能得到的模型 Tool Use,不是源码中的函数:
// 模型可能先写正文{type: 'tool_use',id: 'toolu_memory_write_01',name: 'Write',input: {file_path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',content: `---description: 登录模块的集成测试不得 mock 数据库type: feedback---登录模块的集成测试必须连接真实数据库。原因:曾经出现过 mock 测试通过、生产迁移失败的情况。`, },}// 再更新索引{type: 'tool_use',id: 'toolu_memory_edit_01',name: 'Edit',input: {file_path: '~/.claude/projects/-Users-me-shop/memory/MEMORY.md',old_string: '- [认证重构](project-auth-migration.md) — 认证重构由合规要求推动',new_string: `- [认证重构](project-auth-migration.md) — 认证重构由合规要求推动- [真实数据库测试](feedback-real-database.md) — 登录模块的集成测试不得 mock 数据库`, },}这两次 Tool 已经成功写入文件后,Memory 就产生了。任务结束时,后台提取器会检查这段消息中是否已经出现了 Write 或 Edit 到 Memory 目录的 Tool Use;存在就跳过,避免同一条信息被主 Agent 和提取器各保存一遍。
7. 一轮任务结束后,后台提取器补充遗漏的 Memory
用户并不总会明确说「记住」。例如用户在解决测试失败时说出「登录模块的集成测试不得 mock 数据库」,这条约束对以后排查仍然有用,但主 Agent 可能只专注于修复当前测试。因此,正常任务结束后还会有一条后台提取路径:

1. 主任务结束,Stop Hook 发起后台提取
queryLoop() 走到正常结束路径后,将本轮消息、ToolUseContext 和上下文对象交给 handleStopHooks(),作为主循环准备返回前的收尾工作。
源码位置:src/query.ts:1267-1275
// src/query.ts:241-1290,位于 queryLoop()asyncfunction* queryLoop(params, consumedCommandUuids) {// 前面省略:模型已返回最终文本,本轮没有继续调用 Tool。const stopHookResult = yield* handleStopHooks( messagesForQuery, assistantMessages, systemPrompt, userContext, systemContext, toolUseContext, querySource, stopHookActive, )// messagesForQuery + assistantMessages =>// 本轮用户问题、Tool Result 与最终回答组成的完整主会话消息// 后面省略:根据 stopHookResult 决定本轮结束或续行。}handleStopHooks() 只在非 --bare 的主线程任务中考虑启动提取器,还要求 EXTRACT_MEMORIES Feature Flag 和自动记忆模式都已开启。这里不等待提取任务完成,用户的最终回答可以先返回,Memory 提取留在后台继续。
源码位置:src/query/stopHooks.ts:141-155
// src/query/stopHooks.ts:65-165exportasyncfunction* handleStopHooks( messagesForQuery, assistantMessages, systemPrompt, userContext, systemContext, toolUseContext, querySource, stopHookActive,) {const stopHookContext = {messages: [...messagesForQuery, ...assistantMessages], systemPrompt, userContext, systemContext, toolUseContext, querySource, }// 前面省略:其他 Stop Hook、提示建议和任务状态更新。if (!isBareMode()) {if ( feature('EXTRACT_MEMORIES') && !toolUseContext.agentId && isExtractModeActive() ) {void extractMemoriesModule.executeExtractMemories( stopHookContext, toolUseContext.appendSystemMessage, ) } }// toolUseContext.agentId => undefined(主 Agent)// executeExtractMemories() => 后台 Promise,不等待它完成}2. 公开入口转交给启动时创建的提取器
executeExtractMemories() 是 Stop Hook 调用的公开函数。启动阶段的 initExtractMemories() 创建 executeExtractMemoriesImpl(),并将它保存到模块变量 extractor;公开函数只负责把调用转交进去。
源码位置:src/services/extractMemories/extractMemories.ts:569-602
// src/services/extractMemories/extractMemories.ts:569-602// initExtractMemories() 内部:extractor = async (context, appendSystemMessage) => {await executeExtractMemoriesImpl(context, appendSystemMessage)}// Stop Hook 实际调用的公开入口:exportasyncfunctionexecuteExtractMemories( context, appendSystemMessage,) {await extractor?.(context, appendSystemMessage)}3. 内部入口先决定这次是否需要启动提取
进入 executeExtractMemoriesImpl() 后,源码先排除子 Agent、未启用自动 Memory、远程模式等情况。通过后,如果没有另一个提取正在执行,才进入 runExtraction();正在执行时先保存最新上下文,具体的补跑逻辑放到第 6 步再看。
源码位置:src/services/extractMemories/extractMemories.ts:339-427、src/services/extractMemories/extractMemories.ts:171-238
// src/services/extractMemories/extractMemories.ts:296-435exportfunctioninitExtractMemories() {let lastMemoryMessageUuidasyncfunctionexecuteExtractMemoriesImpl( context, appendSystemMessage,) {// 前面省略:子 Agent、Feature Flag、自动 Memory、远程模式等前置判断。if (inProgress) { pendingContext = { context, appendSystemMessage }// 当前提取尚未完成:先保存最新上下文,本次不新建 forked Agent。return }await runExtraction({ context, appendSystemMessage }) }}4. runExtraction() 组装提取任务,再交给 forked Agent
第 3 步确认可以运行后,才进入同一个 initExtractMemories() 闭包内的 runExtraction()。它依次准备「本次新增消息」「已有 Memory 清单」「提取 Prompt」「受限权限」,最后调用 runForkedAgent()。
是否产生一条新 Memory、更新旧文件,还是不写任何内容,都是 forked Agent 的模型输出决定的;源码没有把「登录测试」或「不得 mock」写成固定提取规则。这个 Agent 不直接保存文件;它和主 Agent 一样,仍要通过正常的 Write、Edit Tool 完成写入。
源码位置:src/services/extractMemories/extractMemories.ts:329-435
// src/services/extractMemories/extractMemories.ts:329-435,位于 initExtractMemories() 的同一闭包内asyncfunctionrunExtraction({ context, appendSystemMessage, isTrailingRun, }) {const { messages } = contextconst memoryDir = getAutoMemPath()const newMessageCount = countModelVisibleMessagesSince( messages, lastMemoryMessageUuid, )// newMessageCount => 4const mainAgentWroteMemory = hasMemoryWritesSince( messages, lastMemoryMessageUuid, )// mainAgentWroteMemory => falseif (mainAgentWroteMemory) {return }// 本轮主 Agent 没有直接写入 feedback-real-database.md,因此继续提取。const existingMemories = formatMemoryManifest(await scanMemoryFiles( memoryDir, createAbortController().signal, ), )// => '- [project] project-auth-migration.md (2026-08-01T08:00:00.000Z): 认证重构由合规要求推动'const skipIndex = falseconst teamMemoryEnabled = falseconst userPrompt = feature('TEAMMEM') && teamMemoryEnabled ? buildExtractCombinedPrompt( newMessageCount, existingMemories, skipIndex, ) : buildExtractAutoOnlyPrompt( newMessageCount, existingMemories, skipIndex, )// feature('TEAMMEM') => false// userPrompt 中包含:// 'If the user explicitly asks you to remember something, save it immediately'// 以及「正文文件 + MEMORY.md 索引」的保存规则。const result = await runForkedAgent({promptMessages: [createUserMessage({ content: userPrompt })],cacheSafeParams: createCacheSafeParams(context),// cacheSafeParams.forkContextMessages => 本轮主 Agent 已经可见的会话消息canUseTool: createAutoMemCanUseTool(memoryDir),querySource: 'extract_memories',forkLabel: 'extract_memories',skipTranscript: true,maxTurns: 5, })// result.messages 可能包含:[// {// type: 'assistant',// message: {// role: 'assistant',// content: [{// type: 'tool_use',// id: 'toolu_memory_write_01',// name: 'Write',// input: {// file_path: '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md',// content: '---\ndescription: 登录模块的集成测试不得 mock 数据库\ntype: feedback\n---\n\n登录模块的集成测试必须连接真实数据库。',// },// }],// },// },// {// type: 'assistant',// message: {// role: 'assistant',// content: [{// type: 'tool_use',// id: 'toolu_memory_edit_01',// name: 'Edit',// input: {// file_path: '~/.claude/projects/-Users-me-shop/memory/MEMORY.md',// old_string: '- [认证重构](project-auth-migration.md) — 认证重构由合规要求推动',// new_string: '- [认证重构](project-auth-migration.md) — 认证重构由合规要求推动\n- [真实数据库测试](feedback-real-database.md) — 登录模块的集成测试不得 mock 数据库',// },// }],// },// },// ]// 两个 Tool Use 执行成功后,正文和索引文件已经写入磁盘。}5. runForkedAgent() 复用 Agent 主循环,Tool 写入文件
runForkedAgent() 做的事情是创建独立的 ToolUseContext,再用已有的 query() 主循环跑这次「提取记忆」任务。它不写 Memory,也不解析 Memory;写入动作仍发生在这个 forked Agent 返回 Write、Edit Tool Use 后,由第四篇追过的 Tool 执行链完成。
代码提供的是约束,不是确定的提取算法:提示词给出四种 Memory 类型、已有文件头和「正文后更新索引」的规则;canUseTool 限制写入目录,maxTurns: 5 限制调用次数。模型在这些边界内决定是否调用 Write、Edit。因此一次提取也可能正常结束而没有任何写入。
源码位置:src/utils/forkedAgent.ts:489-594
// src/utils/forkedAgent.ts:489-594exportasyncfunctionrunForkedAgent({ promptMessages, cacheSafeParams, canUseTool, querySource, maxTurns,}) {const outputMessages = []let totalUsage = { ...EMPTY_USAGE }const { systemPrompt, userContext, systemContext, toolUseContext, forkContextMessages, } = cacheSafeParamsconst isolatedToolUseContext = createSubagentContext(toolUseContext)// 与主 Agent 分开的上下文:fork 中的文件读取状态、取消状态不会改写主会话。const initialMessages = [ ...forkContextMessages, ...promptMessages, ]// promptMessages => [{// type: 'user',// message: { role: 'user', content: userPrompt },// }]forawait (const message of query({messages: initialMessages, systemPrompt, userContext, systemContext, canUseTool,toolUseContext: isolatedToolUseContext,querySource: 'extract_memories',maxTurns: 5, })) {if (message.type === 'stream_event') {if (message.event?.type === 'message_delta' && message.event.usage) { totalUsage = accumulateUsage( totalUsage, updateUsage({ ...EMPTY_USAGE }, message.event.usage), ) }continue }if (message.type === 'stream_request_start') {continue } outputMessages.push(message) }return { messages: outputMessages, totalUsage }}允许写入的边界来自传入的 canUseTool。它只放行 Memory 目录中的 Edit、Write;例如 feedback-real-database.md 可以写,/Users/me/shop/src/auth/login.ts 会被拒绝。这样提取器可以更新「正文文件 + MEMORY.md」,但不能借机修改项目代码。
// src/services/extractMemories/extractMemories.ts:196-214,位于 createAutoMemCanUseTool()if ( (tool.name === FILE_EDIT_TOOL_NAME || tool.name === FILE_WRITE_TOOL_NAME) &&'file_path'in input) {const filePath = input.file_pathif (typeof filePath === 'string' && isAutoMemPath(filePath)) {return { behavior: 'allow', updatedInput: input } }}// tool.name => 'Write'// input.file_path => '~/.claude/projects/-Users-me-shop/memory/feedback-real-database.md'// isAutoMemPath(filePath) => true// 返回 allow,随后进入 Write Tool。6. 提取进行中有新任务结束时,只在最后补跑一次
第 3 步中 inProgress === true 时,第二次调用已经把最新上下文存进 pendingContext 并返回。第 4 步的 runExtraction() 完成后才会执行下面的 finally。源码把这次「前一个提取结束后,补上期间新增消息」的运行称为 trailing extraction;它不是失败重试。
源码位置:src/services/extractMemories/extractMemories.ts:496-523
// src/services/extractMemories/extractMemories.ts:496-523,位于 runExtraction() 的 finally 中finally { inProgress = falseconst trailing = pendingContext pendingContext = undefined// trailing => {// context: 第二次任务结束时的最新 REPLHookContext,// appendSystemMessage: toolUseContext.appendSystemMessage,// }if (trailing) {await runExtraction({context: trailing.context,appendSystemMessage: trailing.appendSystemMessage,isTrailingRun: true, })// 第二次提取只处理第一次运行期间新出现的消息。 }}多个任务在第一次提取期间结束时,pendingContext 会不断被最新一份覆盖;前一个提取结束后只补跑一次。提取失败也只记录日志,不会让已经完成的用户任务变成失败。这样不会并行写 MEMORY.md,也不会为了每次任务结束排队运行多份过时的提取。
7. autoDream:另一条低频的整理分支
提取链路结束后,同一个 Stop Hook 还会尝试调用 executeAutoDream()。名字来自它传给 forked Agent 的 Prompt 标题 # Dream: Memory Consolidation。它不会在每轮任务结束时都实际开始整理,而是检查三个门槛:距上次整理的时间、期间累积的其他会话数量,以及是否已有另一个进程正在整理。默认值是至少间隔 24 小时、至少积累 5 个其他会话;autoDreamEnabled 设置控制是否启用,Feature Flag 可以提供启用状态和两个阈值。
源码位置:src/query/stopHooks.ts:149-153
// src/query/stopHooks.ts:149-153,位于 handleStopHooks()if (!toolUseContext.agentId) {void executeAutoDream( stopHookContext, toolUseContext.appendSystemMessage, )// autoDream 自己检查是否达到整理门槛;未达到时直接返回。}源码位置:src/services/autoDream/autoDream.ts:52-199、src/services/autoDream/config.ts:10-20
// src/services/autoDream/autoDream.ts:125-199,位于 initAutoDream() 中的 runAutoDream()const cfg = getConfig()// cfg => { minHours: 24, minSessions: 5 }const lastAt = await readLastConsolidatedAt()// lastAt => 1786060800000,表示上次整理发生在 2026-08-07T00:00:00.000Zconst hoursSince = (Date.now() - lastAt) / 3_600_000// hoursSince => 72if (hoursSince < cfg.minHours) returnlet sessionIds = await listSessionsTouchedSince(lastAt)// sessionIds => [// 'session_auth_bugfix_01',// 'session_payment_review_02',// 'session_ci_failure_03',// 'session_release_check_04',// 'session_migration_05',// ]sessionIds = sessionIds.filter(id => id !== getSessionId())if (sessionIds.length < cfg.minSessions) returnconst priorMtime = await tryAcquireConsolidationLock()// priorMtime => 1786060800000// 若另一个进程持有锁,priorMtime => null,当前整理直接返回。if (priorMtime === null) return门槛通过后,autoDream 同样调用 runForkedAgent(),也复用 createAutoMemCanUseTool(memoryRoot),所以它只能用 Edit、Write 修改 Memory 目录。不同的是 Prompt 要求它先看现有索引和必要的会话记录,再合并重复项、删除已失效或矛盾的内容,并保持 MEMORY.md 是短索引。
源码位置:src/services/autoDream/consolidationPrompt.ts:10-87
// buildConsolidationPrompt(memoryRoot, transcriptDir, extra) 的关键要求// 1. 读取 MEMORY.md 和已有主题文件,避免创建重复内容;// 2. 只按需要搜索近期 JSONL 会话记录,不完整读取所有记录;// 3. 更新主题正文,删除已被推翻的事实;// 4. 更新 MEMORY.md:删除过期指针、补充新指针、解决冲突。const result = await runForkedAgent({promptMessages: [createUserMessage({ content: prompt })],cacheSafeParams: createCacheSafeParams(context),canUseTool: createAutoMemCanUseTool(memoryRoot),querySource: 'auto_dream',forkLabel: 'auto_dream',})// result.messages => forked Agent 返回的 Read、Edit、Write Tool Use 与 Tool Result。提取器处理「刚刚出现、值得留下的事」;autoDream 处理「留下很多之后,哪些该合并、修正或移除」。两者都由模型在受限 Tool 边界内决定具体文件改动,但触发频率和输入范围不同。
小结
Memory 有两条方向相反的链路。
写入时,主 Agent 可以直接用 Write、Edit写入正文文件和MEMORY.md;没有直接写入时,Stop Hook 启动受限的后台提取器,补充这两个文件。后续的autoDream再低频合并重复或过期内容。读取时,新任务先把 MEMORY.md的短索引放进首轮模型消息;预取任务并行扫描正文文件,用sideQuery()从名称和描述中选择相关项,再把正文作为隐藏消息加入后续请求。
索引负责让首轮请求立即看到长期约束,正文负责按需提供细节。这样既能保留跨会话信息,也不会在每次模型调用时塞入所有 Memory 正文。
夜雨聆风