第二章:Claude Code CLI 记忆的存储结构、Frontmatter 规范与加载流程
第二章:Claude Codel CLI记忆的存储结构、Frontmatter 规范与加载流程深度解析
第二章:记忆的存储结构、Frontmatter 规范与加载流程
系列说明:本章深入 Claude Code 记忆系统的存储层——文件路径如何解析、记忆文件遵循什么格式规范、记忆如何被加载进系统提示,以及截断逻辑的精细设计。
一、存储位置:路径解析的优先级链
源文件:
memdir/paths.ts
Claude Code 的记忆文件存储在本地磁盘,路径由以下优先级链决定(先定义者优先)。在路径解析之前,系统首先确认记忆功能是否启用。
1.0 总开关:isAutoMemoryEnabled()
export function isAutoMemoryEnabled(): boolean {
const envVal = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY
if (isEnvTruthy(envVal)) return false // 1. 环境变量明确禁用
if (isEnvDefinedFalsy(envVal)) return true // 2. 环境变量明确启用
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) return false // 3. --bare 模式
if (
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
!process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
) return false // 4. 远程模式但无持久存储
const settings = getInitialSettings()
if (settings.autoMemoryEnabled !== undefined)
return settings.autoMemoryEnabled // 5. settings.json 配置
return true // 6. 默认开启
}
5 级优先链,第一个命中的规则生效:
| 优先级 | 条件 | 结果 | 典型场景 |
|---|---|---|---|
| 1 | CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 |
OFF | CI 环境强制关闭 |
| 2 | CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 |
ON | 覆盖下层所有配置 |
| 3 | CLAUDE_CODE_SIMPLE=1(--bare) |
OFF | 极简模式 |
| 4 | CLAUDE_CODE_REMOTE=1 且无 CLAUDE_CODE_REMOTE_MEMORY_DIR |
OFF | 无持久存储的远程 session |
| 5 | settings.json 的 autoMemoryEnabled |
按值 | 项目级 opt-out |
| 6 | 以上未命中 | ON | 普通用户默认体验 |
isEnvTruthy 与 isEnvDefinedFalsy 配合实现了"三态":变量未定义时两者都返回 false,继续向下走;变量定义为 1/true 时 isEnvTruthy 命中;定义为 0/false 时 isEnvDefinedFalsy 命中。这让优先级链可以被任意层"穿透"或"截断"。
1.1 根目录解析:getMemoryBaseDir()
export function getMemoryBaseDir(): string {
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
return process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
}
return getClaudeConfigHomeDir() // 默认 ~/.claude
}
最简单的两路分支:有 CLAUDE_CODE_REMOTE_MEMORY_DIR 则使用远程路径(CCR 云端运行时场景),否则返回 ~/.claude。这个函数是 getAutoMemPath() 的第一个依赖。
1.2 最终路径:getAutoMemPath()
export const getAutoMemPath = memoize(
(): string => {
const override = getAutoMemPathOverride() ?? getAutoMemPathSetting()
if (override) {
return override
}
const projectsDir = join(getMemoryBaseDir(), 'projects')
return (
join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep
).normalize('NFC')
},
() => getProjectRoot(), // memoize key:按项目根目录缓存
)
三个关键细节:
-
memoize以getProjectRoot()为 key:渲染路径中isAutoManagedMemoryFile()会被每条消息调用,memoize 避免重复读取 settings.json(realpathSync + readFileSync)。 -
getAutoMemBase()使用findCanonicalGitRoot():
function getAutoMemBase(): string {
return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot()
}
这意味着同一个 Git 仓库的所有 worktree 共享同一个记忆目录[^1]——在 monorepo 或多 worktree 工作流中非常重要。
- Unicode NFC 规范化:路径末尾
.normalize('NFC')防止 macOS HFS+ 和 Linux 之间的文件名等价性问题。
1.3 索引文件路径:getAutoMemEntrypoint()
export function getAutoMemEntrypoint(): string {
return join(getAutoMemPath(), AUTO_MEM_ENTRYPOINT_NAME)
// AUTO_MEM_ENTRYPOINT_NAME = 'MEMORY.md'
}
getAutoMemPath() 返回目录(带尾部 /),此函数拼接 MEMORY.md 得到索引文件的完整绝对路径。在 buildMemoryPrompt() 中被用于 readFileSync 读取索引内容。
1.4 目录结构示意
~/.claude/
├── CLAUDE.md # 全局用户指令
├── projects/
│ └── <sanitized-git-root>/
│ └── memory/ # 自动记忆目录
│ ├── MEMORY.md # 索引文件(入口点)
│ ├── user_role.md
│ ├── feedback_testing_policy.md
│ ├── project_auth_rewrite.md
│ └── team/ # 团队记忆(需特性门控)
│ └── MEMORY.md
└── session-memory/
└── <session-uuid>.md
1.5 路径安全验证与归属检查
validateMemoryPath()(内部函数,不导出)对所有候选路径做安全校验:
// 拒绝以下危险模式:
if (
!isAbsolute(normalized) || // 相对路径
normalized.length < 3 || // 根路径 "/"
/^[A-Za-z]:$/.test(normalized) || // Windows 驱动器根 "C:"
normalized.startsWith('\\\\') || // UNC 网络路径
normalized.startsWith('//') ||
normalized.includes('\0') // 空字节(可截断 syscall)
)
hasAutoMemPathOverride()(memdir/paths.ts:194):
export function hasAutoMemPathOverride(): boolean {
return getAutoMemPathOverride() !== undefined
}
检测 CLAUDE_COWORK_MEMORY_PATH_OVERRIDE 是否设置了有效覆盖路径。这个布尔值在 filesystem.ts 的写权限逻辑里作为判断依据:当有 Cowork 覆盖时,不给予写权限豁免(因为 Cowork 路径是远程挂载点,不在本地危险目录列表里,豁免逻辑不适用);当是 settings.json 设置的自定义路径时,给予写权限豁免(用户显式配置的可信路径)。
isAutoMemPath()(memdir/paths.ts:274):
export function isAutoMemPath(absolutePath: string): boolean {
const normalizedPath = normalize(absolutePath)
return normalizedPath.startsWith(getAutoMemPath())
}
判断一个绝对路径是否位于记忆目录内。filesystem.ts 用这个函数实现写权限白名单——记忆目录内的文件可以绕过 DANGEROUS_DIRECTORIES 检查,允许 Claude 直接写入。必须先 normalize() 再 startsWith(),防止 ../memory/../memory/file.md 这类路径绕过检查。
一个重要的安全决策:projectSettings(.claude/settings.json,会被提交到代码库)不允许设置 autoMemoryDirectory。
原因(源码注释):“a malicious repo could otherwise set
autoMemoryDirectory: '~/.ssh'and gain silent write access to sensitive directories via the filesystem.ts write carve-out”
1.6 后台提取代理开关:isExtractModeActive()
export function isExtractModeActive(): boolean {
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) {
return false
}
return (
!getIsNonInteractiveSession() ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_thimble', false)
)
}
控制对话结束后的后台提取代理是否启动(第三章详述)。两层门控:
tengu_passport_quail:GrowthBook feature flag,false 则完全跳过提取代理!getIsNonInteractiveSession():非交互式 session(管道/stdin)默认不运行提取代理,避免在批量脚本场景产生不必要的后台 API 调用tengu_slate_thimble:实验性 flag,允许在非交互式 session 里也启用提取代理(A/B 测试用)
注意:调用方还必须额外判断 feature('EXTRACT_MEMORIES')——这是编译时 tree-shaking 用的 bundle feature flag,必须直接出现在 if 条件里,无法封装进此函数内。
二、两层存储结构:索引 + 内容分离
记忆系统采用两层存储设计:
┌─────────────────────────────────────────────────┐
│ MEMORY.md(索引层) │
│ 始终加载进系统提示,每条 ~150 字符,上限 200 行 │
│ - [用户角色](user_role.md) — Go专家,React新手 │
│ - [测试策略](feedback_testing.md) — 禁止mock DB │
└─────────────────────┬───────────────────────────┘
│ 按需召回
┌─────────────────────▼───────────────────────────┐
│ 具体记忆文件(内容层) │
│ user_role.md / feedback_testing.md / ... │
│ 包含完整记忆内容,含 frontmatter 元数据 │
└─────────────────────────────────────────────────┘
索引层的限制常数(memdir/memdir.ts):
export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200 // 行数上限
export const MAX_ENTRYPOINT_BYTES = 25_000 // 字节上限(~25KB)
为什么是 200 行?
MEMORY.md在每次对话都会被完整加载进系统提示。过大会消耗大量 token 且降低相关性密度,因此加了字节上限作为补充防护。
三、Frontmatter 规范:记忆文件格式
每个记忆文件以 YAML frontmatter 开头声明元数据,正文包含记忆内容。
3.1 格式定义
源码定义:
memdir/memoryTypes.ts—MEMORY_FRONTMATTER_EXAMPLE
---
name: {{memory name}}
description: {{one-line description — used to decide relevance in future conversations, so be specific}}
type: {{user, feedback, project, reference}}
---
{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}
3.2 各字段作用
| 字段 | 作用 | 重要性 |
|---|---|---|
name |
记忆文件的人类可读标题 | 显示在 UI 中 |
description |
用于相关性判断的核心字段 | 召回时 Sonnet 靠此选择 |
type |
四种类型之一 | 影响 scope 和提示逻辑 |
description 字段最为关键:在语义召回阶段(第三章详述),另一个 Sonnet 实例会只读 frontmatter 的前 30 行来决定是否召回这条记忆——如果 description 模糊,召回准确率会大幅下降。
3.3 实际记忆文件示例
user 类型示例:
---
name: 用户技术背景
description: 用户有十年 Go 开发经验,第一次接触本项目 React 前端
type: user
---
深度 Go 专业知识,React 新手。
解释前端问题时应类比后端概念(例如:React state ≈ Go struct field,
useEffect ≈ goroutine)。避免假设用户了解 JSX 或 hooks 的细节。
feedback 类型示例(注意 Why/How to apply 结构):
---
name: 集成测试策略:禁止 mock 数据库
description: 所有涉及数据库的测试必须连接真实 DB,不得使用 mock
type: feedback
---
集成测试必须连接真实数据库,禁止使用 mock。
**Why:** 上季度 mock 测试全部通过,但 prod 迁移失败导致严重事故——mock
与真实 DB 行为的差异被完全掩盖。
**How to apply:** 任何涉及数据库操作的测试场景,无论是单元测试还是集成
测试粒度,都必须使用真实 DB 连接。测试数据库连接字符串在 `test.env` 中
配置。
project 类型示例:
---
name: Auth 中间件重写背景
description: 重写由法务合规驱动,不是技术债清理,scope 决策要偏向合规
type: project
---
Auth 中间件重写是因为法务部门发现旧实现在存储 session token 方式上
不符合新的合规要求,不是主动的技术债清理。
**Why:** 合规要求具有强制截止日期(2026-06-01),优先级高于功能迭代。
**How to apply:** 重写范围应优先满足合规要求,而非优化 API 人体工程学。
如果某个设计选择在合规和便利之间取舍,选合规。
四、记忆加载流程:从文件到系统提示
源文件:
memdir/memdir.ts
4.1 入口函数
export async function loadMemoryPrompt(): Promise<string | null>
该函数根据特性门控(Feature Flag)选择不同的加载策略:
4.2 三种加载模式
模式一:标准模式(最常见)
调用 buildMemoryLines() 构建系统提示字符串,内容包括:
# auto memory
[目录路径说明 + "目录已存在,直接写入" 的提示]
## Types of memory
[四种类型的完整定义]
## What NOT to save in memory
[排除规则]
## How to save memories
[两步保存流程:写文件 → 更新 MEMORY.md 索引]
## When to access memories
[访问时机规则]
## Before recommending from memory
[验证陈旧记忆的规则]
## Memory and other forms of persistence
[与 Plan、Task 的区别]
## MEMORY.md
[当前 MEMORY.md 内容,截断至限制]
模式二:KAIROS 日志模式(长期会话)
为长期存活的 assistant 会话设计。不维护 MEMORY.md 作为实时索引,而是:
- 追加写入日期命名的日志文件:
logs/YYYY/MM/YYYY-MM-DD.md - 每条记录为带时间戳的短 bullet
- 独立的
/dream技能夜间将日志提炼为主题文件 + MEMORY.md
日志文件路径由 getAutoMemDailyLogPath() 生成(memdir/paths.ts:246):
export function getAutoMemDailyLogPath(date: Date = new Date()): string {
const yyyy = date.getFullYear().toString()
const mm = (date.getMonth() + 1).toString().padStart(2, '0')
const dd = date.getDate().toString().padStart(2, '0')
return join(getAutoMemPath(), 'logs', yyyy, mm, `${yyyy}-${mm}-${dd}.md`)
}
实际路径形如:~/.claude/projects/<hash>/memory/logs/2026/04/2026-04-07.md
按年/月/日三级分层,避免 logs/ 下直接堆积大量文件,保持 readdir 性能。
在系统提示中,路径使用模式字符串而非实际日期:
const logPathPattern = join(memoryDir, 'logs', 'YYYY', 'MM', 'YYYY-MM-DD.md')
原因:系统提示会被 prompt cache 缓存——嵌入今天的实际日期会在午夜导致缓存失效。模型从上下文中的
currentDate附件读取当前日期,自行替换模式字符串。
模式三:团队记忆模式(TEAMMEM 门控)
调用 buildCombinedMemoryPrompt(),在系统提示中描述两个目录:
- 个人目录:
<autoDir> - 团队目录:
<teamDir>
并为每种记忆类型附加 <scope> 标签,指导写入哪个目录。
4.3 目录预创建的设计
// 确保目录存在,idempotent
export async function ensureMemoryDirExists(memoryDir: string): Promise<void> {
const fs = getFsImplementation()
try {
await fs.mkdir(memoryDir) // recursive,自动创建父目录链
} catch (e) {
// 只记录 debug 日志,不中断流程
// EEXIST 已在 fs.mkdir 内部处理
// 真正的错误(EACCES/EPERM)会在模型写入时暴露
}
}
系统提示中包含这句话:
“This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).”
为什么要主动告知目录已存在? 源码注释说:
“Shipped because Claude was burning turns on
ls/mkdir -pbefore writing.”
之前模型会在写入前先执行 ls 确认目录存在,再 mkdir -p 创建——浪费了宝贵的工具调用轮次。提前预创建目录 + 在提示中告知,消除了这类无效操作。
五、截断逻辑:双重防护
源文件:
memdir/memdir.ts—truncateEntrypointContent()
当 MEMORY.md 超过限制时,系统执行双重截断:
export function truncateEntrypointContent(raw: string): EntrypointTruncation {
const trimmed = raw.trim()
const contentLines = trimmed.split('\n')
const lineCount = contentLines.length
const byteCount = trimmed.length // 注意:检查原始大小,不是截断后的
const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES // 200 行
const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES // 25,000 字节
// Step 1: 先按行截断(自然边界)
let truncated = wasLineTruncated
? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n')
: trimmed
// Step 2: 再按字节截断(在最近一个换行符处切割,不切断行中间)
if (truncated.length > MAX_ENTRYPOINT_BYTES) {
const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES)
truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES)
}
// Step 3: 附加截断原因的警告
const reason = wasByteTruncated && !wasLineTruncated
? `${formatFileSize(byteCount)} (limit: 25 KB) — index entries are too long`
: wasLineTruncated && !wasByteTruncated
? `${lineCount} lines (limit: 200)`
: `${lineCount} lines and ${formatFileSize(byteCount)}`
return {
content: truncated +
`\n\n> WARNING: MEMORY.md is ${reason}. Only part of it was loaded. ` +
`Keep index entries to one line under ~200 chars; move detail into topic files.`,
...
}
}
一个精细的细节:字节上限检查的是原始内容大小,而不是行截断后的大小:
// 即使已经行截断了,wasByteTruncated 仍基于原始 byteCount 判断
const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES
原因:超长行(少数行但每行数百字符)是字节超限的主要场景。如果先行截断再检查字节,会低估问题严重性,在警告信息中给出误导性的原因描述。
六、遥测:加载事件记录
每次记忆目录加载都会异步记录遥测事件(fire-and-forget,不阻塞提示构建):
logEvent('tengu_memdir_loaded', {
memory_type: 'auto' | 'agent' | 'team',
content_length: number, // MEMORY.md 字节数
line_count: number, // 行数
was_truncated: boolean, // 是否行截断
was_byte_truncated: boolean, // 是否字节截断
total_file_count: number, // 目录中总文件数
total_subdir_count: number, // 子目录数
})
七、章节小结
paths.ts 7 个导出函数速查:
| 函数 | 职责 | 关键设计 |
|---|---|---|
isAutoMemoryEnabled() |
总开关 | 5 级优先链,环境变量可穿透任意层 |
isExtractModeActive() |
提取代理开关 | 双 feature flag + 交互式判断 |
getMemoryBaseDir() |
根目录 | 远程覆盖 or ~/.claude |
getAutoMemPath() |
最终目录 | memoize + NFC + worktree 共享 |
getAutoMemEntrypoint() |
MEMORY.md 路径 | getAutoMemPath() + 固定文件名 |
getAutoMemDailyLogPath() |
KAIROS 日志路径 | 按年/月分层目录 |
hasAutoMemPathOverride() |
Cowork 覆盖检测 | 影响写权限豁免策略 |
isAutoMemPath() |
路径归属检查 | normalize 后 startsWith,防绕过 |
存储层整体设计决策:
| 设计决策 | 原因 |
|---|---|
| 两层存储(索引+内容) | 平衡 token 效率与信息完整性 |
| Git 根目录作为 memoize key | worktree 共享记忆,避免碎片化 |
| 字节+行数双重截断 | 超长行可绕过行数限制,需两道防线 |
| 提前预创建目录 | 消除模型浪费轮次检查目录存在性 |
| description 字段设计 | 语义召回的核心依据,不能模糊 |
| projectSettings 排除自定义路径 | 防止恶意仓库劫持记忆写权限 |
更多推荐


所有评论(0)