Memory 与记忆系统

📑 目录

本文是《Claude Code 代码全景解析》系列第 52 篇,聚焦 Claude Code 的记忆系统(Memory System)。作为一款面向软件工程场景的 AI Agent,Claude Code 需要在与用户的长期协作中保持上下文连贯性。本文将深入解析其基于文件系统的持久化记忆架构、自动记忆整理后台子 Agent、记忆扫描与召回机制,以及团队记忆的同步策略。

一、记忆系统架构概览

Claude Code 的记忆系统是一个基于文件系统的持久化层,存储在用户主目录下的 ~/.claude/projects/<project-slug>/memory/ 路径中。与向量数据库或外部记忆服务不同,它选择了最简单的 Markdown 文件作为存储介质,这种设计带来了几个显著优势:

  • 可解释性:用户可以像编辑普通文档一样查看和修改记忆内容
  • 版本友好:天然支持 Git 版本控制,便于审计和回滚
  • 跨平台兼容:不依赖任何外部服务,纯文件系统操作
  • 提示缓存友好:固定路径的文件内容可以被 Anthropic API 的 prompt caching 机制高效复用

整个记忆系统由四个核心模块协同构成:

flowchart TB
    subgraph Memdir["memdir/ 目录层"]
        M1["memdir.ts
目录管理与提示构建"] M2["memoryTypes.ts
四类型分类体系"] M3["memoryScan.ts
文件扫描与元数据提取"] M4["paths.ts
路径解析与开关控制"] end subgraph AutoDream["services/autoDream/"] A1["autoDream.ts
三闸门调度与 forked agent"] A2["consolidationPrompt.ts
四阶段整理提示"] A3["consolidationLock.ts
分布式锁与冲突解决"] A4["config.ts
功能开关"] end subgraph Extract["services/extractMemories/"] E1["extractMemories.ts
会话结束记忆提取"] E2["prompts.ts
提取提示模板"] end subgraph TeamMem["团队记忆"] T1["teamMemoryOps.ts
工具调用检测"] T2["teamMemPaths.ts
路径安全验证"] T3["teamMemPrompts.ts
组合提示构建"] end Memdir --> |"扫描元数据"| AutoDream Memdir --> |"扫描元数据"| Extract Memdir --> |"路径服务"| TeamMem Extract --> |"写入记忆文件"| Memdir AutoDream --> |"整理/合并"| Memdir TeamMem --> |"读写团队目录"| Memdir style Memdir fill:#e3f2fd style AutoDream fill:#f3e5f5 style Extract fill:#e8f5e9 style TeamMem fill:#fff3e0

1.1 记忆系统的启用条件

记忆系统并非无条件启用。src/memdir/paths.ts(第 24–46 行)定义了 isAutoMemoryEnabled() 函数,其优先级链如下:

// src/memdir/paths.ts:24-46
export function isAutoMemoryEnabled(): boolean {
  const envVal = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY
  if (isEnvTruthy(envVal)) {
    return false
  }
  if (isEnvDefinedFalsy(envVal)) {
    return true
  }
  if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
    return false
  }
  if (
    isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
    !process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
  ) {
    return false
  }
  const settings = getInitialSettings()
  if (settings.autoMemoryEnabled !== undefined) {
    return settings.autoMemoryEnabled
  }
  return true
}

环境变量 CLAUDE_CODE_DISABLE_AUTO_MEMORY 拥有最高优先级;--bareCLAUDE_CODE_SIMPLE)模式会完全关闭记忆系统;远程模式下如果没有配置 CLAUDE_CODE_REMOTE_MEMORY_DIR 也会禁用;最后才会检查用户设置 settings.json 中的 autoMemoryEnabled 字段。

二、Memory 目录结构

2.1 MEMORY.md:记忆的索引入口

每个记忆目录的核心是 MEMORY.md 文件。src/memdir/memdir.ts(第 12–15 行)定义了它的关键约束:

// src/memdir/memdir.ts:12-15
export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000

MEMORY.md 是一个纯索引文件,不包含 frontmatter,也不直接存储记忆内容。每一行都是一个指向具体记忆文件的指针,格式为:

- [Title](file.md) — one-line hook

MAX_ENTRYPOINT_LINES 限制为 200 行,MAX_ENTRYPOINT_BYTES 限制为 25KB。当索引超出这些限制时,truncateEntrypointContent() 函数(第 28–72 行)会执行截断,并附加警告信息提示模型将详细内容迁移到专题文件中。

这种设计的精妙之处在于:索引始终加载在系统提示中,而详细的记忆内容只在需要时才通过工具读取。这既保证了模型对记忆全景的快速感知,又避免了系统提示被过长内容撑爆。

2.2 记忆文件的分类体系

src/memdir/memoryTypes.ts(第 14–24 行)定义了严格的四类型分类法:

// src/memdir/memoryTypes.ts:14-24
export const MEMORY_TYPES = [
  'user',
  'feedback',
  'project',
  'reference',
] as const

export type MemoryType = (typeof MEMORY_TYPES)[number]

export function parseMemoryType(raw: unknown): MemoryType | undefined {
  if (typeof raw !== 'string') return undefined
  return MEMORY_TYPES.find(t => t === raw)
}
类型作用域描述
user始终 private用户的角色、目标、责任和知识背景
feedback默认 private,项目级惯例可存 team用户对 Agent 工作方式的指导(该避免什么、该坚持什么)
project偏向 team正在进行的工作、目标、 initiative、bug 或事故等不可从代码推导的信息
reference通常 team指向外部系统信息的指针(如 Linear 项目、Grafana 看板)

每种类型都配有详细的 <when_to_save><how_to_use><examples> 说明,以 XML 风格的标签嵌入在系统提示中,指导模型在何时保存何种类型的记忆。

2.3 记忆的存储格式

每个记忆文件都是一个独立的 Markdown 文件,必须包含 frontmatter:

---
name: 记忆标题
description: 一句话描述
 type: user | feedback | project | reference
---

记忆正文内容...

这种 frontmatter 设计使得 memoryScan.ts 可以快速扫描并提取元数据,而无需读取完整文件内容。

2.4 目录的组织方式

默认情况下,记忆存储在单目录中:

~/.claude/projects/<slug>/memory/
├── MEMORY.md          # 索引入口
├── user_role.md       # user 类型记忆
├── feedback_testing.md # feedback 类型记忆
├── project_release.md # project 类型记忆
└── reference_linear.md # reference 类型记忆

当启用团队记忆(TEAMMEM feature flag)时,会额外创建一个 team/ 子目录,形成 private + team 的双目录结构。每个目录都有自己的 MEMORY.md 索引。

三、autoDream:自动记忆整理后台子 Agent

3.1 什么是 autoDream

autoDream 是 Claude Code 记忆系统的后台整理服务。它的作用类似于人类的睡眠记忆巩固过程——在后台回顾近期的会话记录,将零散的新信息整合到已有的记忆体系中,删除过时的内容,并维护索引的整洁。

src/services/autoDream/autoDream.ts(第 1–4 行)的注释精准地概括了它的职责:

// src/services/autoDream/autoDream.ts:1-4
// Background memory consolidation. Fires the /dream prompt as a forked
// subagent when time-gate passes AND enough sessions have accumulated.
//
// Gate order (cheapest first):
//   1. Time: hours since lastConsolidatedAt >= minHours (one stat)
//   2. Sessions: transcript count with mtime > lastConsolidatedAt >= minSessions
//   3. Lock: no other process mid-consolidation

3.2 三闸门调度机制

autoDream 采用三闸门串行检查策略,按成本从低到高依次验证:

flowchart LR
    A["Time Gate
距上次整理 >= 24h"] -->|"通过"| B["Scan Throttle
10分钟扫描间隔"] B -->|"通过"| C["Session Gate
>= 5 个新会话"] C -->|"通过"| D["Lock Gate
获取 .consolidate-lock"] D -->|"成功"| E["启动 forked agent
执行记忆整理"] style A fill:#ffcdd2 style B fill:#fff9c4 style C fill:#c8e6c9 style D fill:#e1bee7 style E fill:#b3e5fc

第一闸:时间闸(Time Gate)

src/services/autoDream/autoDream.ts(第 128–136 行)检查距上次整理是否已超过 minHours(默认 24 小时)。readLastConsolidatedAt() 通过读取锁文件的 mtime 获取这个时间戳,单次调用成本仅一次 stat 系统调用。

第二闸:扫描节流(Scan Throttle)

SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000(第 35 行)防止时间闸通过后频繁扫描会话目录。这是一个简单的内存级节流器,避免在没有足够新会话时重复执行 I/O 密集型操作。

第三闸:会话闸(Session Gate)

src/services/autoDream/autoDream.ts(第 145–157 行)检查自上次整理以来是否有足够的新会话(默认 5 个)。当前会话会被排除,因为它的 mtime 总是最新的。

// src/services/autoDream/autoDream.ts:145-157
let sessionIds: string[]
try {
  sessionIds = await listSessionsTouchedSince(lastAt)
} catch (e: unknown) {
  logForDebugging(
    `[autoDream] listSessionsTouchedSince failed: ${(e as Error).message}`,
  )
  return
}
const currentSession = getSessionId()
sessionIds = sessionIds.filter(id => id !== currentSession)
if (!force && sessionIds.length < cfg.minSessions) {
  logForDebugging(
    `[autoDream] skip — ${sessionIds.length} sessions since last consolidation, need ${cfg.minSessions}`,
  )
  return
}

第四闸:分布式锁(Lock Gate)

src/services/autoDream/consolidationLock.ts(第 12–14 行)使用 .consolidate-lock 文件作为分布式锁:

// src/services/autoDream/consolidationLock.ts:12-14
const LOCK_FILE = '.consolidate-lock'
const HOLDER_STALE_MS = 60 * 60 * 1000

锁文件的内容是持有者的 PID,mtime 即为 lastConsolidatedAttryAcquireConsolidationLock()(第 31–74 行)实现了完整的竞争处理逻辑:如果锁被其他存活进程持有(通过 isProcessRunning() 验证),则放弃;如果 PID 已死或锁已过期(超过 1 小时),则重新夺取。两个进程同时写入时,后写入的 PID 会被验证阶段检测,失败者自动退出。

3.3 Orient → Gather → Consolidate → Prune 四阶段流程

当所有闸门通过后,autoDream 会启动一个 forked subagentrunForkedAgent),并注入由 buildConsolidationPrompt() 构建的特殊提示。这个提示将整理工作划分为四个明确阶段:

// src/services/autoDream/consolidationPrompt.ts:10-66
export function buildConsolidationPrompt(
  memoryRoot: string,
  transcriptDir: string,
  extra: string,
): string {
  return `# Dream: Memory Consolidation

You are performing a dream — a reflective pass over your memory files...

## Phase 1 — Orient
- \`ls\` the memory directory to see what already exists
- Read \`${ENTRYPOINT_NAME}\` to understand the current index
...

## Phase 2 — Gather recent signal
Look for new information worth persisting...
1. **Daily logs** if present
2. **Existing memories that drifted**
3. **Transcript search**

## Phase 3 — Consolidate
For each thing worth remembering, write or update a memory file...
- Merging new signal into existing topic files
- Converting relative dates to absolute dates
- Deleting contradicted facts

## Phase 4 — Prune and index
Update \`${ENTRYPOINT_NAME}\` so it stays under ${MAX_ENTRYPOINT_LINES} lines AND under ~25KB...
- Remove pointers to stale memories
- Demote verbose entries
- Add pointers to newly important memories
- Resolve contradictions`
}

Phase 1 — Orient(定向):子 Agent 首先 ls 记忆目录,读取 MEMORY.md 索引,浏览现有专题文件。这一步确保整理者了解当前记忆版图,避免创建重复文件。

Phase 2 — Gather(收集):按优先级查找新信号——首先是 logs/YYYY/MM/YYYY-MM-DD.md 的每日日志(append-only 流),然后是已漂移的现有记忆(与当前代码库矛盾的内容),最后是通过 grep 在 JSONL 会话转录中搜索特定术语获取精确上下文。

Phase 3 — Consolidate(巩固):将新信号写入或更新到记忆文件中。核心原则包括:合并到现有专题而非创建近似重复、将相对日期("昨天"、"上周")转换为绝对日期、删除已被证伪的旧记忆。

Phase 4 — Prune and Index(修剪与索引):维护 MEMORY.md 索引,确保其在 200 行和 25KB 限制内。移除指向过时记忆的指针、将过长的索引行降级到专题文件、添加新记忆指针、解决文件间的矛盾。

3.4 DreamTask 的实现

autoDream 的执行状态通过 DreamTask 在应用状态中注册和追踪。src/services/autoDream/autoDream.ts(第 170–180 行)展示了任务的注册与监控:

// src/services/autoDream/autoDream.ts:170-180
const setAppState =
  context.toolUseContext.setAppStateForTasks ??
  context.toolUseContext.setAppState
const abortController = new AbortController()
const taskId = registerDreamTask(setAppState, {
  sessionsReviewing: sessionIds.length,
  priorMtime,
  abortController,
})

makeDreamProgressWatcher()(第 202–232 行)是一个消息监视器,它监听 forked agent 的每一轮 assistant 消息,提取文本块和工具调用计数,并收集 Edit/Write 操作的文件路径:

// src/services/autoDream/autoDream.ts:202-232
function makeDreamProgressWatcher(
  taskId: string,
  setAppState: import('../../Task.js').SetAppState,
): (msg: Message) => void {
  return msg => {
    if (msg.type !== 'assistant') return
    let text = ''
    let toolUseCount = 0
    const touchedPaths: string[] = []
    for (const block of msg.message.content) {
      if (block.type === 'text') {
        text += block.text
      } else if (block.type === 'tool_use') {
        toolUseCount++
        if (
          block.name === FILE_EDIT_TOOL_NAME ||
          block.name === FILE_WRITE_TOOL_NAME
        ) {
          const input = block.input as { file_path?: unknown }
          if (typeof input.file_path === 'string') {
            touchedPaths.push(input.file_path)
          }
        }
      }
    }
    addDreamTurn(taskId, { text: text.trim(), toolUseCount }, touchedPaths, setAppState)
  }
}

整理完成后,结果会以系统消息的形式内联到主对话中(例如 "Improved 3 memories"),与 extractMemories 的 "Saved N memories" 共享相同的展示表面。

如果整理过程中发生异常,且不是用户主动中止的,rollbackConsolidationLock(priorMtime) 会将锁文件的 mtime 回滚到整理前的时间戳,这样时间闸会在下次重新通过,而扫描节流器则起到退避作用,避免立即重试。

四、记忆扫描与召回

4.1 memoryScan.ts:元数据扫描

src/memdir/memoryScan.ts(第 10–76 行)提供了记忆目录扫描的基础原语:

// src/memdir/memoryScan.ts:10-20
export type MemoryHeader = {
  filename: string
  filePath: string
  mtimeMs: number
  description: string | null
  type: MemoryType | undefined
}

const MAX_MEMORY_FILES = 200
const FRONTMATTER_MAX_LINES = 30

scanMemoryFiles()(第 28–76 行)的实现体现了工程上的精细考量:

// src/memdir/memoryScan.ts:28-76
export async function scanMemoryFiles(
  memoryDir: string,
  signal: AbortSignal,
): Promise<MemoryHeader[]> {
  try {
    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,
          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 is PromiseFulfilledResult<MemoryHeader> =>
        r.status === 'fulfilled',
      )
      .map(r => r.value)
      .sort((a, b) => b.mtimeMs - a.mtimeMs)
      .slice(0, MAX_MEMORY_FILES)
  } catch {
    return []
  }
}

关键设计细节:

  • 单遍读取readFileInRange 内部已经执行了 stat,因此无需额外的 stat 轮询。对于常见场景(N ≤ 200),这比 "先 stat 排序再读取" 的策略减少了一半的系统调用。
  • 仅读取前 30 行:因为 frontmatter 通常很短,限制读取范围大幅降低了 I/O 开销。
  • 按修改时间倒序:最新的记忆排在最前面,符合召回时优先使用近期信息的直觉。
  • 上限 200 个文件:避免极端情况下的性能退化。
  • 失败静默:任何扫描错误都返回空数组,而不是中断主流程。

formatMemoryManifest()(第 82–93 行)将扫描结果格式化为文本清单,供提示模板使用:

// src/memdir/memoryScan.ts:82-93
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')
}

4.2 findRelevantMemories:相关记忆查找

src/memdir/findRelevantMemories.ts(第 36–64 行)实现了基于 LLM 的相关性筛选

// src/memdir/findRelevantMemories.ts:36-64
export async function findRelevantMemories(
  query: string,
  memoryDir: string,
  signal: AbortSignal,
  recentTools: readonly string[] = [],
  alreadySurfaced: ReadonlySet<string> = new Set(),
): Promise<RelevantMemory[]> {
  const memories = (await scanMemoryFiles(memoryDir, signal)).filter(
    m => !alreadySurfaced.has(m.filePath),
  )
  if (memories.length === 0) {
    return []
  }

  const selectedFilenames = await selectRelevantMemories(
    query, memories, signal, recentTools,
  )
  const byFilename = new Map(memories.map(m => [m.filename, m]))
  const selected = selectedFilenames
    .map(filename => byFilename.get(filename))
    .filter((m): m is MemoryHeader => m !== undefined)

  if (feature('MEMORY_SHAPE_TELEMETRY')) {
    const { logMemoryRecallShape } =
      require('./memoryShapeTelemetry.js') as typeof import('./memoryShapeTelemetry.js')
    logMemoryRecallShape(memories, selected)
  }

  return selected.map(m => ({ path: m.filePath, mtimeMs: m.mtimeMs }))
}

工作流程如下:

  1. 调用 scanMemoryFiles 扫描目录,排除已在之前轮次中展示过的记忆(alreadySurfaced
  2. 将所有记忆的 frontmatter 元数据格式化为清单文本
  3. 通过 sideQuery 发送给一个轻量级的 Sonnet 调用,要求其从清单中选出最多 5 个最相关的文件名
  4. 将选中的文件名映射回完整路径,连带 mtimeMs 返回给调用者

selectRelevantMemories()(第 66–123 行)使用了 JSON Schema 输出格式,强制模型返回结构化的 selected_memories 数组,便于可靠解析:

// src/memdir/findRelevantMemories.ts:96-108
const result = await sideQuery({
  model: getDefaultSonnetModel(),
  system: SELECT_MEMORIES_SYSTEM_PROMPT,
  skipSystemPromptPrefix: true,
  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',
})

这里有两个值得注意的设计点:

  • recentTools 过滤:如果用户当前正在使用某个工具(如 MCP 工具),模型不需要被提醒该工具的基础文档记忆,但仍然需要关于该工具的警告、陷阱和已知问题记忆——因为"正在使用"恰恰是这些警告最重要的时刻。
  • alreadySurfaced 去重:避免在多轮对话中重复加载相同的记忆文件,让每轮都有机会展示新的相关内容。

五、团队记忆

5.1 团队记忆的架构

团队记忆(Team Memory)是 Claude Code 记忆系统的扩展,它在个人记忆目录下创建一个 team/ 子目录,实现跨用户、跨会话的共享记忆。这个功能由 TEAMMEM feature flag 控制。

src/memdir/teamMemPaths.ts(第 55–75 行)定义了团队记忆的路径:

// src/memdir/teamMemPaths.ts:55-75
export function getTeamMemPath(): string {
  return (join(getAutoMemPath(), 'team') + sep).normalize('NFC')
}

export function getTeamMemEntrypoint(): string {
  return join(getAutoMemPath(), 'team', 'MEMORY.md')
}

5.2 路径安全验证

团队记忆涉及多人写入,路径安全至关重要。src/memdir/teamMemPaths.ts 实现了多层防御的路径验证体系:

第一层:字符串级消毒

sanitizePathKey()(第 23–52 行)拒绝多种注入向量:

// src/memdir/teamMemPaths.ts:23-52
function sanitizePathKey(key: string): string {
  if (key.includes('\0')) {
    throw new PathTraversalError(`Null byte in path key: "${key}"`)
  }
  let decoded: string
  try {
    decoded = decodeURIComponent(key)
  } catch {
    decoded = key
  }
  if (decoded !== key && (decoded.includes('..') || decoded.includes('/'))) {
    throw new PathTraversalError(`URL-encoded traversal in path key: "${key}"`)
  }
  const normalized = key.normalize('NFKC')
  if (
    normalized !== key &&
    (normalized.includes('..') ||
      normalized.includes('/') ||
      normalized.includes('\\') ||
      normalized.includes('\0'))
  ) {
    throw new PathTraversalError(
      `Unicode-normalized traversal in path key: "${key}"`,
    )
  }
  if (key.includes('\\')) {
    throw new PathTraversalError(`Backslash in path key: "${key}"`)
  }
  if (key.startsWith('/')) {
    throw new PathTraversalError(`Absolute path key: "${key}"`)
  }
  return key
}

检查项包括:空字节、URL 编码的遍历序列(%2e%2e%2f)、Unicode 规范化攻击(全角 ../ 经 NFKC 归一化后变成 ../)、反斜杠、绝对路径。

第二层:符号链接解析

realpathDeepestExisting()(第 85–145 行)处理更隐蔽的符号链接逃逸攻击。它从目标路径向上遍历,对最深存在的祖先目录调用 realpath() 解析符号链接,然后将不存在的尾部重新拼接回去。这防御了攻击者在团队目录内放置指向外部敏感路径(如 ~/.ssh/authorized_keys)的符号链接的攻击向量。

// src/memdir/teamMemPaths.ts:85-145 (核心逻辑摘要)
async function realpathDeepestExisting(absolutePath: string): Promise<string> {
  const tail: string[] = []
  let current = absolutePath
  for (let parent = dirname(current); current !== parent; parent = dirname(current)) {
    try {
      const realCurrent = await realpath(current)
      return tail.length === 0 ? realCurrent : join(realCurrent, ...tail.reverse())
    } catch (e: unknown) {
      // 处理 ENOENT、ELOOP、ENOTDIR 等...
      tail.push(current.slice(parent.length + sep.length))
      current = parent
    }
  }
  return absolutePath
}

第三层:写入时验证

validateTeamMemWritePath()(第 176–200 行)在写入前执行完整的两阶段验证:先用 path.resolve() 做快速字符串级拒绝,再用 realpathDeepestExisting() 解析符号链接并确认真实路径仍在团队目录内。

5.3 团队记忆的工具调用检测

src/utils/teamMemoryOps.ts(第 14–39 行)提供了检测工具调用是否针对团队记忆的能力:

// src/utils/teamMemoryOps.ts:14-25
export function isTeamMemorySearch(toolInput: unknown): boolean {
  const input = toolInput as
    | { path?: string; pattern?: string; glob?: string }
    | undefined
  if (!input) return false
  if (input.path && isTeamMemFile(input.path)) {
    return true
  }
  return false
}

// src/utils/teamMemoryOps.ts:28-39
export function isTeamMemoryWriteOrEdit(
  toolName: string,
  toolInput: unknown,
): boolean {
  if (toolName !== FILE_WRITE_TOOL_NAME && toolName !== FILE_EDIT_TOOL_NAME) {
    return false
  }
  const input = toolInput as { file_path?: string; path?: string } | undefined
  const filePath = input?.file_path ?? input?.path
  return filePath !== undefined && isTeamMemFile(filePath)
}

这些检测函数被用于:

  • 在 UI 摘要中正确显示 "Recalling 3 team memories" 或 "Writing team memories"
  • 在分析遥测中区分个人记忆和团队记忆的操作
  • 在权限检查中对团队记忆目录施加额外的安全约束

5.4 团队记忆的组合提示

当团队记忆和个人记忆同时启用时,src/memdir/teamMemPrompts.ts(第 15–84 行)会构建一个组合提示,将两种 scope(privateteam)嵌入到四类型分类的每个类型块中:

// src/memdir/teamMemPrompts.ts:15-84
export function buildCombinedMemoryPrompt(
  extraGuidelines?: string[],
  skipIndex = false,
): string {
  const autoDir = getAutoMemPath()
  const teamDir = getTeamMemPath()
  // ...
  const lines = [
    '# Memory',
    '',
    `You have a persistent, file-based memory system with two directories: a private directory at \`${autoDir}\` and a shared team directory at \`${teamDir}\`.`,
    // ...
    ...TYPES_SECTION_COMBINED,
    ...WHAT_NOT_TO_SAVE_SECTION,
    '- You MUST avoid saving sensitive data within shared team memories.',
    // ...
  ]
  return lines.join('\n')
}

TYPES_SECTION_COMBINEDTYPES_SECTION_INDIVIDUAL 的核心区别在于:组合模式在每个 <type> 块内嵌入了 <scope> 标签(always privatedefault to privateprivate or teamusually team),指导模型根据信息性质选择正确的存储位置。

5.5 团队记忆的同步

团队记忆的同步发生在每次会话开始时teamMemPrompts.ts 构建的提示会明确告知模型:

"Team memories are synced at the beginning of every session and they are stored at <teamDir>."

这意味着团队记忆文件的内容会在会话初始化时被读取并加载到系统提示中,而个人记忆则始终从本地文件系统读取。这种设计确保了所有协作者都能看到最新的团队共识,同时保持个人偏好的隐私性。

六、extractMemories:会话结束时的记忆提取

除了 autoDream 的定期整理,Claude Code 还在每次完整查询循环结束时(当模型产生最终响应且无工具调用时)通过 extractMemories 执行一次快速记忆提取。

src/services/extractMemories/extractMemories.ts(第 88–112 行)实现了与主 Agent 的互斥逻辑:

// src/services/extractMemories/extractMemories.ts:88-112
function hasMemoryWritesSince(
  messages: Message[],
  sinceUuid: string | undefined,
): boolean {
  let foundStart = sinceUuid === undefined
  for (const message of messages) {
    if (!foundStart) {
      if (message.uuid === sinceUuid) {
        foundStart = true
      }
      continue
    }
    if (message.type !== 'assistant') {
      continue
    }
    const content = (message as AssistantMessage).message.content
    if (!Array.isArray(content)) {
      continue
    }
    for (const block of content) {
      const filePath = getWrittenFilePath(block)
      if (filePath !== undefined && isAutoMemPath(filePath)) {
        return true
      }
    }
  }
  return false
}

如果主 Agent 在本轮中已经自行写入了记忆文件,后台提取 Agent 就会跳过该轮次(通过推进 lastMemoryMessageUuid 光标实现)。这种主从互斥设计避免了重复工作,同时确保即使主 Agent 遗漏了某些值得记忆的信息,后台 Agent 也能补上。

6.1 工具权限的严格沙箱

createAutoMemCanUseTool()(第 124–170 行)为后台记忆 Agent 创建了一个高度受限的工具沙箱

// src/services/extractMemories/extractMemories.ts:124-170 (摘要)
export function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn {
  return async (tool: Tool, input: Record<string, unknown>) => {
    // 允许 REPL(内部会重新检查权限)
    if (tool.name === REPL_TOOL_NAME) {
      return { behavior: 'allow', updatedInput: input }
    }
    // 允许 Read/Grep/Glob(只读工具)
    if (tool.name === FILE_READ_TOOL_NAME || tool.name === GREP_TOOL_NAME || tool.name === GLOB_TOOL_NAME) {
      return { behavior: 'allow', updatedInput: input }
    }
    // 允许只读 Bash
    if (tool.name === BASH_TOOL_NAME) {
      const parsed = tool.inputSchema.safeParse(input)
      if (parsed.success && tool.isReadOnly(parsed.data)) {
        return { behavior: 'allow', updatedInput: input }
      }
      return denyAutoMemTool(tool, 'Only read-only shell commands are permitted...')
    }
    // 允许 Edit/Write 但仅限于记忆目录内
    if (tool.name === FILE_EDIT_TOOL_NAME || tool.name === FILE_WRITE_TOOL_NAME) {
      // ... 检查 file_path 是否在 memoryDir 内
    }
    // 拒绝其他所有工具
    return denyAutoMemTool(tool, `only ... within ${memoryDir} are allowed`)
  }
}

这个沙箱的设计哲学是最小权限原则:后台 Agent 只能读取文件系统信息、在记忆目录内写入文件,绝不能修改项目代码、执行任意 shell 命令或调用 MCP 工具。

6.2 提取提示模板

src/services/extractMemories/prompts.ts(第 24–52 行)的 opener() 函数构建了提取 Agent 的核心指令:

// src/services/extractMemories/prompts.ts:24-52
function opener(newMessageCount: number, existingMemories: string): string {
  const manifest =
    existingMemories.length > 0
      ? `\n\n## Existing memory files\n\n${existingMemories}\n\nCheck this list before writing...`
      : ''
  return [
    `You are now acting as the memory extraction subagent. Analyze the most recent ~${newMessageCount} messages above...`,
    `Available tools: ${FILE_READ_TOOL_NAME}, ${GREP_TOOL_NAME}, ${GLOB_TOOL_NAME}, read-only ${BASH_TOOL_NAME}...`,
    `You have a limited turn budget... turn 1 — issue all ${FILE_READ_TOOL_NAME} calls... turn 2 — issue all ${FILE_WRITE_TOOL_NAME}/${FILE_EDIT_TOOL_NAME} calls...`,
    `You MUST only use content from the last ~${newMessageCount} messages...` + manifest,
  ].join('\n')
}

提示中特别强调了两轮策略:第一轮并行读取所有可能需要更新的文件,第二轮并行执行所有写入/编辑操作。这是因为 Edit 工具要求先 Read 同一文件,而 interleaving(交错执行)会浪费宝贵的轮次预算。

七、记忆持久化的工程考量

7.1 目录存在性保证

src/memdir/memdir.ts(第 90–109 行)的 ensureMemoryDirExists() 确保记忆目录在提示构建前就已经存在:

// src/memdir/memdir.ts:90-109
export async function ensureMemoryDirExists(memoryDir: string): Promise<void> {
  const fs = getFsImplementation()
  try {
    await fs.mkdir(memoryDir)
  } catch (e) {
    const code =
      e instanceof Error && 'code' in e && typeof e.code === 'string'
        ? e.code
        : undefined
    logForDebugging(
      `ensureMemoryDirExists failed for ${memoryDir}: ${code ?? String(e)}`,
      { level: 'debug' },
    )
  }
}

这个函数是幂等的——fs.mkdir 默认递归创建目录且内部已经吞掉了 EEXIST 错误。任何到达 catch 块的错误都是真正的权限问题(EACCESEPERMEROFS),会被记录到 debug 日志中,但不会中断提示构建流程。模型后续尝试写入时,FileWriteTool 会自行创建父目录并报告真实的权限错误。

7.2 记忆文件的版本与冲突

Claude Code 的记忆系统没有显式的版本管理机制,但它通过以下策略隐式地处理冲突:

  1. Last-write-wins:由于记忆文件是基于文件系统的,正常的文件写入语义适用。如果两个进程同时修改同一文件,后者的写入会覆盖前者。
  2. 分布式锁:autoDream 使用 .consolidate-lock 确保同一时间只有一个整理进程在运行。
  3. 主从互斥extractMemories 和主 Agent 通过 hasMemoryWritesSince 实现轮次级别的互斥。
  4. 用户可编辑:由于记忆是纯 Markdown 文件,用户随时可以手动介入解决任何感知到的冲突或不一致。

7.3 遥测与可观测性

记忆系统内置了丰富的遥测事件:

  • tengu_memdir_loaded:记忆目录加载时的文件/子目录计数
  • tengu_auto_dream_fired / tengu_auto_dream_completed / tengu_auto_dream_failed:autoDream 的生命周期
  • tengu_auto_mem_tool_denied:后台 Agent 的工具权限拒绝
  • MEMORY_SHAPE_TELEMETRY feature flag 控制的记忆召回形状分析

这些遥测数据帮助产品团队理解记忆系统的实际使用模式、召回准确率,以及后台 Agent 的资源消耗。

八、总结

Claude Code 的记忆系统是一个工程上高度务实的设计。它没有使用向量数据库或复杂的嵌入检索,而是选择了最朴素、最透明、最可控的文件系统方案,并在其上构建了精妙的分层架构:

flowchart TB
    subgraph UserInteraction["用户交互层"]
        U1["主 Agent 写入记忆"]
        U2["用户手动编辑 MEMORY.md"]
    end
    
    subgraph BackgroundLayer["后台处理层"]
        B1["extractMemories
每轮结束提取"] B2["autoDream
定期整理合并"] B3["findRelevantMemories
查询时召回"] end subgraph StorageLayer["持久化层"] S1["private/ 个人记忆"] S2["team/ 团队记忆"] S3["MEMORY.md 索引"] S4["*.md 专题记忆文件"] end subgraph SecurityLayer["安全层"] X1["路径遍历防护"] X2["符号链接解析"] X3["工具权限沙箱"] X4["分布式锁"] end UserInteraction --> StorageLayer BackgroundLayer --> StorageLayer StorageLayer --> SecurityLayer style UserInteraction fill:#e8f5e9 style BackgroundLayer fill:#e3f2fd style StorageLayer fill:#fff3e0 style SecurityLayer fill:#ffcdd2

从架构视角来看,这个记忆系统体现了几个关键的设计原则:

  1. 透明性优先:用户可以完全理解、查看和修改记忆的每一处内容,没有黑盒向量空间
  2. 提示缓存意识MEMORY.md 索引始终加载在系统提示前缀中,享受 Anthropic API 的 prompt caching 红利
  3. 安全纵深防御:从字符串消毒到符号链接解析,从工具权限沙箱到分布式锁,多层安全机制叠加
  4. 渐进式智能化:基础的记忆文件操作由主 Agent 直接完成,复杂的整理工作委托给后台 forked agent,二者通过互斥机制协调
  5. 人机协作:记忆系统不是完全自主的——它依赖用户的反馈("记住这个"、"忘记那个")来保持准确性,用户的直接编辑也被视为一等公民

对于构建自己的 AI Agent 系统的开发者而言,Claude Code 的记忆系统提供了一个重要的启示:持久化记忆不一定需要复杂的专用数据库。在正确的架构设计下,简单的文件系统配合智能的索引策略、后台整理 Agent 和严格的安全约束,同样可以实现生产级的跨会话记忆能力。