模块七:记忆与上下文 | 前置依赖:第 20 课 | 预计学习时间:55 分钟
学习目标
完成本课后,你将能够:
- 解释 autoDream 的三道门控(时间、会话数、合并锁)的设计意图和执行顺序
- 描述 consolidationLock 的文件 mtime 时间戳机制与 PID 竞争检测
- 理解合并提示的四阶段流程(Orient→Gather→Consolidate→Prune)
- 说明只读 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,明确覆盖远程配置。调度参数(minHours、minSessions)来自 GrowthBook flag tengu_onyx_plover。
21.3 consolidationLock:分布式锁机制
核心设计
锁文件 .consolidate-lock 位于记忆目录内,同时承担两个角色:
- 互斥锁 — 防止多个 Claude Code 实例同时合并
- 时间戳 — 文件的 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。