会话 / 历史 / 记忆
第 4 周「平台化进阶」开篇。前三周讲清了 Agent 的大脑、手脚、扩展;本周讲让它成为一个能长期用的产品——今天先讲对话怎么存/恢复、CLAUDE.md 记忆怎么分层加载。贴真实路径规则和加载注释,先厘清一个大坑:这里有三套不同的"历史"。
三套"历史"别搞混
--resume 读档接着玩,连"花了多少钱"这种进度也一起恢复。CLAUDE.md 记忆则像角色设定卡:不管开哪个存档都自动生效的长期规矩。今天的大坑是——"历史"这个词同时指了三种完全不同的东西,务必分清。读这块代码最容易懵——"历史"这个词指了三种完全不同的东西:
① UI 状态(内存)
运行时的界面状态(设置/模型/任务/权限)。不含对话消息!
② 输入提示历史(磁盘)
你输入框敲过的命令(↑ / Ctrl+R 那份),不是对话。
③ 对话 transcript(磁盘)
★ 真正的对话记录。--resume 恢复的就是它。
--resume 接着聊)。读到 history.ts 别以为是对话记录——它是输入框历史。真正的对话在 sessionStorage.ts。UI 状态:极简自研 store
没用 Redux/Zustand,而是一个极简自研 store(src/state/store.ts:10):createStore 提供 getState/setState/subscribe。全局状态形状 AppState(src/state/AppStateStore.ts:91)含 settings、mainLoopModel、tasks、mcp、plugins、todos、toolPermissionContext 等。React 绑定用 useSyncExternalStore(useAppState(selector))。
messages(Day 04/06 讲的 ref + state,以 initialMessages 传入)。AppState 只管"界面配置类"状态。为什么分开? 对话消息更新极其频繁(每个字)、量大,放进全局 store 会让所有订阅者疯狂重渲染。把它留在 REPL 局部管理(配合 Day 04 性能手法),全局 store 只放低频的配置状态——各得其所。对话 transcript:真正的会话记录(真实路径)
真实对话存在 src/utils/sessionStorage.ts。真实路径规则(:199、:256):
export function getProjectsDir(): string {
return join(getClaudeConfigHomeDir(), 'projects') // ~/.claude/projects
}
// 主会话 transcript:~/.claude/projects/<转义的cwd>/<sessionId>.jsonl
// 子代理 transcript(Day 15)嵌套在:
// ~/.claude/projects/<cwd>/<sessionId>/subagents/agent-<agentId>.jsonl
每条消息带丰富元数据:parentUuid(父消息,构成树)、sessionId、cwd、gitBranch、timestamp、version。
/proj-a 的对话和 /proj-b 的互不相干。用"转义后的 cwd 路径"当目录名,天然按项目隔离会话。JSONL(每行一个 JSON)适合"不断追加"的日志类数据——每来一条消息追加一行,不用重写整个文件。parentUuid 让消息能组成树(支持分支对话)。子代理(Day 15)的记录单独存在 subagents 子目录,不混进主 transcript——正好呼应 Day 15 讲的"独立上下文"。--continue / --resume:恢复对话
两个旗标(src/main.tsx:1319)让你接着上次聊:
| 旗标 | 行为 |
|---|---|
-c / --continue | 直接恢复最近一次会话 |
-r / --resume [id] | 恢复指定会话(UUID / 标题 / .jsonl 路径 / 交互选择器) |
--fork-session | 基于某会话分叉出新会话 |
流程(--continue):loadConversationForResume(传 undefined 就取最近会话)→ processResumedConversation → REPL 以 initialMessages 启动。
src/cost-tracker.ts:131 的 restoreCostStateForSession 会把成本状态也恢复——所以 --continue 后累计花费接着算,不会归零。恢复本质就是"把磁盘上那个 .jsonl 读回来,作为 REPL 的 initialMessages"(Day 04/06 讲的 initialMessages 就是从这来的),然后一切照常。context.ts:注入进对话的上下文(别弄反)
又一个易混点:src/context.ts(单数文件)和 src/context/(复数目录)是两回事:
src/context.ts= 注入进对话的上下文:getSystemContext(:116,主要是 git 状态)+getUserContext(:155,注入合并后的 CLAUDE.md + 当前日期)。src/context/(复数目录)= 无关的 React UI Context(mailbox/notifications/voice 等)。
getSystemContext/getUserContext 就是收集这些、拼进请求。这样模型"知道"当前环境,而不是凭空作答。这跟 Day 06 QueryEngine 拼 systemPrompt 是配套的。CLAUDE.md 记忆分层(真实注释)
CLAUDE.md 是你给项目/自己定的"长期规矩",每次对话自动注入给模型。src/utils/claudemd.ts 开头的真实注释直接列了加载顺序:
/**
* 1. Managed memory (/etc/claude-code/CLAUDE.md) - 所有用户的全局指令
* 2. User memory (~/.claude/CLAUDE.md) - 你的私人全局指令
* 3. Project memory (CLAUDE.md / .claude/CLAUDE.md / .claude/rules/*.md) - 进 git 的项目指令
* 4. Local memory (CLAUDE.local.md) - 私人的项目专属指令
*
* Files are loaded in reverse order of priority, i.e. the latest files are
* highest priority ...
*/
~/.claude/CLAUDE.md 写"提交信息用英文",但某个项目的 CLAUDE.local.md 写"本项目提交信息用中文" → 合并后在这个项目里"中文"胜出(Local 最优先),换个项目又回到默认"英文"。@import 与 memdir(自动记忆)
@import 指令(claudemd.ts:474):CLAUDE.md 里能用 @path 引入别的文件(@./rel/@~/home/@/abs),把大的规范拆成多个文件复用。代码块内的 @ 不解析、有循环引用防护、外部导入需批准。
memdir 自动记忆(src/memdir/,本 fork 的额外功能):官方 CLAUDE.md 之外的第二套"自动记忆"系统——~/.claude/projects/<slug>/memory/ 下的 MEMORY.md + 主题文件,类型化分类。Agent 能在对话中主动往里写"值得长期记住的事"。
今日小结 + 动手
🧠 今天你应该能回答
- 三套"历史"各是什么、各存哪?(UI 状态内存 / 输入历史 jsonl / 对话 transcript jsonl)
- 为什么对话消息不放全局 store?
- transcript 为什么按 cwd 分目录、用 JSONL?子代理记录存哪?
- --continue/--resume 恢复了什么?(消息 + 成本状态)
- CLAUDE.md 四层记忆 + @import + memdir 各是什么?
✋ 动手:对着真实代码/文件读一遍
# 1. 看真实的会话记录目录
ls ~/.claude/projects/ 2>/dev/null
cat ~/.claude/history.jsonl 2>/dev/null | tail -3 # 输入历史
# 2. transcript 路径规则(L03)
sed -n '199,257p' src/utils/sessionStorage.ts
# 3. CLAUDE.md 分层加载注释(L06)
sed -n '1,12p' src/utils/claudemd.ts
# 4. --continue/--resume
sed -n '3669,3720p' src/main.tsx
# 5. 实操:聊几句退出,再 --continue 接着聊
bun run dev # 聊,退出
bun run dev --continue # 恢复