Appearance
Session 存储与分支
实现在 packages/coding-agent/src/core/session-manager.ts。这一层回答:消息怎么落盘、怎么表示分支、恢复时怎么重建上下文。
JSONL 格式
一个 session 文件的第一行是 header,之后每行一个 entry:
json
{"type":"session","version":3,"id":"...","timestamp":"...","cwd":"...","parentSession":"..."}
{"type":"message","id":"...","parentId":null,"timestamp":"...","message":{...}}
{"type":"model_change","id":"...","parentId":"...","timestamp":"...","provider":"anthropic","modelId":"..."}读取时逐行 JSON 解析,坏行直接跳过。CURRENT_SESSION_VERSION 是 3,加载时通过 migrateToCurrentVersion() 升级旧版本。
Entry 类型
所有 entry 共享 type / id / parentId / timestamp。
| type | 作用 |
|---|---|
message | 普通消息:system / user / assistant / toolResult,也可能带 bashExecution |
custom_message | 扩展注入、参与 LLM 上下文的消息 |
custom | 扩展状态,不参与 LLM 上下文 |
compaction | 压缩摘要,带 firstKeptEntryId 和 systemMessage |
branch_summary | 分支切换时对被放弃路径的摘要 |
model_change | 模型选择记录 |
thinking_level_change | 思考级别记录 |
context_edit | 对早期 entry 内容的追加式改写 |
label | 用户书签 |
session_info | 会话显示名 |
usage | 计费记录,如缓存预热 |
appendMessage() 不允许写入 compaction / branch_summary;它们必须走专用方法,便于按类型查找。
树结构
text
SessionManager
fileEntries 按写入顺序的全部 entry
byId id -> entry
leafId 当前分支末端关键操作:
branch(id):只把leafId移到该 entry,不修改也不删除任何 entryresetLeaf():leafId = null,下一次追加会成为新的根getBranch(fromId?):从叶子沿parentId回溯到根,再正序返回getTree():构建完整的父子结构;孤立节点按根处理getChildren(parentId):直接子节点
所以「分支」不是复制数据,而是移动一个指针,所有历史 entry 永远保留。
投影:从树到模型上下文
投影分三步:
text
buildSessionPath() 叶子 -> 根
buildContextEntries() 处理 compaction
buildSessionProjection() 应用 context_edit,产出消息buildSessionPath():
leafId为null时返回空- 有
leafId时用它,否则用最后一条 entry - 沿
parentId回溯后反转
buildContextEntries() 找到路径上最新的 compaction:
text
结果 = [compaction]
+ firstKeptEntryId 开始保留的 entry
+ compaction 之后的所有 entry被摘要掉的旧 entry 不进入结果。注意从 firstKeptEntryId 开始会跳过其中的 system 消息,避免重复提示词。
buildSessionProjection() 还做两件事:
- 收集路径上的
context_edit,对目标 entry 做替换或省略(replacement: null表示从模型上下文里去掉) - 回放
model_change/thinking_level_change/ 最后一条 assistant 消息,得到恢复用的模型和思考级别
context_edit 是分支局部的:另一个分支看不到它。
entry 到消息
sessionEntryToContextMessages() 做转换:
| entry | 产出 |
|---|---|
message | 原消息;缺失 content 时补成空字符串或空数组 |
custom_message | createCustomMessage() |
branch_summary | createBranchSummaryMessage() |
compaction | 有 systemMessage 时产出 system 消息 + 压缩摘要消息,否则只有摘要 |
| 其它 | 空数组 |
容错是有意为之:session 文件可能来自旧版本、fork 或手工编辑。
追加与持久化
所有 appendXxx() 都会:
text
生成 id
parentId = 当前 leafId
push 进 fileEntries、写 byId
leafId = 新 entry id
按需写盘写盘策略:_hasConversation() 为 true(存在 user 或 assistant 消息)之前不创建文件。这样只是打开 Pi 看看,不会留下空的 session 文件。第一条用户消息出现后,header 和已有 entry 一次性写入,之后是纯追加。
appendMessage() 返回 entry id,AgentSession 把它记进 WeakMap,后续扩展事件和 context edit 都靠它定位。
compaction entry
ts
appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage)它还会保存当时的系统提示词快照(systemMessage)。firstKeptEntryId 为空时等于自身 id,表示不保留任何更早的 entry。
分支与 fork
| 操作 | 效果 |
|---|---|
branch(id) | 移动叶子,原地开新分支 |
branchWithSummary(id, summary) | 移动叶子,并追加一条 branch_summary |
createBranchedSession(leafId) | 新建文件,只保留从根到该叶子的路径 |
SessionManager.forkFrom(path, cwd) | 新建文件,新 header 的 parentSession 指向源文件,复制全部 entry |
createBranchedSession() 需要重新串联 parentId,因为 label entry 会被过滤掉,而某些 entry 可能是 label 的子节点。
常用构造入口:
text
SessionManager.create(...) 新会话
SessionManager.continueRecent(...) 继续最近的会话
SessionManager.forkFrom(...) 从别的项目目录 fork
SessionManager.inMemory(...) 不落盘的会话
SessionManager.findById(...) 按 id 找会话文件恢复流程
text
loadEntriesFromFile()
-> 迁移版本
-> _buildIndex()
leafId = 最后一条 entry
-> buildSessionProjection()
messages / model / thinkingLevel
-> AgentSession 写回 agent.stateUI 侧用 buildContextEntries() 渲染保留的历史,用 getBranch() 拿到完整路径。
易错点
firstKeptEntryId可能等于 compaction 自身 id。agent.state.messages是投影结果,不是持久化真相;要看原始历史用getBranch()/getEntries()。leafId会随任意追加前进,包括 label、usage、model_change,这是正常的,路径就是靠parentId串起来的。context_edit不改变原始 entry,只影响投影。
建议阅读顺序
packages/coding-agent/src/core/session-manager.ts的类型定义部分buildSessionPath()/buildContextEntries()/buildSessionProjection()SessionManager的 append 方法branch()/createBranchedSession()/forkFrom()