第 21 课:autoDream — "做梦"系统

6 阅读9分钟

模块七:记忆与上下文 | 前置依赖:第 20 课 | 预计学习时间:55 分钟


学习目标

完成本课后,你将能够:

  1. 解释 autoDream 的三道门控(时间、会话数、合并锁)的设计意图和执行顺序
  2. 描述 consolidationLock 的文件 mtime 时间戳机制与 PID 竞争检测
  3. 理解合并提示的四阶段流程(Orient→Gather→Consolidate→Prune)
  4. 说明只读 Bash 约束与 DreamTask 进度追踪的工作方式

21.1 系统概览:为什么需要"做梦"

人类在睡眠中整理记忆 — 将白天的零散经历整合为结构化的长期知识。Claude Code 的 autoDream 系统做的是类似的事情:在多个工作会话之后,自动启动一个后台 agent,回顾最近的记忆文件和会话记录,将它们整合、去重、更新索引。

会话 1: 修复了认证 bug      ─┐
会话 2: 重构了数据库层       ─┤
会话 3: 添加了 API 测试      ─┤   各自写入零散记忆文件
会话 4: 升级了依赖           ─┤
会话 5: 修复了 CI 管道       ─┘
                              
                                autoDream 触发
                    ┌─────────────────────┐
                       合并(Dream)       
                                         
                      - 合并重复记忆      
                      - 修正过时信息      
                      - 更新索引         
                      - 精简冗长条目      
                    └─────────────────────┘
                              
                              
                    整洁的、最新的记忆库

21.2 三道门控:何时触发"做梦"

autoDream 使用"最便宜优先"(cheapest first)的门控策略 — 每道门的检查成本递增,尽早短路避免不必要的开销。

门控概览

executeAutoDream(context)
  │
  ├── 前置检查(零成本)
  │   ├── KAIROS 模式? → 跳过(用自己的 dream)
  │   ├── 远程模式?   → 跳过
  │   ├── autoMemory 未启用? → 跳过
  │   └── autoDream 未启用?  → 跳过
  │
  ├── 第 1 道:时间门控(一次 stat 调用)
  │   └── 距上次合并 >= 24 小时?
  │       └── 否 → return
  │
  ├── 扫描节流(防止频繁目录扫描)
  │   └── 距上次扫描 < 10 分钟?
  │       └── 是 → return
  │
  ├── 第 2 道:会话门控(目录扫描)
  │   └── 新会话数 >= 5?
  │       └── 否 → return
  │
  └── 第 3 道:合并锁(文件锁 + PID 检查)
      └── 能获取锁?
          └── 否 → return
          └── 是 → 触发合并!

第 1 道:时间门控

const DEFAULTS: AutoDreamConfig = {
  minHours: 24,
  minSessions: 5,
}

// --- Time gate ---
let lastAt: number
try {
  lastAt = await readLastConsolidatedAt()
} catch (e: unknown) {
  return
}
const hoursSince = (Date.now() - lastAt) / 3_600_000
if (!force && hoursSince < cfg.minHours) return

readLastConsolidatedAt() 读取锁文件的 mtime(修改时间),一次 stat() 系统调用即可完成。默认需要 24 小时间隔。

扫描节流

const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000  // 10 分钟

const sinceScanMs = Date.now() - lastSessionScanAt
if (!force && sinceScanMs < SESSION_SCAN_INTERVAL_MS) {
  return
}
lastSessionScanAt = Date.now()

当时间门通过但会话门不通过时,锁文件的 mtime 不会推进(因为没有执行合并),所以时间门在后续每一轮都会通过。扫描节流防止在这种情况下每一轮都扫描目录。

第 2 道:会话门控

let sessionIds = await listSessionsTouchedSince(lastAt)
// 排除当前会话(其 mtime 总是最新的)
const currentSession = getSessionId()
sessionIds = sessionIds.filter(id => id !== currentSession)
if (!force && sessionIds.length < cfg.minSessions) return

扫描项目目录中的会话记录文件(JSONL),找出在上次合并之后被修改过的会话。排除当前会话 — 它的 mtime 肯定是最新的,但不代表"完成的工作"。

第 3 道:合并锁

这是最复杂的门控,涉及分布式锁和进程检测。详见下一节。

配置来源

export function isAutoDreamEnabled(): boolean {
  const setting = getInitialSettings().autoDreamEnabled
  if (setting !== undefined) return setting  // 用户设置优先
  const gb = getFeatureValue_CACHED_MAY_BE_STALE<{ enabled?: unknown } | null>(
    'tengu_onyx_plover', null,
  )
  return gb?.enabled === true  // GrowthBook 开关
}

用户可以在 settings.json 中设置 autoDreamEnabled,明确覆盖远程配置。调度参数(minHoursminSessions)来自 GrowthBook flag tengu_onyx_plover


21.3 consolidationLock:分布式锁机制

核心设计

锁文件 .consolidate-lock 位于记忆目录内,同时承担两个角色:

  1. 互斥锁 — 防止多个 Claude Code 实例同时合并
  2. 时间戳 — 文件的 mtime 就是"上次合并完成时间"
~/.claude/projects/<path>/memory/
├── MEMORY.md
├── user_role.md
├── feedback_testing.md
└── .consolidate-lock    ← mtime = lastConsolidatedAt
                            body  = holder PID

获取锁

export async function tryAcquireConsolidationLock(): Promise<number | null> {
  const path = lockPath()

  // 1. 读取当前状态
  let mtimeMs: number | undefined
  let holderPid: number | undefined
  try {
    const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
    mtimeMs = s.mtimeMs
    const parsed = parseInt(raw.trim(), 10)
    holderPid = Number.isFinite(parsed) ? parsed : undefined
  } catch {
    // ENOENT — 没有锁文件,首次运行
  }

  // 2. 检查是否被持有
  if (mtimeMs !== undefined && Date.now() - mtimeMs < HOLDER_STALE_MS) {
    if (holderPid !== undefined && isProcessRunning(holderPid)) {
      return null  // 锁被活跃进程持有
    }
    // 死进程或无法解析的 PID → 接管
  }

  // 3. 写入自己的 PID
  await mkdir(getAutoMemPath(), { recursive: true })
  await writeFile(path, String(process.pid))

  // 4. 二次确认(防止竞争写入)
  let verify: string
  try {
    verify = await readFile(path, 'utf8')
  } catch {
    return null
  }
  if (parseInt(verify.trim(), 10) !== process.pid) return null

  return mtimeMs ?? 0  // 返回之前的 mtime(用于回滚)
}

竞争检测流程

进程 A                          进程 B
  │                               │
  ├── stat + read lock            │
  │   → mtime=old, PID=dead      │
  │                               ├── stat + read lock
  │                               │   → mtime=old, PID=dead
  ├── writeFile(PID=A)            │
  │                               ├── writeFile(PID=B)  ← 覆盖 A
  │                               │
  ├── readFile → PID=B            ├── readFile → PID=B
  │   ≠ process.pid!              │   === process.pid!
  │   → return null (失败)        │   → return old_mtime (成功)
  │                               │
  ▼                               ▼
  放弃                            执行合并

两个进程同时尝试获取锁时,最后一个写入 PID 的进程获胜。验证步骤确保只有一个进程继续执行。

过期机制

const HOLDER_STALE_MS = 60 * 60 * 1000  // 1 小时

即使持有者 PID 仍在运行,如果锁已经被持有超过 1 小时,也认为是过期的。这防止了 PID 复用导致的永久死锁 — 操作系统会重新分配 PID,一个长时间运行的无关进程可能恰好使用了原持有者的 PID。

回滚机制

export async function rollbackConsolidationLock(priorMtime: number): Promise<void> {
  const path = lockPath()
  try {
    if (priorMtime === 0) {
      await unlink(path)  // 之前没有锁文件 → 删除
      return
    }
    await writeFile(path, '')  // 清除 PID(不再占有)
    const t = priorMtime / 1000  // utimes 需要秒
    await utimes(path, t, t)     // 恢复之前的 mtime
  } catch (e: unknown) {
    // 回滚失败 → 下次触发延迟到 minHours
  }
}

如果合并过程失败,需要将 mtime 恢复到之前的值,这样时间门控可以在下次满足条件时重新触发。清除 PID body 是为了防止当前仍在运行的进程被误认为持有者。


21.4 合并提示:四阶段流程

完整提示结构

consolidationPrompt.ts 构建的提示分为四个阶段:

# Dream: Memory Consolidation

You are performing a dream — a reflective pass over your
memory files. Synthesize what you've learned recently into
durable, well-organized memories so that future sessions
can orient quickly.

Memory directory: `~/.claude/projects/<path>/memory/`
Session transcripts: `~/.claude/projects/<path>/`

## Phase 1 — Orient           ← 了解现状
## Phase 2 — Gather           ← 收集新信号
## Phase 3 — Consolidate      ← 写入/更新
## Phase 4 — Prune and index  ← 精简索引

Phase 1: Orient — 定位现状

- `ls` the memory directory to see what already exists
- Read `MEMORY.md` to understand the current index
- Skim existing topic files so you improve them rather than creating duplicates
- If `logs/` or `sessions/` subdirectories exist, review recent entries

这个阶段让 agent 先了解记忆库的当前状态,避免创建重复条目。

Phase 2: Gather — 收集新信号

Look for new information worth persisting. Sources in rough priority order:

1. Daily logs (logs/YYYY/MM/YYYY-MM-DD.md) if present
2. Existing memories that drifted (facts contradicting current codebase)
3. Transcript search — grep JSONL transcripts for narrow terms

关键约束:不要穷举读取 transcript。JSONL 文件可能很大,只用 grep 搜索特定术语:

grep -rn "<narrow term>" ${transcriptDir}/ --include="*.jsonl" | tail -50

Phase 3: Consolidate — 合并写入

For each thing worth remembering, write or update a memory file.

Focus on:
- Merging new signal into existing topic files
- Converting relative dates to absolute dates
- Deleting contradicted facts

特别强调三个操作:

  • 合并而非新建 — 优先更新已有文件,避免主题碎片化
  • 日期绝对化 — "yesterday" → "2026-04-07",防止时间推移后失去意义
  • 删除矛盾 — 如果新信息推翻了旧记忆,修正源头

Phase 4: Prune and Index — 精简索引

Update MEMORY.md so it stays under ${MAX_ENTRYPOINT_LINES} lines
AND under ~25KB.

- Remove pointers to stale memories
- Demote verbose entries (>200 chars → shorten, move detail to topic file)
- Add pointers to newly important memories
- Resolve contradictions between files

MEMORY.md 是索引,不是内容存储。每条不超过 150 字符,总共不超过 200 行。


21.5 只读 Bash 约束

autoDream 的 forked agent 有特殊的工具权限限制:

const extra = `
**Tool constraints for this run:** Bash is restricted to read-only
commands (\`ls\`, \`find\`, \`grep\`, \`cat\`, \`stat\`, \`wc\`,
\`head\`, \`tail\`, and similar). Anything that writes, redirects to
a file, or modifies state will be denied.`

这是通过 createAutoMemCanUseTool() 在代码层面强制执行的:

if (tool.name === BASH_TOOL_NAME) {
  const parsed = tool.inputSchema.safeParse(input)
  if (parsed.success && tool.isReadOnly(parsed.data)) {
    return { behavior: 'allow' as const, updatedInput: input }
  }
  return denyAutoMemTool(tool, 'Only read-only shell commands are permitted...')
}

BashTool 的 isReadOnly() 方法分析命令内容,判断是否包含写入操作(重定向、管道写入等)。这确保 dream agent 只能观察代码库,不能修改。

写入权限仅限于记忆目录内的 Edit 和 Write 操作:

if ((tool.name === FILE_EDIT_TOOL_NAME || tool.name === FILE_WRITE_TOOL_NAME)
    && 'file_path' in input) {
  if (typeof filePath === 'string' && isAutoMemPath(filePath)) {
    return { behavior: 'allow' as const, updatedInput: input }
  }
}

21.6 DreamTask 进度追踪

任务注册

const abortController = new AbortController()
const taskId = registerDreamTask(setAppState, {
  sessionsReviewing: sessionIds.length,
  priorMtime,
  abortController,
})

Dream 作为一个后台任务被注册到 AppState 的 tasks 中,用户可以在背景任务面板中看到进度和状态。

进度监视器

makeDreamProgressWatcher() 为每个 assistant 消息触发进度更新。它从消息内容中提取文本(用户可以看到推理过程)、统计工具调用次数、收集被 Edit/Write 修改的文件路径,然后通过 addDreamTurn() 更新 DreamTask 状态。

完成与失败处理

try {
  const result = await runForkedAgent({ ... })
  completeDreamTask(taskId, setAppState)

  // 在主对话中显示结果摘要
  if (appendSystemMessage && dreamState.filesTouched.length > 0) {
    appendSystemMessage({
      ...createMemorySavedMessage(dreamState.filesTouched),
      verb: 'Improved',
    })
  }
} catch (e: unknown) {
  if (abortController.signal.aborted) {
    // 用户手动终止 → 不覆盖已有状态
    return
  }
  failDreamTask(taskId, setAppState)
  // 回滚锁,允许下次重试
  await rollbackConsolidationLock(priorMtime)
}

失败时的锁回滚是关键 — 如果不回滚,mtime 已经被推进到当前时间,下一次需要等待完整的 24 小时才能重试。回滚让时间门控可以在下一次满足条件时立即重试(扫描节流提供 10 分钟的退避)。


21.7 完整数据流

┌─────────────────────────────────────────────────────────────┐
│                     autoDream 完整流程                        │
│                                                             │
│  postSamplingHook                                           │
│  ┌──────────────┐                                           │
│  │ isGateOpen() │ ← KAIROS/remote/autoMem/autoDream 检查    │
│  └──────┬───────┘                                           │
│         ▼                                                   │
│  ┌──────────────────┐                                       │
│  │ readLastConsolid  │ ← stat(.consolidate-lock) → mtime   │
│  │ atedAt()         │                                       │
│  └──────┬───────────┘                                       │
│         │ >= 24h?                                           │
│         ▼                                                   │
│  ┌──────────────────┐                                       │
│  │ listSessionsTouch│ ← 扫描 JSONL 文件 mtime              │
│  │ edSince()        │                                       │
│  └──────┬───────────┘                                       │
│         │ >= 5 sessions?                                    │
│         ▼                                                   │
│  ┌──────────────────┐                                       │
│  │ tryAcquireConsol │ ← write PID → verify → 竞争检测      │
│  │ idationLock()    │                                       │
│  └──────┬───────────┘                                       │
│         │ acquired?                                         │
│         ▼                                                   │
│  ┌──────────────────────────────────────────┐               │
│  │  runForkedAgent('auto_dream')             │               │
│  │                                           │               │
│  │  Phase 1: ls memory/, read MEMORY.md      │               │
│  │  Phase 2: grep transcripts for signals    │               │
│  │  Phase 3: Edit/Write memory files         │               │
│  │  Phase 4: Update MEMORY.md index          │               │
│  │                                           │               │
│  │  onMessage → addDreamTurn (进度追踪)      │               │
│  └──────┬───────────────────────────────────┘               │
│         │                                                   │
│         ├── 成功 → completeDreamTask + appendSystemMessage  │
│         └── 失败 → failDreamTask + rollbackConsolidationLock│
└─────────────────────────────────────────────────────────────┘

课后练习

练习 1:门控分析

一个用户在 48 小时内完成了 3 个 Claude Code 会话。autoDream 是否会触发?如果该用户继续工作,最少还需要多少个会话才能触发?在这之前,系统的每轮检查成本是多少?

练习 2:锁竞争模拟

进程 A(PID=1000)获取了合并锁并开始工作。20 分钟后,进程 A 崩溃。进程 B(PID=2000)在 30 分钟后尝试获取锁。详细描述 tryAcquireConsolidationLock() 中每一步的执行结果。如果进程 A 没有崩溃而是在正常运行(只是很慢),结果会有什么不同?

练习 3:提示优化

当前的合并提示对 transcript 搜索的指导是 "grep narrowly, don't read whole files"。设计一个更结构化的 transcript 搜索策略,用 3-5 个具体的 grep 模式覆盖最有价值的信息类型。

练习 4:扩展设计

如果要为 autoDream 添加"优先级合并"功能 — 对包含错误修复或用户明确反馈的会话给予更高优先级 — 你会如何修改门控逻辑和合并提示?考虑如何在不增加 stat 成本的前提下实现。


本课小结

要点内容
三道门控时间(24h) → 会话数(5) → 合并锁,最便宜优先
锁机制.consolidate-lock 的 mtime = lastConsolidatedAt,body = PID
竞争检测write-then-verify 模式,最后写入者获胜
过期策略1 小时超时,防止 PID 复用导致死锁
四阶段合并Orient → Gather → Consolidate → Prune
工具约束Bash 只读,Edit/Write 仅限记忆目录
扫描节流10 分钟间隔,防止频繁目录扫描
失败回滚恢复锁 mtime,允许下次快速重试

下一课预告

第 22 课:Context Compression — 上下文压缩 — 深入 Claude Code 最复杂的子系统之一,理解三层压缩策略:microCompact(轻量级工具结果清理)、autoCompact(基于 token 阈值的自动压缩)、和手动 /compact。包括按 API 轮次分组、图片剥离、摘要生成、文件/技能恢复,以及 feature-gated 的 REACTIVE_COMPACT 和 CONTEXT_COLLAPSE。