目录
  1. 1. 一、核心概念
    1. 1.1. 问题
    2. 1.2. 解决方案
  2. 2. 二、目录结构
  3. 3. 三、记忆类型分类法(四大类型)
  4. 4. 四、MEMORY.md 入口文件
  5. 5. 五、记忆加载流程
  6. 6. 六、记忆写入时机
  7. 7. 七、团队记忆(TEAMMEM)
  8. 8. 八、findRelevantMemories — AI 驱动的语义检索
    1. 8.1. 8.1 整体流程
    2. 8.2. 8.2 scanMemoryFiles 扫描机制(memoryScan.ts)
    3. 8.3. 8.3 Sonnet Side Query 选择机制
    4. 8.4. 8.4 alreadySurfaced 去重
    5. 8.5. 8.5 mtime 线程穿透
    6. 8.6. 8.6 与 MEMORY.md 的分工
  9. 9. 九、面试要点
  10. 10. 十、findRelevantMemories 两阶段检索深度剖析
    1. 10.1. 10.1 整体架构
    2. 10.2. 10.2 第一阶段:memoryScan.ts 文件系统扫描
      1. 10.2.1. 核心设计决策
      2. 10.2.2. MemoryHeader 数据结构
      3. 10.2.3. formatMemoryManifest:候选列表的序列化格式
    3. 10.3. 10.3 第二阶段:Sonnet 语义精选
    4. 10.4. 10.4 recentTools 过滤:避免”正在用的工具文档”噪音
    5. 10.5. 10.5 alreadySurfaced 去重:防止 5-slot 预算浪费
    6. 10.6. 10.6 遥测:记忆召回形状
  11. 11. 十一、extractMemories 自动提取机制深度剖析
    1. 11.1. 11.1 触发时机
    2. 11.2. 11.2 forkedAgent 模式:共享 prompt cache
    3. 11.3. 11.3 互斥机制:主 Agent 写了记忆则跳过
    4. 11.4. 11.4 串行化:in-flight 保护与 trailing run
    5. 11.5. 11.5 提取 Prompt 构造
    6. 11.6. 11.6 记忆文件去重策略
    7. 11.7. 11.7 工具权限沙箱
  12. 12. 十二、记忆文件格式规范
    1. 12.1. 12.1 Frontmatter 字段
    2. 12.2. 12.2 四种记忆类型的内容规范
    3. 12.3. 12.3 文件命名规则
    4. 12.4. 12.4 MEMORY.md 索引格式
  13. 13. 十三、memdir 路径体系(paths.ts)
    1. 13.1. 13.1 路径解析优先级
    2. 13.2. 13.2 默认路径结构
    3. 13.3. 13.3 路径安全校验
    4. 13.4. 13.4 isAutoMemEnabled 优先级链
  14. 14. 十四、sessionMemoryCompact 会话记忆压缩
    1. 14.1. 14.1 与全局 compact 的区别
    2. 14.2. 14.2 SM Compact 触发流程
    3. 14.3. 14.3 保留消息计算:calculateMessagesToKeepIndex
    4. 14.4. 14.4 压缩结果的持久化
  15. 15. 十五、记忆注入到 System Prompt
    1. 15.1. 15.1 加载时机与调用链
    2. 15.2. 15.2 注入内容结构
    3. 15.3. 15.3 truncateEntrypointContent 双重截断
    4. 15.4. 15.4 KAIROS 模式:日志驱动记忆
  16. 16. 十六、面试深度题(进阶版)
【Claude Code源码剖析】23-MemDir 自动记忆系统

源码路径:src/memdir/(8个文件)
这是 CC 的”长期记忆”机制——让 Claude 在会话之间保持对用户习惯、项目状态、历史反馈的持久记忆。


一、核心概念

问题

LLM 上下文窗口有限,每次新会话 Claude 都”失忆”——不记得用户偏好、项目约定、历史决策。

解决方案

MemDir(Memory Directory):在文件系统上维护结构化的记忆文件,每次会话启动时注入 System Prompt,让 Claude “想起”以往的信息。


二、目录结构

~/.claude/projects/<project-slug>/memory/   ← 个人自动记忆(auto memory)
MEMORY.md ← 入口索引文件(最多200行/25KB)
user/ ← 用户画像记忆
feedback/ ← 行为反馈记忆
project/ ← 项目状态记忆
reference/ ← 参考资料记忆
team/ ← 团队共享记忆(TEAMMEM 特性)
MEMORY.md
...

三、记忆类型分类法(四大类型)

CC 将记忆严格约束为四种类型,不存储可从代码/git推导出的信息

类型 范围 存储内容 示例
user 永远 private 用户角色、目标、技能水平 “是数据科学家,精通Go但不熟悉React”
feedback 默认 private,全局约定→team 用户对 Claude 行为的纠正和确认 “不要用 mock 数据库,上季度出过事故”
project 强烈偏向 team 进行中的工作、目标、bug、incident “周四前需要完成 migration,Alice 在做 API 层”
reference private 或 team 无法从当前项目推导的外部资料 “该客户使用 UTC+8,日志时区需转换”

关键约束:代码架构、文件结构、git 历史 → 不存(可通过 grep/git 实时获取)。


四、MEMORY.md 入口文件

最大限制:200行 OR 25,000字节(先触发者截断)
截断行为:追加警告文本提示模型
格式:Markdown + Frontmatter
---
type: user
created: 2026-05-01
---
# 用户画像
- 高级后端工程师,10年Go经验
- 正在学习React,需要类比解释

truncateEntrypointContent() 负责截断并追加警告:

> WARNING: MEMORY.md is 250 lines (limit: 200). Only part of it was loaded.
> Keep index entries to one line under ~200 chars; move detail into topic files.

五、记忆加载流程

Session 启动


loadMemoryPrompt() ← 由 systemPromptSection 缓存(每会话只算一次)

├─ isAutoMemoryEnabled()?
│ ├─ NO → 跳过
│ └─ YES →
│ ├─ ensureMemoryDirExists(autoDir) ← Harness 保证目录存在
│ ├─ 读取 MEMORY.md(主索引)
│ ├─ 读取各子目录文件
│ └─ buildMemoryLines()

├─ TEAMMEM 特性开启?
│ └─ YES →
│ ├─ ensureMemoryDirExists(teamDir)
│ └─ buildCombinedMemoryPrompt() ← 个人 + 团队合并

└─ 注入 System Prompt

关键设计DIR_EXISTS_GUIDANCE 文本告知 Claude “目录已存在,直接用 Write 工具写入,不要先 mkdir”——节省了每次写记忆都要检查目录的 turn。


六、记忆写入时机

Claude 在以下情况主动写入记忆(通过 FileWriteTool):

  1. 用户纠正 Claude 的行为(feedback 类型)
  2. 用户确认某种非显然的做法有效(feedback 类型)
  3. 了解到用户背景信息(user 类型)
  4. 获知项目进展/目标(project 类型)

写入规则

  • 记忆文件有 Frontmatter(type:, created:
  • 反馈记忆包含 Why:How to apply: 段落(便于未来判断边界情况)
  • 团队记忆放在 team/ 子目录,个人记忆放主目录

七、团队记忆(TEAMMEM)

Swarm 模式下,Leader 和 Teammate 共享 team/ 目录:

// 团队记忆路径:~/.claude/teams/<teamName>/memory/
// Leader 写入 team/ 的记忆,所有 Teammate 都能读到
// buildCombinedMemoryPrompt() 合并个人 + 团队记忆

用途:项目级约定(如代码风格、测试策略)写入 team 记忆,每个 Agent 都自动遵守。


八、findRelevantMemories — AI 驱动的语义检索

这是 MemDir 最精妙的设计:不加载全部记忆,而是用一个轻量 Sonnet 调用(Side Query)来选择最相关的记忆文件。

8.1 整体流程

用户发出查询

scanMemoryFiles(memoryDir) # Step 1: 扫描所有 .md 文件,读取 frontmatter

过滤 alreadySurfaced # 排除本轮已展示过的记忆

formatMemoryManifest(memories) # 生成"记忆清单"(filename + description 摘要)

sideQuery(Sonnet, manifest+query) # Step 2: 用 Sonnet 选择相关文件名(最多5个)

返回选中文件的绝对路径 + mtime

8.2 scanMemoryFiles 扫描机制(memoryScan.ts)

// 单次 readdir 递归扫描(包含子目录)
// 过滤条件:.md 文件且非 MEMORY.md(入口文件已在 System Prompt 中)
// 每个文件:读前 30 行(FRONTMATTER_MAX_LINES)提取 frontmatter
// 按 mtime 降序排序(最新的记忆优先评估)
// 上限:MAX_MEMORY_FILES = 200 个文件

type MemoryHeader = {
filename: string // 相对路径(用于 Sonnet 决策)
filePath: string // 绝对路径(用于后续加载)
mtimeMs: number // 修改时间(freshness 信号)
description: string | null // frontmatter 中的 description 字段
type: MemoryType | undefined // user/feedback/project/reference
}

性能优化readFileInRange 只读前 30 行,内部同时 stat 获取 mtime,一次 syscall 同时完成”读内容”和”获取时间戳”。

8.3 Sonnet Side Query 选择机制

const SELECT_MEMORIES_SYSTEM_PROMPT = `
You are selecting memories that will be useful to Claude Code as it processes a user's query.
Return filenames for memories that will CLEARLY be useful (up to 5).
Be selective and discerning — if unsure, don't include it.
If recent tools list is provided, skip usage-reference memories for those tools
(Claude is already exercising them). DO still select memories with warnings/gotchas
about those tools — active use is exactly when those matter.
`

关键设计

  • 最多选 5 个:控制注入到上下文的记忆总量
  • 工具感知过滤:传入 recentTools 列表,避免选”已在用”工具的使用说明
  • 仍选警告类:即使工具在用,仍选该工具的 gotcha/已知 bug 记忆

8.4 alreadySurfaced 去重

// 跨轮次去重:本轮已展示过的记忆不再重复选
const alreadySurfaced: ReadonlySet<string>

// 在 Sonnet 调用前过滤,节省 Sonnet 的 5-slot 预算
memories.filter(m => !alreadySurfaced.has(m.filePath))

8.5 mtime 线程穿透

type RelevantMemory = {
path: string // 文件路径
mtimeMs: number // 修改时间(透传给主模型)
}

主模型收到记忆时可以看到”这条记忆最后更新时间”,判断是否过时(project 类型记忆尤其重要,快速过时)。

8.6 与 MEMORY.md 的分工

机制 加载时机 内容 令牌成本
MEMORY.md 每次会话启动 全部索引(200行上限) 固定开销
findRelevantMemories 每次用户查询 动态选 ≤5 个文件全文 按需开销

这是一个两阶段检索:MEMORY.md 提供快速索引,Sonnet 根据查询语义精选详细文件。


九、面试要点

Q:CC 的记忆系统与 RAG 有什么区别?

CC 的 MemDir 是”主动写入式”记忆:Claude 在对话中主动识别值得记住的信息并写入文件。RAG 是”被动检索式”:从固定知识库向量检索。MemDir 更像人类的笔记本——Claude 自己整理、分类、持续更新;RAG 更像图书馆——只读、不更新。

Q:为什么要限制 MEMORY.md 最多 200 行?

MEMORY.md 会被注入 System Prompt,受 prompt cache 约束,过长会增加每次请求的 token 成本,并稀释模型对其他重要信息的注意力。200 行约 25KB 是经验得出的性价比边界。


十、findRelevantMemories 两阶段检索深度剖析

10.1 整体架构

findRelevantMemories 是 MemDir 的查询时检索引擎,实现了一个经典的”粗排 + 精排”两阶段架构:

用户查询 query


[第一阶段:文件系统扫描] memoryScan.ts::scanMemoryFiles()
│ - readdir 递归扫描 memdir
│ - 并行读取每个 .md 文件前 30 行(frontmatter)
│ - 解析 description / type / mtime
│ - 按 mtime 降序排列,最多保留 200 个候选


[过滤:alreadySurfaced 去重]
│ - 过滤掉已在本次对话前几轮出现过的记忆路径
│ - 避免同一文件被重复选中占用 5-slot 预算


[第二阶段:Sonnet 语义精选] selectRelevantMemories()
│ - 将候选列表格式化为 manifest(filename + description + type + timestamp)
│ - 附加 recentTools 提示(避免把正在使用的工具文档误选为相关)
│ - sideQuery 调用 Sonnet,JSON Schema 输出 selected_memories: string[]
│ - 最多返回 5 个文件名


[返回 RelevantMemory[]:{path, mtimeMs}]

10.2 第一阶段:memoryScan.ts 文件系统扫描

源码位置src/memdir/memoryScan.ts

核心设计决策

const MAX_MEMORY_FILES = 200
const FRONTMATTER_MAX_LINES = 30

export async function scanMemoryFiles(
memoryDir: string,
signal: AbortSignal,
): Promise<MemoryHeader[]> {
const entries = await readdir(memoryDir, { recursive: true }) // 递归一次性列出
const mdFiles = entries.filter(
f => f.endsWith('.md') && basename(f) !== 'MEMORY.md', // 排除入口索引
)

const headerResults = await Promise.allSettled(
mdFiles.map(async (relativePath): Promise<MemoryHeader> => {
const filePath = join(memoryDir, relativePath)
const { content, mtimeMs } = await readFileInRange(
filePath, 0, FRONTMATTER_MAX_LINES, // 只读前30行
undefined, signal,
)
const { frontmatter } = parseFrontmatter(content, filePath)
return { filename: relativePath, filePath, mtimeMs,
description: frontmatter.description || null,
type: parseMemoryType(frontmatter.type) }
}),
)

return headerResults
.filter(r => r.status === 'fulfilled')
.map(r => r.value)
.sort((a, b) => b.mtimeMs - a.mtimeMs) // 最新优先
.slice(0, MAX_MEMORY_FILES) // 硬上限 200
}

关键设计点

设计 理由
readdir({ recursive: true }) 一次调用 避免深度优先递归产生大量 stat 系统调用,单次 readdir 拿到所有路径
只读前 30 行 frontmatter 一般在文件头部,无需读全文,大幅减少 I/O
Promise.allSettled 而非 Promise.all 单个文件读取失败不阻塞其他文件,静默跳过坏文件
排除 MEMORY.md 入口索引已在 System Prompt 中加载,无需进入检索池
按 mtime 降序 + 截取 200 优先考虑最近修改的记忆,限制规模避免 LLM 上下文超限
单遍扫描(read-then-sort) 比 stat-sort-read 节省一半 syscall;N≤200 时读少量多余文件代价可接受

MemoryHeader 数据结构

type MemoryHeader = {
filename: string // 相对于 memoryDir 的路径,如 "feedback/no_summaries.md"
filePath: string // 绝对路径
mtimeMs: number // 文件修改时间(毫秒时间戳)
description: string | null // frontmatter 中的 description 字段
type: MemoryType | undefined // user | feedback | project | reference
}

formatMemoryManifest:候选列表的序列化格式

export function formatMemoryManifest(memories: MemoryHeader[]): string {
return memories.map(m => {
const tag = m.type ? `[${m.type}] ` : ''
const ts = new Date(m.mtimeMs).toISOString()
return m.description
? `- ${tag}${m.filename} (${ts}): ${m.description}`
: `- ${tag}${m.filename} (${ts})`
}).join('\n')
}

输出示例:

- [feedback] feedback/no_summaries.md (2026-05-20T10:00:00.000Z): 用户不喜欢响应末尾的总结段落
- [user] user/role.md (2026-05-18T08:30:00.000Z): 用户是数据科学家,关注可观测性
- [project] project/merge_freeze.md (2026-05-15T14:00:00.000Z): 合并冻结从2026-05-22开始

这个格式是精心设计的:Sonnet 只需要 filename + description,timestamp 作为时效性辅助,type 作为分类提示。

10.3 第二阶段:Sonnet 语义精选

完整 System PromptSELECT_MEMORIES_SYSTEM_PROMPT):

You are selecting memories that will be useful to Claude Code as it processes
a user's query. You will be given the user's query and a list of available
memory files with their filenames and descriptions.

Return a list of filenames for the memories that will clearly be useful to
Claude Code as it processes the user's query (up to 5). Only include memories
that you are certain will be helpful based on their name and description.
- If you are unsure if a memory will be useful in processing the user's
query, then do not include it in your list. Be selective and discerning.
- If there are no memories in the list that would clearly be useful, feel
free to return an empty list.
- If a list of recently-used tools is provided, do not select memories that
are usage reference or API documentation for those tools (Claude Code is
already exercising them). DO still select memories containing warnings,
gotchas, or known issues about those tools — active use is exactly when
those matter.

sideQuery 参数

const result = await sideQuery({
model: getDefaultSonnetModel(), // claude-sonnet-* 侧边查询
system: SELECT_MEMORIES_SYSTEM_PROMPT,
skipSystemPromptPrefix: true, // 不注入 CC 的主系统提示,保持轻量
messages: [{
role: 'user',
content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}`,
}],
max_tokens: 256, // 输出极小:只需文件名列表
output_format: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
selected_memories: { type: 'array', items: { type: 'string' } },
},
required: ['selected_memories'],
additionalProperties: false,
},
},
signal,
querySource: 'memdir_relevance',
})

关键设计

  • max_tokens: 256:极低 token 上限,因为输出只是文件名列表,防止模型冗余输出
  • output_format 强制 JSON Schema:结构化输出,避免解析失败
  • skipSystemPromptPrefix: true:不携带主系统提示,该 sideQuery 是独立的轻量判断,无需 CC 的全套上下文
  • 结果经过 validFilenames 白名单过滤:防止模型幻觉出不存在的文件名

10.4 recentTools 过滤:避免”正在用的工具文档”噪音

const toolsSection =
recentTools.length > 0
? `\n\nRecently used tools: ${recentTools.join(', ')}`
: ''

场景:用户在用 mcp__git__spawn,此时查询中含 “spawn”,恰好也有一个 memory 文件描述 spawn API 参数。如果不做过滤,Sonnet 会误选这个参考文档——但主对话已经在使用该工具,文档是噪音。

但例外:含 warnings/gotchas/known issues 的记忆仍然选——正在使用时恰恰是最需要知道潜在陷阱的时刻。

10.5 alreadySurfaced 去重:防止 5-slot 预算浪费

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

alreadySurfaced 是调用方(通常是对话循环)传入的 ReadonlySet<string>,记录本次对话中已向模型展示过的记忆路径。在过滤之后再进行 Sonnet 精选,保证 5-slot 预算全部用于”新鲜候选”。

10.6 遥测:记忆召回形状

if (feature('MEMORY_SHAPE_TELEMETRY')) {
const { logMemoryRecallShape } = require('./memoryShapeTelemetry.js')
logMemoryRecallShape(memories, selected) // 即使 selected 为空也上报
}

即使一条记忆都没选中也会触发上报。注释明确说明:selection-rate needs the denominator(选择率统计需要分母),且 -1 ages 区分”运行了但没选中”与”从未运行”。


十一、extractMemories 自动提取机制深度剖析

11.1 触发时机

extractMemories 不是实时触发,而是在每次完整查询循环结束时(模型产生最终响应、无挂起工具调用)由 stopHooks.ts 中的 handleStopHooks 异步调用:

用户提问 → 模型响应(含工具调用) → ... → 模型最终响应(无 tool_use)

└─ handleStopHooks

└─ executeExtractMemories() ← fire-and-forget

触发前的守卫链executeExtractMemoriesImpl 中):

// 1. 仅主 Agent 触发,子 Agent 不触发
if (context.toolUseContext.agentId) return

// 2. Feature gate:tengu_passport_quail GrowthBook flag
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) return

// 3. auto-memory 未被禁用
if (!isAutoMemoryEnabled()) return

// 4. 非远程模式
if (getIsRemoteMode()) return

// 5. 频率节流:tengu_bramble_lintel 控制每 N 轮才触发一次(默认 1)
if (turnsSinceLastExtraction < threshold) return

11.2 forkedAgent 模式:共享 prompt cache

extractMemories 使用完美 forkrunForkedAgent)而非独立会话:

const cacheSafeParams = createCacheSafeParams(context)

const result = await runForkedAgent({
promptMessages: [createUserMessage({ content: userPrompt })],
cacheSafeParams, // 共享父会话的 system prompt + 消息前缀
canUseTool, // 受限工具集(仅读 + 写 memdir)
querySource: 'extract_memories',
forkLabel: 'extract_memories',
skipTranscript: true, // 不写入 transcript,避免主线程竞争
maxTurns: 5, // 硬上限:2-4 轮正常完成(读→写)
})

为什么用 fork 而不是独立会话?

fork 继承父会话的完整消息历史(也就是待提取内容本身),并且共享 prompt cache。独立会话需要把所有内容重新传输,cache 命中率会大幅下降,额外花费大量 token。

11.3 互斥机制:主 Agent 写了记忆则跳过

function hasMemoryWritesSince(messages, sinceUuid): boolean {
// 扫描 sinceUuid 之后的 assistant 消息
// 检查是否有 tool_use 块(Write/Edit)且 file_path 在 memdir 下
}

if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
// 主 agent 自己写了,跳过 forked extraction
// 但仍然推进游标
lastMemoryMessageUuid = messages.at(-1)?.uuid
return
}

设计哲学:主 Agent 的系统提示已经包含完整的记忆写入指令。当用户明确说”记住这个”时,主 Agent 会直接写。后台提取代理只是”兜底”——当主 Agent 遗漏时补充提取。两者互斥,避免重复写入。

11.4 串行化:in-flight 保护与 trailing run

let inProgress = false
let pendingContext: { context, appendSystemMessage } | undefined

if (inProgress) {
// 正在提取中:保存最新上下文,等当前轮完成后执行 trailing run
pendingContext = { context, appendSystemMessage }
return
}

// runExtraction finally 块:
const trailing = pendingContext
pendingContext = undefined
if (trailing) {
await runExtraction({ ...trailing, isTrailingRun: true })
}

多次触发时的行为:

  • 第 1 次:立即开始提取,设 inProgress = true
  • 第 2、3 次(在第 1 次未完成时):只保存最新 context(覆盖),不启动新的提取
  • 第 1 次完成后:用最后一次保存的 context 执行 trailing run

注意 pendingContext 只保存最新的——中间被覆盖的 context 丢弃,因为 trailing run 会用最新消息计算游标差,捕获所有积累的内容。

11.5 提取 Prompt 构造

buildExtractAutoOnlyPrompt 的 opener 核心

You are now acting as the memory extraction subagent. Analyze the most
recent ~${newMessageCount} messages above and use them to update your
persistent memory systems.

Available tools: Read, Grep, Glob, read-only Bash (ls/find/cat/stat/wc/
head/tail), and Edit/Write for paths inside the memory directory only.
Bash rm is not permitted.

You have a limited turn budget. Edit requires a prior Read of the same file,
so the efficient strategy is: turn 1 — issue all Read calls in parallel for
every file you might update; turn 2 — issue all Write/Edit calls in parallel.

You MUST only use content from the last ~${newMessageCount} messages to
update your persistent memories. Do not waste any turns attempting to
investigate or verify that content further — no grepping source files, no
reading code to confirm a pattern exists, no git commands.

## Existing memory files
- [feedback] feedback/no_summaries.md (2026-05-20T10:00:00.000Z): ...
...(预注入 manifest,省去第一轮 ls)

关键约束

  • 明确声明 maxTurns: 5,且 prompt 解释了最优策略(并行读→并行写,2轮完成)
  • 只能基于近 N 条消息,不得额外 grep 源码验证(避免 rabbit-hole)
  • 预注入现有记忆列表(scanMemoryFiles 的结果),省去第一轮 ls 调用

11.6 记忆文件去重策略

代理被要求在写入前检查是否已有可更新的记忆文件:

  • Prompt 明确说:Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
  • 预注入的 manifest 包含所有现有文件的 description,让代理在无需 Read 的情况下判断是否有重叠
  • MEMORY.md 的更新是机械性的(加一行索引),不计入实际记忆数量(memoryPaths 过滤掉了 MEMORY.md

11.7 工具权限沙箱

createAutoMemCanUseTool 为提取代理创建受限的工具权限:

export function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn {
return async (tool, input) => {
if (tool.name === REPL_TOOL_NAME) return allow // REPL 透传
if (tool.name is Read/Grep/Glob) return allow // 读操作无限制
if (tool.name === BASH && isReadOnly(input)) return allow // 只读 bash
if (tool.name is Edit/Write && isAutoMemPath(input.file_path)) return allow
return deny("only Read/Grep/Glob/read-only Bash/Edit+Write within memdir")
}
}

Write/Edit 必须写到 isAutoMemPath() 返回 true 的路径,防止提取代理意外修改项目文件。


十二、记忆文件格式规范

12.1 Frontmatter 字段

每个记忆文件必须以 YAML frontmatter 开头(parseFrontmatter 解析):

---
name: 用户偏好:不要响应末尾总结
description: 用户明确要求停止在每次回复末尾添加"总结段落",认为这是冗余噪音
type: feedback
---

停止在回复末尾添加"刚才做了什么"的总结段落。

**Why:** 用户说"我能看 diff"——他们是有经验的开发者,不需要手把手解释。

**How to apply:** 在每次回复完成后,检查是否有类似"总之,我..."或"综上所述..."的段落,有则删除。

三个必须字段

字段 作用 使用位置
name 人类可读标题 未直接用于检索,但约定要存在
description 一行精准描述,用于 Sonnet 精选时判断相关性 formatMemoryManifest 输出,传给精选 Sonnet
type user / feedback / project / reference memoryScan.ts 解析,用于 manifest 格式化标签

description 是检索性能的关键:Sonnet 精选阶段看不到文件正文,只看 description。写得模糊则召回率低,写得精准则能在 N≤200 候选中被准确命中。

12.2 四种记忆类型的内容规范

类型 内容 体结构建议 时效性
user 用户角色、专业背景、沟通偏好 无固定格式,以人物画像为主 长期有效
feedback 被纠正/确认的行为模式 规则 + Why: + How to apply: 长期有效,可被后续否定
project 进行中的工作、截止日期、决策背景 事实/决策 + Why: + How to apply: 快速衰减(weeks)
reference 外部系统的路径/入口(Linear、Grafana 等) 指针 + 用途说明 中期有效,需验证

12.3 文件命名规则

无强制命名约定,Prompt 给出的示例是语义化命名:

  • user_role.md(用户角色)
  • feedback_testing.md(测试相关反馈)
  • project_merge_freeze.md(合并冻结项目状态)

实际文件名只影响 filename 字段(用于 manifest),不影响检索性能(检索靠 description)。建议:{type}_{topic}.md

12.4 MEMORY.md 索引格式

- [用户偏好:不要响应末尾总结](feedback/no_summaries.md) — 用户明确要求停止添加总结段落
- [用户画像:数据科学家](user/role.md) — 深度 Go 经验,React 新手
- [合并冻结 2026-05-22](project/merge_freeze.md) — 移动端发布分支切出

每行约 150 字符以内,格式:- [Title](file.md) — one-line hookMEMORY.md 是纯索引,不含 frontmatter,不含记忆正文。


十三、memdir 路径体系(paths.ts)

13.1 路径解析优先级

export const getAutoMemPath = memoize((): string => {
// 优先级 1:env var CLAUDE_COWORK_MEMORY_PATH_OVERRIDE(Cowork 全路径覆盖)
// 优先级 2:settings.json 中 autoMemoryDirectory(支持 ~/ 展开)
// 来源:policySettings > flagSettings > localSettings > userSettings
// 安全限制:projectSettings 被明确排除(防止恶意仓库写入 ~/.ssh)
// 优先级 3:默认路径 ~/.claude/projects/{sanitizePath(gitRoot)}/memory/
const override = getAutoMemPathOverride() ?? getAutoMemPathSetting()
if (override) return override
const projectsDir = join(getMemoryBaseDir(), 'projects')
return join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep
}, () => getProjectRoot()) // 按 projectRoot 缓存,同一会话只计算一次

13.2 默认路径结构

~/.claude/
projects/
{sanitizePath(gitRoot)}/ ← 使用 git 规范根(所有 worktree 共享一个目录)
memory/ ← AUTO_MEM_DIRNAME
MEMORY.md ← 索引入口
user/
feedback/
project/
reference/
team/ ← TEAMMEM 特性
MEMORY.md
logs/ ← KAIROS 模式:日志驱动记忆
2026/05/2026-05-27.md

getAutoMemBase() 使用 git 规范根

function getAutoMemBase(): string {
return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot()
}

同一 git 仓库的所有 worktree 共享同一个 memory/ 目录(通过规范根 findCanonicalGitRoot 实现)。这是 Issue #24382 的修复:worktree 切换不会导致记忆目录碎片化。

13.3 路径安全校验

validateMemoryPath 对自定义路径进行多层安全检查:

function validateMemoryPath(raw, expandTilde): string | undefined {
// 拒绝:相对路径("../foo")
if (!isAbsolute(normalized)) return undefined
// 拒绝:过短路径("/" → "","/a")
if (normalized.length < 3) return undefined
// 拒绝:Windows 盘符根("C:")
if (/^[A-Za-z]:$/.test(normalized)) return undefined
// 拒绝:UNC 路径("\\server\share","//...")
if (normalized.startsWith('\\\\') || normalized.startsWith('//')) return undefined
// 拒绝:null byte(可截断 syscall)
if (normalized.includes('\0')) return undefined
// 返回 NFC 标准化 + 尾部分隔符
return (normalized + sep).normalize('NFC')
}

为什么排除 projectSettings? .claude/settings.json 是 git 追踪的文件,恶意仓库可设置 autoMemoryDirectory: "~/.ssh" 获得写入权限(filesystem.ts 的 write carve-out 对 isAutoMemPath() 的路径放行)。policySettings/localSettings/userSettings 都不在仓库内,可信。

13.4 isAutoMemEnabled 优先级链

CLAUDE_CODE_DISABLE_AUTO_MEMORY=1  →  禁用
CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 → 启用(明确覆盖)
CLAUDE_CODE_SIMPLE=1 → 禁用(--bare 模式)
CLAUDE_CODE_REMOTE=1 且无 CLAUDE_CODE_REMOTE_MEMORY_DIR → 禁用
settings.json autoMemoryEnabled → 遵从设置
默认 → 启用

十四、sessionMemoryCompact 会话记忆压缩

14.1 与全局 compact 的区别

维度 传统 compact sessionMemoryCompact
触发 上下文窗口接近上限时 同上,但优先尝试 SM 路径
摘要来源 调用 Claude 生成摘要(额外 API 开销) 从 SessionMemory 文件读取已有摘要
token 成本 高(需 LLM 生成摘要) 低(直接读文件,无 LLM 调用)
信息完整性 依赖摘要质量 依赖 SessionMemory 提取质量
适用前提 tengu_session_memory + tengu_sm_compact 双 flag 均开启

14.2 SM Compact 触发流程

export async function trySessionMemoryCompaction(
messages, agentId?, autoCompactThreshold?,
): Promise<CompactionResult | null> {

if (!shouldUseSessionMemoryCompaction()) return null // flag 检查

await initSessionMemoryCompactConfig() // 从 GrowthBook 拉取阈值配置
await waitForSessionMemoryExtraction() // 等待正在进行的 SM 提取完成

const lastSummarizedMessageId = getLastSummarizedMessageId()
const sessionMemory = await getSessionMemoryContent()

if (!sessionMemory) return null // 无 SM 文件,降级到传统 compact
if (isSessionMemoryEmpty(sessionMemory)) return null // SM 是空模板,降级

// 计算保留消息的起始位置
const startIndex = calculateMessagesToKeepIndex(messages, lastSummarizedIndex)
// 过滤掉旧的 compact boundary 消息
const messagesToKeep = messages.slice(startIndex).filter(m => !isCompactBoundaryMessage(m))

// 检查压缩后 token 数是否超过阈值(autoCompact 场景)
if (autoCompactThreshold && postCompactTokenCount >= autoCompactThreshold) return null

return createCompactionResultFromSessionMemory(...)
}

14.3 保留消息计算:calculateMessagesToKeepIndex

配置参数(来自 GrowthBook tengu_sm_compact_config,默认值):

const DEFAULT_SM_COMPACT_CONFIG = {
minTokens: 10_000, // 最少保留 10K token 的对话
minTextBlockMessages: 5, // 最少保留 5 条有文本块的消息
maxTokens: 40_000, // 最多保留 40K token(防止压缩无效)
}

算法逻辑:

从 lastSummarizedIndex + 1 开始(已被 SM 摘要的消息之后)

检查当前 token 数和文本消息数是否已满足 min 约束

如果不满足:向前扩展(包含更早的消息),直到满足或达到 maxTokens

floor 限制:不能越过最近的 compact boundary(磁盘有断点)

adjustIndexToPreserveAPIInvariants:
- 不拆分 tool_use / tool_result 配对
- 不拆分 thinking block(相同 message.id 的 assistant 消息必须整组保留)

14.4 压缩结果的持久化

function createCompactionResultFromSessionMemory(...): CompactionResult {
const summaryContent = getCompactUserSummaryMessage(
truncatedContent, // 截断过长的 SM sections
true,
transcriptPath,
true,
)

return {
boundaryMarker, // CompactBoundaryMessage(记录压缩前 token 数、最后消息 UUID)
summaryMessages, // 包含 SM 内容的 UserMessage(isCompactSummary: true)
messagesToKeep, // 压缩后保留的消息片段
attachments, // 可能包含 plan attachment
preCompactTokenCount,
postCompactTokenCount,
truePostCompactTokenCount,
}
}

压缩后的 SM 内容以 isCompactSummary: true 标记,在 transcript 中可见但 isVisibleInTranscriptOnly: true,不直接进入 API 调用的消息流(由 buildPostCompactMessages 重组)。


十五、记忆注入到 System Prompt

15.1 加载时机与调用链

会话启动
└─ loadMemoryPrompt() [memdir.ts]
├─ KAIROS 模式:buildAssistantDailyLogPrompt()
├─ TEAMMEM 模式:buildCombinedMemoryPrompt()
└─ 普通模式:buildMemoryLines() → 返回 string

└─ systemPromptSection('memory', loadMemoryPrompt)
└─ 注入 System Prompt(带 prompt cache 标记)

15.2 注入内容结构

loadMemoryPrompt 返回的字符串包含:

# auto memory

You have a persistent, file-based memory system at `~/.claude/projects/.../memory/`.
This directory already exists — write to it directly with the Write tool.

[类型说明、保存/读取指南、不应保存的内容...]

## MEMORY.md

- [Title](file.md) — one-line hook
...(最多200行/25KB 的索引内容)

token 预算MAX_ENTRYPOINT_BYTES = 25_000(约 25KB)。注意这是每次请求都会携带的固定成本,受 prompt cache 保护(稳定部分被缓存,动态部分—实际 MEMORY.md 内容—会随记忆更新而失效缓存)。

15.3 truncateEntrypointContent 双重截断

export function truncateEntrypointContent(raw: string): EntrypointTruncation {
// 步骤 1:行数截断(200行)
const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES
let truncated = wasLineTruncated
? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n')
: trimmed

// 步骤 2:字节截断(25000 bytes)在最近换行处切割
if (truncated.length > MAX_ENTRYPOINT_BYTES) {
const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES)
truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES)
}

// 追加警告说明触发了哪个上限
return { content: truncated + '\n\n> WARNING: ...', ... }
}

行截断先于字节截断的原因:行是语义边界(一行一条索引),优先在语义边界截断;字节上限是兜底,处理超长行(单行 >125 字符的异常情况,p100 实测 197KB 超过 200 行上限但字节超标)。

15.4 KAIROS 模式:日志驱动记忆

对于长期运行的 assistant 会话(feature('KAIROS') && getKairosActive()),记忆策略完全不同:

普通模式:维护 MEMORY.md + 主题文件(实时更新)
KAIROS 模式:只追加写日志文件 logs/YYYY/MM/YYYY-MM-DD.md
夜间 /dream 技能蒸馏日志 → 主题文件 + MEMORY.md

日志路径按日期分组,Prompt 里描述的是模式路径(logs/YYYY/MM/YYYY-MM-DD.md)而非今天的字面路径,保证 prompt cache 跨日期有效(防止日期变更导致每天缓存失效)。


十六、面试深度题(进阶版)

Q1:为什么记忆检索用文件系统而不是向量数据库?

核心原因有三:

  1. 无需嵌入基础设施:向量数据库需要 embedding 模型、索引维护、相似度搜索服务,显著增加部署复杂度。MemDir 只需文件系统 + Sonnet sideQuery。
  2. LLM 本身是最好的语义检索器:Sonnet 理解 description 的语义,能做 “query 说’测试数据库’,description 说’不 mock 数据库’→ 高相关” 这样的跨语义匹配,这是向量相似度难以准确捕获的。
  3. 记忆数量 N 很小(上限 200):向量数据库在 N=10^6 时有优势,在 N=200 时 overhead 完全不值得。两阶段架构(文件扫描 + Sonnet 精选)在这个规模下延迟更低、成本更小。
    代价:无法对记忆正文做全文语义搜索(只能搜 description)。但这是刻意的设计边界——description 是对内容的精准摘要,足够用于判断相关性。

Q2:两阶段检索相比单阶段(直接让 Sonnet 读所有记忆全文)有什么优势?

  1. token 成本:200 个记忆文件如果每个 1KB,直接传递全文需要 200KB+ token。两阶段只传 manifest(每条约 100 字符,200 条约 3KB),精选后最多读 5 个全文(约 5KB)。成本差了约 40 倍。
  2. 延迟:manifest 构建是纯 I/O(只读 30 行 frontmatter),比读全文快;sideQuery 的 max_tokens=256 极小,比大上下文推理快。
  3. 质量:Sonnet 在小上下文(manifest)中精选比在大上下文(全文)中噪音更少,不会被无关正文内容干扰判断。

Q3:记忆系统的一致性风险有哪些?如何缓解?

主要风险:

  • 过时记忆(stale memory):project 类型记忆衰减快,但系统无自动过期机制。缓解:系统提示包含 MEMORY_DRIFT_CAVEAT——推荐模型在使用记忆前验证(如检查文件是否仍存在、函数是否仍存在)。
  • 写入竞争:主 Agent 写记忆 vs 后台 extractMemories 写记忆可能产生覆盖。缓解:hasMemoryWritesSince 互斥机制——同一轮只有一方写入。
  • 游标丢失lastMemoryMessageUuid 是内存状态,进程崩溃后丢失,下次会话重新从头提取。缓解:extractMemories 的去重 Prompt 保证”先检查再写,有则更新,无则新建”。
  • MEMORY.md 索引与文件不同步:跳过 skipIndex 模式下不写 MEMORY.md,记忆文件存在但索引没有记录。缓解:检索走 scanMemoryFiles(直接扫文件系统),不依赖 MEMORY.md 的完整性。

Q4:extractMemories 的 forked agent 共享 prompt cache 是如何实现的?如果主会话有 100K token,fork 会导致什么?

createCacheSafeParams(context) 将主会话的 messages(截止当前)和 system prompt 封装成参数传给 runForkedAgent。由于 Anthropic API 的 prompt cache 以消息前缀为 key,fork 和主会话共享相同的消息前缀,cache 命中率极高(extractMemories 日志中可以看到 hitPct 统计)。

100K token 的会话场景:fork 的 API 调用携带完整的 100K token 历史,但其中绝大部分命中 cache(只收 cache read 费用,约为 base price 的 10%)。真正新增的只有 extraction userPrompt 和 agent 的 5 轮输出(约 1-2K token)。整体额外成本约等于 1-2K 的 output token + 100K 的 cache read token,远小于用独立会话重传 100K 的成本。

打赏
  • 微信
  • 支付宝

评论