# 团队记忆同步完全解析

当多个团队成员在同一个代码仓库上使用 Claude Code 时，他们各自积累的"AI 记忆"是割裂的——张三让 Claude 记住了"这个项目用 ESM 模块"，李四的 Claude 完全不知道。团队记忆同步系统（2,167 行）解决了这个问题：它通过 Anthropic API 将项目级记忆上传到云端，让团队成员的 Claude 共享同一份"项目知识"。

> **源码位置**：`src/services/teamMemorySync/` 目录共 5 个 .ts 文件，总计 2,167 行（`wc -l` 实测）：`index.ts`（1,256）、`watcher.ts`（387）、`secretScanner.ts`（324）、`types.ts`（156）、`teamMemSecretGuard.ts`（44）

> 💡 **通俗理解**：就像公司的共享文档——每个员工都可以在自己的笔记本上记笔记（本地记忆），但重要的项目知识要上传到公司 Wiki（云端同步），这样新入职的同事也能看到。团队记忆同步就是这个"自动上传到 Wiki"的机制——而且上传前会自动检查有没有不小心写进去的密码（密钥扫描）。

### 行业背景

AI 编程工具的"多用户记忆共享"是一个前沿领域：

- **GitHub Copilot**：无显式记忆系统，通过代码上下文隐式学习
- **Cursor**：有项目级 `.cursorrules`（相当于 CLAUDE.md），但不是动态同步
- **Aider**：无记忆持久化，每次会话从零开始
- **LangMem / Mem0 / Zep**：第三方记忆框架，提供多用户记忆同步，但需要独立部署

Claude Code 的独特之处在于**原生集成**——记忆同步是内置能力，无需额外基础设施，通过 Anthropic 自有 API 实现，且与 OAuth 认证体系深度绑定。

---

## 概述

本章按以下顺序展开：第 1 节给出系统架构全景；第 2 节解析同步协议（pull + push）；第 3 节深入密钥扫描机制；第 4 节讲解文件监听与实时同步；第 5 节分析多会话冲突处理；第 6 节讨论设计取舍；第 7 节给出批判与反思。

---

> **[图表预留 3.20-A]**：团队记忆同步架构 — 本地 memdir ↔ SyncState ↔ Anthropic API ↔ 其他团队成员

> **[图表预留 3.20-B]**：Pull/Push 同步流程 — ETag 条件请求 → Delta 检测 → 密钥扫描 → 分批上传

---

## 1. 系统架构

### 1.1 组件关系

```
本地文件系统 (.claude/memory/)
  │
  │ fs.watch({recursive: true}) 监听
  ▼
┌────────────────────────────────────────┐
│ watcher.ts — 文件变更监听              │
│  · Node.js 原生 fs.watch（非 chokidar） │
│  · 2s 防抖                             │
│  · 变更事件 → 触发 push                │
└─────────────────┬──────────────────────┘
                  │
                  ▼
┌────────────────────────────────────────┐
│ index.ts — 同步核心                     │
│  · pullTeamMemory(): 服务器 → 本地      │
│  · pushTeamMemory(): 本地 → 服务器      │
│  · 逐 key 内容 checksum 用于 delta 检测 │
│  · ETag 条件请求避免冗余传输           │
│  · MAX_PUT_BODY_BYTES=200KB → 分批 PUT  │
│  · 本地删除不传播（upsert 语义）        │
└─────────────────┬──────────────────────┘
                  │
                  ▼
┌────────────────────────────────────────┐
│ secretScanner.ts — 密钥扫描            │
│  · 上传前检查内容中有无密钥/凭证       │
│  · 36 条 gitleaks 规则（云/AI/VCS 等）  │
│  · 检测到密钥 → 阻止上传 + 警告用户    │
└────────────────────────────────────────┘
                  │
                  ▼
   Anthropic API /api/claude_code/team_memory?repo={owner/repo}
                  │
                  ▼
        其他团队成员的 Claude Code 实例
```

### 1.2 代码量分布

| 文件 | 行数 | 职责 |
|------|------|------|
| `index.ts` | 1,256 | 同步核心：pull/push/hash/batch |
| `watcher.ts` | 387 | 文件变更监听 + 防抖 |
| `secretScanner.ts` | 324 | 密钥泄露检测 |
| `types.ts` | 156 | TypeScript 类型定义 |
| `teamMemSecretGuard.ts` | 44 | 密钥守卫执行器 |
| **合计** | **2,167** | |

---

## 2. 同步协议

### 2.1 Repo 标识

团队记忆以 **`owner/repo` 字符串** 为标识符，直接从 git 远程 URL 归一化得到（而非 SHA256 哈希）：

```typescript
// 实际 API 路径格式（index.ts:9-11 顶部注释）：
// GET  /api/claude_code/team_memory?repo={owner/repo}
// GET  /api/claude_code/team_memory?repo={owner/repo}&view=hashes
// PUT  /api/claude_code/team_memory?repo={owner/repo}

// 效果：
// /Users/张三/projects/my-app ─┐
// /Users/李四/work/my-app    ─┴→ owner/repo = "org/my-app" → 共享同一份团队记忆
```

> **修正说明**：本章早期草稿写的是"`repoId = sha256(gitRemoteUrl)`"——这是事实错误。源码使用明文 `owner/repo` 作为查询参数，**没有对 repo URL 做 SHA256 哈希**。

> 💡 **通俗理解**：就像公司项目的"项目编号"——不管张三的代码放在桌面还是文档文件夹，只要是同一个 Git 仓库（owner/repo 一致），就对应同一个项目编号，共享同一份项目知识库。

### 2.2 Pull（服务器 → 本地）

```typescript
// pullTeamMemory() — 服务器权威模式
async function pullTeamMemory(syncState: SyncState) {
  // 1. GET /team-memory/{repoId}
  //    带 If-None-Match: <上次的 ETag>
  
  // 2. 如果 304 Not Modified → 无变更，跳过
  
  // 3. 如果 200 OK:
  //    服务器返回所有 key-value 对
  //    本地文件被覆写（服务器权威）
  //    更新本地 ETag 缓存
}
```

**关键设计：服务器权威**——pull 时不做合并，直接用服务器数据覆盖本地。这简化了冲突处理，但意味着如果两人同时修改同一条记忆，后提交的会覆盖先提交的。

### 2.3 Push（本地 → 服务器）

```typescript
// pushTeamMemory() — Delta 推送
async function pushTeamMemory(syncState: SyncState) {
  // 1. 读取本地所有记忆文件
  //
  // 2. 对每个文件计算内容校验和（entryChecksums，逐 key 而非整文件哈希）
  //
  // 3. 对比上次已知的服务器状态（serverChecksums）：
  //    - 哈希相同 → 跳过（无变更）
  //    - 哈希不同 → 标记为"需上传"（upsert）
  //    - 本地新增 → 标记为"需上传"（upsert）
  //    - 本地删除 → **不传播**（源码 index.ts:18 顶部注释明确："File deletions do NOT
  //      propagate: deleting a local file won't remove it from the server, and the
  //      next pull will restore it locally"）
  //
  // 4. 密钥扫描（见第3节）
  //
  // 5. 分批 PUT（见 2.4），服务器使用 upsert 语义：PUT 里没出现的 key 保持不变
}
```

> **关键修正**：本章早期把"本地删除 → 标记为需删除"描述成系统行为——这是事实错误。源码**刻意不传播删除**：如果你在本地 `rm` 了一条记忆，下次 pull 时服务器版本会把它重新下载回来。若需真正从团队记忆中移除一条，需使用专门的 `soft_delete_keys` 机制（由用户显式触发，非 watcher 自动行为）。

### 2.4 分批上传

实际有**两个不同的上限**（`index.ts:75, 89`）：

```typescript
const MAX_FILE_SIZE_BYTES = 250_000   // 单个 entry（单条记忆）本身的大小上限 ≈ 250KB
const MAX_PUT_BODY_BYTES  = 200_000   // 单次 PUT 请求 body 的大小上限 ≈ 200KB
```

**注意**：`250KB` 是"单条记忆最大允许"的文件级限制；`200KB` 才是"一次 PUT 请求能传多少字节"的批级限制。两者不是一回事——早期草稿把它们混为一谈（"GATEWAY_BODY_LIMIT = 250 * 1024"）。

```typescript
// 分批逻辑（简化版，完整实现见 index.ts:415-461）
function batchPush(entries: MemoryEntry[]) {
  let batch: MemoryEntry[] = []
  let batchBytes = 0

  for (const entry of entries) {
    if (batchBytes + entry.size > MAX_PUT_BODY_BYTES && batch.length > 0) {
      await putBatch(batch)  // 当前批次已满 → 发送
      batch = []
      batchBytes = 0
    }
    if (entry.size > MAX_PUT_BODY_BYTES) {
      // 单条即超过 PUT body 上限（但未超 MAX_FILE_SIZE_BYTES）→ 独占一个 solo batch
      await putBatch([entry])
      continue
    }
    batch.push(entry)
    batchBytes += entry.size
  }
  if (batch.length > 0) await putBatch(batch)
}
```

当单条 entry 超过 `MAX_PUT_BODY_BYTES` 但仍在 `MAX_FILE_SIZE_BYTES` 以内时，它会独占一个 solo batch——这个分支在上面的伪码中显式处理，不能漏。

### 2.5 容量限制处理

```typescript
// 服务器返回 413 Payload Too Large 时:
// 响应体包含结构化信息: { max_entries: 100 }
// 客户端学习这个限制，本地截断超出的条目
// 下次 push 时自动遵守限制
```

---

## 3. 密钥扫描（secretScanner.ts）

上传到云端的记忆数据会被团队成员共享——如果记忆中不小心包含了 API 密钥或数据库密码，就是一次安全事故。

### 3.1 扫描时机

```
记忆文件变更
  → 触发 push
  → push 之前执行密钥扫描
  → 发现密钥 → 阻止上传 + 警告用户
  → 未发现密钥 → 继续上传
```

### 3.2 检测模式

**规则来源：gitleaks**（`secretScanner.ts:6-11` 注释明确说明）：

```typescript
// secretScanner.ts:6-11 原文：
// Rule IDs and regexes sourced directly from the public gitleaks config:
// https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml
```

总共 36 条 `SECRET_RULES`（`grep -c "^\s*id: '"` 核实），覆盖范围远超早期草稿列出的"API Key / JWT / 私钥 / AWS"几类。实际按类别分组举例（rule ID 与源码一一对应，完整正则见 `secretScanner.ts:48-225`）：

| 类别 | 部分 rule ID（源码完整列表） |
|------|-----|
| 云提供商 | `aws-access-token`、`gcp-api-key`、`azure-ad-client-secret`、`digitalocean-pat`、`digitalocean-access-token` |
| AI API | `anthropic-api-key`、`anthropic-admin-api-key`、`openai-api-key`、`huggingface-access-token` |
| 版本控制 | `github-pat`、`github-fine-grained-pat`、`github-oauth`、`github-app-token`、`gitlab-pat`、`gitlab-pipeline-trigger-token` |
| 包/构件仓库 | `npm-access-token`、`pypi-upload-token` |
| 支付/通信 | `stripe-access-token`、`sendgrid-api-token`、`slack-bot-token`、`slack-legacy-token`、`telegram-bot-api-token` |
| 私钥/证书 | `private-key-rsa`、`private-key-openssh`、`private-key-ec`、`private-key-pgp` |
| 其他 | `postman-api-token`、`readme-api-token`、`atlassian-api-token`、`asana-client-secret` 等 |

> **重要修正**：本章早期草稿自己编写了 5 条正则（如 `/(?:sk|pk|api)[-_](?:live|test|prod)?.../`），这些**不存在于源码中**，属于事实捏造。真实实现是逐条对应 gitleaks 的 Go 正则转译为 JS 正则（源码注释中还标注了 Go → JS 的转译差异，如 `(?i)` 大小写标志的处理）。

### 3.3 永久抑制

某些错误是"永久性"的——不应该无限重试：

```typescript
// 永久失败类型:
// - 403 Forbidden: 用户无权使用团队记忆
// - 404 Not Found: 团队记忆功能未开启
// - no_oauth: 用户未完成 OAuth 授权

// 遇到永久失败 → 抑制文件监听 → 直到重启才重试
// 防止无限重试循环浪费资源
```

---

## 4. 文件监听与实时同步

### 4.1 文件监听：`fs.watch({recursive: true})`

**源码实际使用 Node.js 原生 `fs.watch`，不是 chokidar**（`watcher.ts:150-151` 原注释："Uses `fs.watch({recursive: true})` on the directory (not chokidar). chokidar 4+ dropped fsevents, and Bun's `fs.watch` fallback uses kqueue..."）。

```typescript
// watcher.ts 实际实现（简化版）
function watchTeamMemory(memoryDir: string) {
  // fs.watch 在目录上监听，不区分 add/change/unlink —— 三者都触发同一回调
  const watcher = fs.watch(memoryDir, { recursive: true }, (eventType, filename) => {
    scheduleDebouncedPush()
  })

  watcher.on('error', err => {
    logWarn(`team-memory-watcher: fs.watch error: ${errorMessage(err)}`)
  })
}
```

> **关键差异**：
> - `chokidar.watch` 会对外暴露 `'change'` / `'add'` / `'unlink'` 三个事件，调用方可以区别对待；
> - `fs.watch` 只给一个 `eventType` 字符串（`'rename'` / `'change'`），**不可靠地区分增删**——watcher.ts 注释明确提到 "fs.watch doesn't distinguish unlink from too-many-entries"；
> - 因此 team memory 的上传逻辑采取"所有变更都重新 hash 比对"的策略，把事件类型当成"有东西变了"的粗粒度信号，具体变化由后续的 `serverChecksums vs localChecksums` delta 计算判断。

**早期草稿错误修正**：
- 事实错误 ① 使用 chokidar → 实际 fs.watch；
- 事实错误 ② `awaitWriteFinish: { stabilityThreshold: 300 }` → fs.watch 不支持此选项，源码在"文件稳定"上依赖业务层的 debounce + 内容 hash 去重，而非监听器级的 stabilityThreshold；
- 事实错误 ③ 三个独立 `watcher.on('change'/'add'/'unlink')` 绑定 → 源码是单回调（见上）。

### 4.2 防抖策略

2 秒防抖确保批量文件修改（如 AI 一次更新多条记忆）只触发一次 push：

```
t=0ms:    memory/user_role.md 变更
t=500ms:  memory/project_info.md 变更
t=1200ms: memory/feedback_style.md 变更
t=3200ms: 距离最后一次变更 2000ms → 触发 push（3 个文件一起上传）
```

---

## 5. 多会话冲突处理

### 5.1 乐观并发

团队记忆同步使用 **ETag-based 乐观并发控制**：

```
会话A: pull (ETag: v1) → 修改记忆 → push (If-Match: v1) → 成功 (ETag: v2)
会话B: pull (ETag: v1) → 修改记忆 → push (If-Match: v1) → 409 Conflict
                                     → re-pull (ETag: v2) → 重算 hashes+delta → push (If-Match: v2)
```

> **注意**：冲突恢复路径中**没有内容级 merge 步骤**——源码 `index.ts:882-884` 明确说明 "Content-level merge (same key, both changed) is not attempted — the local version simply overwrites the server version for that key"。同 key 冲突按 Last-Write-Wins 覆盖，详见 5.2。

### 5.2 Last-Write-Wins

对于同一个 key 的冲突，当前实现采用 **Last-Write-Wins** 策略——不做内容合并，后写入的覆盖先写入的。这在实践中可接受，因为：

- 团队记忆条目通常是独立的（每人负责不同领域的知识）
- 同时修改同一条记忆的概率很低
- 合并语义对于自然语言文本来说很复杂（不像代码有行级 diff）

### 5.3 OAuth Token 刷新

同步需要有效的 OAuth token。当 token 过期时：

```typescript
// 自动刷新流程:
// 1. API 返回 401 Unauthorized
// 2. 使用 refresh_token 获取新的 access_token
// 3. 重试原请求
// 如果 refresh 也失败 → 标记为永久失败 → 停止同步
```

---

## 6. 设计取舍

### 6.1 服务器权威 vs 本地权威

| 方面 | 服务器权威（当前方案） | 本地权威 |
|------|---------------------|---------|
| 冲突处理 | 简单（服务器说了算） | 复杂（需要合并算法） |
| 离线编辑 | 上线后可能被覆盖 | 离线编辑保留 |
| 一致性 | 服务器端线性化 + 客户端最终一致（客户端需显式 pull 才看到其他人的更新） | 最终一致 |
| 实现复杂度 | 低 | 高（需要 CRDT 或 OT） |

当前选择合理：团队记忆是"辅助信息"而非"关键数据"，偶尔的覆盖不会造成严重后果。需要澄清的是，"服务器权威"指的是**服务器端**的存储是权威版本（Last-Write-Wins + ETag 并发控制），但**客户端之间看到相同数据需要各自主动 pull**——这是典型的客户-服务器异步同步模式，不是多副本强一致。

### 6.2 密钥扫描的假阳性

正则表达式匹配不可避免地会有假阳性——某些合法的记忆内容（如讨论 API 密钥格式的记忆）会被误判为密钥泄露。当前没有"白名单"机制让用户跳过密钥扫描，这可能导致用户困扰。

### 6.3 无版本历史

团队记忆没有版本历史——一旦覆盖，旧内容就丢失了。对于重要的项目知识，这可能是个风险。未来可以考虑：

- 保留最近 N 个版本
- 在覆盖前自动备份到 `.claude/memory/_history/`
- 与 git 集成，让记忆变更也进入版本控制

---

## 7. 批判与反思

### 7.1 隐私边界模糊

团队记忆同步意味着你的 Claude "记住"的东西会被同事看到。如果用户习惯在记忆中存储个人偏好（如"我不喜欢某个同事的代码风格"），同步后可能造成尴尬。当前没有"私有记忆 vs 团队记忆"的清晰分界。

### 7.2 对 Anthropic 后端的强依赖

同步必须经过 Anthropic 的 API——没有自托管选项。对于在意数据主权的企业（如金融、医疗行业），将项目知识上传到第三方云端可能不被允许。

### 7.3 与 CLAUDE.md 的定位重叠

CLAUDE.md 文件也能存储项目级指令，且通过 git 自然同步。团队记忆同步与 CLAUDE.md 的定位有部分重叠，用户可能困惑"什么放 CLAUDE.md，什么放团队记忆"。区分原则：

- **CLAUDE.md**：静态的、代码仓库级的指令（所有人都看到，通过 git 同步）
- **团队记忆**：动态的、运行时发现的知识（AI 学到的东西，自动同步）

> 🔑 **深度洞察**：团队记忆同步系统虽然只有 2,167 行，但它触及了 AI 协作工具最前沿的问题——**AI 的知识应该属于个人还是团队？** 当你让 Claude "记住"一个项目的技术决策，这个知识归你还是归项目？当多人的 AI 记忆冲突时，谁说了算？这些问题目前通过"服务器权威 + Last-Write-Wins"粗暴解决，但随着 AI 记忆系统的成熟，它们会成为需要认真回答的产品哲学问题。
