第 10 章:记忆系统与文件历史

# 第 10 章:记忆系统与文件历史

> 记忆不是存储,而是遗忘的艺术——只保留对未来有用的东西。

---

## 10.1 核心问题

第 9 章的 Agent 能感知当前项目,但它有两个"失忆"问题:

**问题 A:知识不跨会话**

```
第一次会话:
  用户:我们项目用 4 空格缩进,记住了
  Agent:好的,我记住了!

第二次会话(新进程):
  用户:我们的缩进规范是什么?
  Agent:我没有相关信息…
```

每次会话都是全新的进程,所有"记忆"随进程销毁。

**问题 B:Agent 改错了文件没法撤销**

```
用户:帮我重构 auth.ts
Agent:好的!(把 auth.ts 重写成了完全错误的版本)
用户:不对不对,恢复原来的!
Agent:很抱歉,我没有保存原来的内容……
```

Agent 在修改文件前没有保存快照,出错后无法还原。

本章解决这两个问题:

1. **记忆系统(Memory System)**:Agent 主动把重要信息写入 `MEMORY.md`,下次启动时注入系统提示,实现跨会话记忆
2. **文件历史检查点(File History Checkpointing)**:每轮用户消息到来前自动为被修改文件创建快照,错了可一键还原

---

## 10.2 原理讲解

### 10.2.1 记忆 vs 历史 vs 上下文

这三个概念容易混淆,需要明确区分:

| 机制 | 存储格式 | 生命周期 | 用途 |
|------|---------|---------|------|
| **上下文(CLAUDE.md)** | Markdown 文件 | 永久,人工维护 | 项目说明、团队约定 |
| **记忆(MEMORY.md)** | Markdown 文件 | 永久,Agent 主动写入 | 用户偏好、工作习惯 |
| **历史(.jsonl)** | JSONL 文件 | 永久,自动追加 | 完整对话记录,用于 resume |
| **文件快照** | 硬链接备份 | 内存 + 磁盘,会话内 | 文件修改前的内容备份 |

### 10.2.2 记忆系统:两层目录结构

Claude Code 的记忆系统基于文件系统,存储在 `~/.claude/` 下:

```
~/.claude/
├── MEMORY.md                    ← 用户级记忆(跨所有项目)
├── memory/
│   ├── MEMORY.md                ← 全局记忆入口(索引)
│   ├── user_preferences.md      ← 话题文件(按语义分类)
│   ├── coding_standards.md
│   └── project_context.md
└── projects/
    └── <project-hash>/
        └── memory/
            ├── MEMORY.md        ← 项目级记忆入口(索引)
            ├── architecture.md
            └── decisions.md
```

**`MEMORY.md` 是索引,不是内容**:

```markdown
<!-- ~/.claude/memory/MEMORY.md 的内容示例 -->
- [用户偏好](user_preferences.md) — 缩进4空格,偏好函数式风格,中文回复
- [项目记录](project_context.md) — 电商系统,PostgreSQL+Prisma,部署在 AWS
- [反馈日志](feedback_coding.md) — 不要生成过长的函数,prefer 小函数组合
```

`MEMORY.md` 始终注入 context(最多 200 行 / 25,000 字节),话题文件按需读取(Agent 主动调用 `Read` 工具)。这种两层设计避免了把所有记忆内容都塞进上下文的浪费。

### 10.2.3 记忆的四种类型

Claude Code 通过系统提示教会 LLM 记忆的分类(`src/memdir/memoryTypes.ts`):

```
user       — 用户的角色、背景、技能水平
             例:"用户是 Python 专家,倾向于函数式编程"

feedback   — 对 Agent 行为的偏好(偏好/避免)
             例:"不要生成超过 50 行的函数"
             例:"总是用中文回复"

project    — 当前代码库的上下文知识
             例:"数据库迁移用 prisma migrate,不要手写 SQL"

reference  — 指向外部资源的指针
             例:"API 文档在 https://internal.example.com/docs"
```

**什么不应该记忆**(内置 WHAT_NOT_TO_SAVE 规则):
- 可以从代码库直接推导出的信息(架构、代码风格——看代码就知道了)
- 仅在当前会话有意义的临时状态("我们现在在第 3 步")
- 完整的代码片段(应放进 CLAUDE.md 或文档,不是记忆)

### 10.2.4 保存记忆的两步流程

Claude Code 系统提示明确规定保存记忆是**两步操作**:

```
第 1 步:将记忆写入话题文件
  ~/.claude/memory/user_preferences.md

  内容格式(YAML frontmatter):
  ---
  name: 缩进偏好
  description: 用户偏好的代码缩进风格
  type: feedback
  ---
  用户偏好 4 空格缩进,不使用 Tab。

第 2 步:在 MEMORY.md 中添加一行索引
  - [缩进偏好](user_preferences.md) — 4空格缩进,非Tab
```

每行索引应控制在 ~150 字符以内(`MEMORY.md` 超过 200 行后会触发截断警告)。

### 10.2.5 自动记忆提取(extractMemories)

除了 Agent 主动调用工具保存记忆,Claude Code 还有一个**后台自动提取**机制:

```
Agentic Loop 完成一轮(模型输出最终文本,无工具调用)
        ↓
handleStopHooks() 触发
        ↓
initExtractMemories() 中的 shouldExtract() 检查:
  - 本轮有足够多的新消息?(threshold: 4 条模型可见消息)
  - 距上次提取有足够多的新消息?
  ↓ 是
runForkedAgent():fork 一个子 Agent,共享父 Agent 的 prompt cache
        ↓
子 Agent 分析本轮对话,判断是否有值得记忆的新信息
如果有 → 写入 memory/ 目录(两步流程:话题文件 + MEMORY.md 索引)
如果没有 → 直接返回,不写入
```

**forked agent 的设计精髓**:子 Agent 是父 Agent 的"完美克隆"——共享相同的系统提示和工具列表。由于 Anthropic API 的 prompt cache 工作在前缀级别,两个 Agent 的系统提示完全一致,子 Agent 的首次 API 请求几乎完全命中缓存,额外成本极低。

### 10.2.6 文件历史检查点:三阶段协议

`src/utils/fileHistory.ts` 实现了精心设计的三阶段协议,确保并发安全:

```
文件检查点触发时机:
  ┌─────────────────────────────────────────────────────────┐
  │  每轮用户消息到来时(fileHistoryMakeSnapshot)           │
  │    └→ 为所有被追踪文件创建版本快照                       │
  │                                                         │
  │  工具开始修改文件前(fileHistoryTrackEdit)               │
  │    └→ 将该文件加入追踪列表,立即备份 v1 版本             │
  └─────────────────────────────────────────────────────────┘

三阶段执行(以 fileHistoryMakeSnapshot 为例):

Phase 1 — Capture(无副作用读取当前状态):
  updateFileHistoryState(state => { captured = state; return state })
  // 通过 no-op updater 读取状态,不触发重渲染

Phase 2 — Async I/O(在 updater 外部执行所有磁盘操作):
  await Promise.all(trackedFiles.map(file => createBackup(file, nextVersion)))
  // 并行备份所有文件,不持有状态锁

Phase 3 — Commit(原子更新状态):
  updateFileHistoryState(state => {
    // 重新读取最新状态(Phase 2 期间可能有 trackEdit 并发写入)
    // 合并新快照 + 继承现有备份
    return newState
  })
```

**为什么分三阶段?**  
如果在 updater 函数内部做 I/O,会阻塞 React 的状态更新队列。Phase 1 先读,Phase 2 在外部做耗时 I/O,Phase 3 再原子更新——这是 Claude Code 中状态管理的一贯模式(与第 7 章中的不可变 State 模式一脉相承)。

### 10.2.7 备份文件的命名与存储

备份文件存储在 `~/.claude/file-history/<session-id>/` 目录下:

```typescript
// 备份文件名格式:{文件路径 SHA1 前 8 位}@v{版本号}
// src/utils/fileHistory.ts
function getBackupFileName(filePath: string, version: number): string {
  const fileNameHash = createHash('sha1').update(filePath).digest('hex').slice(0, 8)
  return `${fileNameHash}@v${version}`
}

// 存储路径
function resolveBackupPath(backupFileName: string): string {
  return join(getClaudeConfigHomeDir(), 'file-history', getSessionId(), backupFileName)
}
```

示例:修改 `/home/user/work/auth.ts`,第一版备份:
```
~/.claude/file-history/session-uuid-xxx/a3f7b291@v1
```

**硬链接优化**:实际存储使用 `link()`(硬链接)而非 `copyFile()`。相同内容的文件共享同一 inode,不占用额外磁盘空间。只有当文件内容真正改变时才创建新文件。

**变化检测(checkOriginFileChanged)**:
```
1. stat 比较文件大小和权限 → 不同则已变化(快速路径)
2. 比较 mtime:原文件 mtime < 备份 mtime → 没变化(时间戳快速路径)
3. 读取两个文件内容逐字节比较 → 慢速路径(兜底)
```

**快照上限**:最多保留 100 个快照(`MAX_SNAPSHOTS = 100`),超出时 FIFO 淘汰最旧的快照。

### 10.2.8 还原(Rewind)

```
用户执行 --rewind-files <messageId>
        ↓
fileHistoryRewind(updateFileHistoryState, messageId)
        ↓
找到目标快照:findLast(snapshot => snapshot.messageId === messageId)
        ↓
applySnapshot():对每个追踪文件:
  - 目标快照中有备份?→ copyFile(备份, 原路径)
  - 目标快照中 backupFileName === null?→ 文件在那个时间点不存在 → unlink(原路径)
  - 目标快照中没有这个文件?→ 继承更早快照中的备份(getBackupFileNameFirstVersion)
```

**`/rewind` vs `--rewind-files` 的区别**:
- `/rewind <messageId>`:截断**对话历史**(消息分支回溯),下一轮对话从那个节点继续
- `--rewind-files <messageId>`:还原**磁盘文件内容**到那个消息时刻
- 两者互补:通常需要同时执行,才能让对话历史和磁盘状态重新一致

---

## 10.3 源码细节

### 10.3.1 `isAutoMemoryEnabled()` 的判断链

```typescript
// src/memdir/paths.ts
export function isAutoMemoryEnabled(): boolean {
  // 1. CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 → 强制关闭
  if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY)) return false
  // 2. CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 → 强制开启
  if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY)) return true
  // 3. --bare 模式 → 关闭
  if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) return false
  // 4. 远程模式且无持久化目录 → 关闭
  if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && !process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) return false
  // 5. settings.json 中的 autoMemoryEnabled 字段
  if (settings.autoMemoryEnabled !== undefined) return settings.autoMemoryEnabled
  // 6. 默认开启
  return true
}
```

### 10.3.2 `MEMORY.md` 截断保护

```typescript
// src/memdir/memdir.ts
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000

export function truncateEntrypointContent(raw: string): EntrypointTruncation {
  // 先按行数截断(200行),再按字节数截断(25KB)
  // 在截断末尾追加警告信息,告知 LLM MEMORY.md 不完整
}
```

警告信息示例:
```
> WARNING: MEMORY.md is 247 lines (limit: 200). Only part of it was loaded.
  Keep index entries to one line under ~200 chars; move detail into topic files.
```

### 10.3.3 `fileHistoryEnabled()` 的开关逻辑

```typescript
// src/utils/fileHistory.ts
export function fileHistoryEnabled(): boolean {
  if (getIsNonInteractiveSession()) {
    // SDK 模式:需要显式开启
    return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING) &&
           !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING)
  }
  // 交互模式:默认开启,可用配置或环境变量关闭
  return getGlobalConfig().fileCheckpointingEnabled !== false &&
         !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING)
}
```

### 10.3.4 追踪文件路径的缩短处理

备份文件名中不存储完整路径,而是用路径的 SHA1 前 8 位:

```typescript
// 路径缩短:将长路径映射到短哈希,避免文件名过长
function maybeShortenFilePath(filePath: string): string {
  if (filePath.startsWith(getOriginalCwd())) {
    // 项目内文件:使用相对路径(便于调试)
    return relative(getOriginalCwd(), filePath)
  }
  // 项目外文件:使用完整路径
  return filePath
}
```

---

## 10.4 最小化产出物

> 代码骨架位于 `../chapters/10/src/`,参考实现位于 `../chapters/10/solution/`。
> **前置条件**:需要 `ANTHROPIC_API_KEY` 环境变量(`npm start` 需要)。

### 本章要实现什么

本章新增两个模块:

**A. `../chapters/10/src/memory.ts`** — 跨会话记忆

```typescript
export const MEMORY_FILE: string        // ./MEMORY.md 路径
export const MAX_MEMORY_LINES: number   // 200

export async function loadMemory(): Promise<string | null>
// 读取 MEMORY.md,超过 200 行时截断并追加 WARNING

export async function saveMemory(content: string): Promise<string>
// 追加写入 MEMORY.md,返回确认消息
```

**B. `../chapters/10/src/history.ts`** — 文件历史检查点

```typescript
export type FileBackup = { filePath: string; backupPath: string | null }
// backupPath === null 表示文件在那个时刻不存在(null 语义)

export async function loadCheckpoints(): Promise<CheckpointStore>
export async function backupFile(filePath: string, messageId: string): Promise<FileBackup>
export async function makeCheckpoint(messageId: string, trackedFiles: string[]): Promise<void>
export async function rewindToCheckpoint(messageId: string): Promise<void>
```

**你需要实现**:
1. `loadMemory()`:读取文件,按行截断(200行),追加 WARNING
2. `saveMemory()`:追加写入,文件不存在时创建
3. `loadCheckpoints()`:读取 JSON 索引,失败时返回 `{ checkpoints: [] }`
4. `backupFile()`:检查文件是否存在,存在则 copyFile,不存在则返回 `{ backupPath: null }`
5. `makeCheckpoint()`:并行备份 + 更新索引 + FIFO 淘汰(最多 100 个)
6. `rewindToCheckpoint()`:按 null 语义还原文件

### 验收步骤