第二章:记忆的存储结构、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.jsonautoMemoryEnabled 按值 项目级 opt-out
6 以上未命中 ON 普通用户默认体验

isEnvTruthyisEnvDefinedFalsy 配合实现了"三态":变量未定义时两者都返回 false,继续向下走;变量定义为 1/trueisEnvTruthy 命中;定义为 0/falseisEnvDefinedFalsy 命中。这让优先级链可以被任意层"穿透"或"截断"。


已设置

未设置

已设置

未设置

getAutoMemPath 被调用

CLAUDE_COWORK_MEMORY_PATH_OVERRIDE 环境变量?

使用完整路径覆盖

settings.json 中 autoMemoryDirectory?

使用自定义目录
支持 ~/ 展开

使用默认路径

/projects//memory/

memoryBase = CLAUDE_CODE_REMOTE_MEMORY_DIR
或 ~/.claude

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:按项目根目录缓存
)

三个关键细节

  1. memoizegetProjectRoot() 为 key:渲染路径中 isAutoManagedMemoryFile() 会被每条消息调用,memoize 避免重复读取 settings.json(realpathSync + readFileSync)。

  2. getAutoMemBase() 使用 findCanonicalGitRoot()

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

这意味着同一个 Git 仓库的所有 worktree 共享同一个记忆目录[^1]——在 monorepo 或多 worktree 工作流中非常重要。

  1. 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.tsMEMORY_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)选择不同的加载策略:

loadMemoryPrompt 调用

isAutoMemoryEnabled?

logEvent disabled
返回 null

feature KAIROS && getKairosActive?

buildAssistantDailyLogPrompt
日志追加模式

feature TEAMMEM && isTeamMemoryEnabled?

buildCombinedMemoryPrompt
双目录模式 auto+team

buildMemoryLines.join
标准单目录模式

ensureMemoryDirExists

返回系统提示字符串

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 -p before writing.”

之前模型会在写入前先执行 ls 确认目录存在,再 mkdir -p 创建——浪费了宝贵的工具调用轮次。提前预创建目录 + 在提示中告知,消除了这类无效操作。


五、截断逻辑:双重防护

源文件:memdir/memdir.tstruncateEntrypointContent()

当 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,   // 子目录数
})

七、章节小结

系统提示 文件系统 loadMemoryPrompt() 系统启动 系统提示 文件系统 loadMemoryPrompt() 系统启动 调用(每次会话一次) 检查特性门控 ensureMemoryDirExists() readFileSync(MEMORY.md) 索引内容(或空) truncateEntrypointContent() buildMemoryLines() 组装提示 返回完整系统提示字符串

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 排除自定义路径 防止恶意仓库劫持记忆写权限

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐