Day 16 / 共 20 天 · 第 4 周 平台化进阶 · 加深版

会话 / 历史 / 记忆

第 4 周「平台化进阶」开篇。前三周讲清了 Agent 的大脑、手脚、扩展;本周讲让它成为一个能长期用的产品——今天先讲对话怎么存/恢复、CLAUDE.md 记忆怎么分层加载。贴真实路径规则和加载注释,先厘清一个大坑:这里有三套不同的"历史"。

📍 你在整门课的位置 · 第 4 周「平台化进阶」(会话记忆 → 配置成本 → TUI → 远程 → 收官)
D15 子代理(W3 收官) D16 会话/历史/记忆 D17 配置/模型/成本 D18 TUI 深入 D19 远程/workflow D20 收官
L01

三套"历史"别搞混

🤔 痛点:关掉终端,聊了半天的上下文就没了? 你和 Claude Code 聊了两小时、它读遍了你的项目,你手一抖关掉终端——明天还得重头再来一遍?还有:它怎么记住"这个项目用 2 空格缩进"这类长期规矩?
💡 本质:会话 = 游戏存档,记忆 = 角色设定卡 对话记录(transcript)就像游戏存档:随时存盘,明天 --resume 读档接着玩,连"花了多少钱"这种进度也一起恢复。CLAUDE.md 记忆则像角色设定卡:不管开哪个存档都自动生效的长期规矩。今天的大坑是——"历史"这个词同时指了三种完全不同的东西,务必分清。

读这块代码最容易懵——"历史"这个词指了三种完全不同的东西:

① UI 状态(内存)

src/state/store.ts

运行时的界面状态(设置/模型/任务/权限)。不含对话消息!

② 输入提示历史(磁盘)

~/.claude/history.jsonl

你输入框敲过的命令(↑ / Ctrl+R 那份),不是对话。

③ 对话 transcript(磁盘)

~/.claude/projects/<cwd>/<id>.jsonl

★ 真正的对话记录。--resume 恢复的就是它。

为什么要分清? 它们各存各的:①内存里的界面数据(关了就没);②你敲过啥命令(跨会话,供你按↑重复);③完整问答记录(供 --resume 接着聊)。读到 history.ts 别以为是对话记录——它是输入框历史。真正的对话在 sessionStorage.ts
L02

UI 状态:极简自研 store

没用 Redux/Zustand,而是一个极简自研 store(src/state/store.ts:10):createStore 提供 getState/setState/subscribe。全局状态形状 AppStatesrc/state/AppStateStore.ts:91)含 settings、mainLoopModel、tasks、mcp、plugins、todos、toolPermissionContext 等。React 绑定用 useSyncExternalStoreuseAppState(selector))。

关键:对话消息不在 AppState 里。 你可能以为全局状态该存对话——但没有。对话消息在 REPL 组件的 messages(Day 04/06 讲的 ref + state,以 initialMessages 传入)。AppState 只管"界面配置类"状态。为什么分开? 对话消息更新极其频繁(每个字)、量大,放进全局 store 会让所有订阅者疯狂重渲染。把它留在 REPL 局部管理(配合 Day 04 性能手法),全局 store 只放低频的配置状态——各得其所。
L03

对话 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(父消息,构成树)、sessionIdcwdgitBranchtimestampversion

为什么按 cwd 分目录? 因为你在不同项目目录用 Claude Code,对话应该分开——在 /proj-a 的对话和 /proj-b 的互不相干。用"转义后的 cwd 路径"当目录名,天然按项目隔离会话。JSONL(每行一个 JSON)适合"不断追加"的日志类数据——每来一条消息追加一行,不用重写整个文件。parentUuid 让消息能组成树(支持分支对话)。子代理(Day 15)的记录单独存在 subagents 子目录,不混进主 transcript——正好呼应 Day 15 讲的"独立上下文"。
L04

--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:131restoreCostStateForSession 会把成本状态也恢复——所以 --continue 后累计花费接着算,不会归零。恢复本质就是"把磁盘上那个 .jsonl 读回来,作为 REPL 的 initialMessages"(Day 04/06 讲的 initialMessages 就是从这来的),然后一切照常。
L05

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 等)。
什么叫"注入进对话的上下文"? 每次请求模型时,除了对话消息,还要给它一些"环境信息"——你在哪个 git 分支、有哪些未提交改动、今天几号、项目的 CLAUDE.md 说了什么规矩。getSystemContext/getUserContext 就是收集这些、拼进请求。这样模型"知道"当前环境,而不是凭空作答。这跟 Day 06 QueryEngine 拼 systemPrompt 是配套的。
L06

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 ...
 */
Managed企业托管,最先加载
User~/.claude/CLAUDE.md(你的全局偏好)
ProjectCLAUDE.md / .claude/rules/*.md(进 git,从 cwd 向上逐级找)
LocalCLAUDE.local.md(本机不进 git 的个人备注)
四层 CLAUDE.md 从上往下加载,越靠下越优先,最后合并成一份注入模型 ① Managed /etc/claude-code/CLAUDE.md(企业统一) ② User ~/.claude/CLAUDE.md(你的全局偏好) ③ Project CLAUDE.md / .claude/rules/*.md(进 git) ④ Local CLAUDE.local.md(本机私人, 最高优先) 优先级递增 →
图注:四层记忆像"公司规章制度"——公司→个人→项目→本机,逐层可覆盖,越具体越优先。
📝 举个例子:同一条规矩被覆盖 User 层 ~/.claude/CLAUDE.md 写"提交信息用英文",但某个项目的 CLAUDE.local.md 写"本项目提交信息用中文" → 合并后在这个项目里"中文"胜出(Local 最优先),换个项目又回到默认"英文"。
为什么分层? 和 Day 09 权限、Day 12 MCP、Day 17 settings 一个套路——不同层次的规矩来自不同角色:企业统一规范(Managed)、你的个人习惯(User)、项目团队约定(Project,进 git 共享)、你在这个项目的私人备注(Local,不进 git)。它们合并后一起注入,近的/具体的优先。从 cwd 向上逐级找意味着你在子目录工作时,项目根和各级目录的 CLAUDE.md 都会生效——就像 Day 13 skills 的目录发现。
L07

@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 能在对话中主动往里写"值得长期记住的事"。

CLAUDE.md vs memdir 的区别:CLAUDE.md 是你手写的规矩("这个项目用 2 空格缩进");memdir 是 Agent 自动沉淀的记忆("上次用户说他偏好用 pnpm")。前者是显式配置,后者是 Agent 的"经验积累"。本教程作者的记忆系统就是 memdir 这套——我在为你写教程时,就往 memdir 里记了"这是逆向 Claude Code 项目""用户要求写完直接 commit"等,下次就不会搞错。这跟 gov-agents 教程 Day 09 的四层记忆是同类思想(长期记忆 + 自我改进)。标注:memdir、team memory 是本 fork 扩展,官方 Claude Code 只有 CLAUDE.md。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 三套"历史"各是什么、各存哪?(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 # 恢复
明天预告 · Day 17:Day 17 讲配置分层与模型/成本——settings.json 五层优先级、/model 选模型、7 个供应商、退出时那句"花了多少钱"怎么算的(贴真实定价表)。

← Day 15 子代理 Day 17 · 配置分层与模型/成本 →