JSONL 会话文件怎样记录一棵树
从 session header、entry parentId、延迟建文件和 context projection 出发,说明 JSONL 为什么既保存完整历史,又只向模型恢复当前分支。
Pi 的 session 文件确实是一行一个 JSON 对象,但“JSONL 对话日志”这个简称会遮住两个结构。文件的物理顺序是追加顺序;运行时的逻辑顺序由每条 entry 的 parentId 和当前 leafId 决定。分叉以后,后写入的行未必属于当前模型上下文。
flowchart LR
accTitle: JSONL 行怎样组成会话树
accDescr: header 定义会话身份;每条 entry 指向父节点,当前 leaf 选择一条根到叶路径,context projection 再应用 compaction 与消息类型过滤
H["SessionHeader"] --> U1["message u1\nparentId: null"]
U1 --> A1["message a1"]
A1 --> U2["message u2"]
A1 --> U3["message u3\n后创建的分支"]
U3 --> C["compaction"]
C --> L["current leaf"]
L -. "沿 parentId 回溯" .-> P["当前 branch"]
P --> X["compaction-aware context"]
Header 管身份,entry 管演化
文件第一行是 SessionHeader,包含格式版本、会话 id、timestamp、cwd 和可选的 parentSession。其余行共享 id、parentId、timestamp,再按类型增加 message、model change、thinking level change、compaction、branch summary、custom、custom message、label 或 session info 字段。
其中 custom 与 custom_message 很容易混淆。前者给扩展保存自己的恢复数据,明确不进入 LLM Context;后者会投影成 Agent 自定义消息,可以参与上下文。两者都在树上,也都会推进 leaf。
新 session 会生成 header,清空索引并把 leafId 设为 null。每次 _appendEntry() 都把新对象加入 fileEntries 与 byId,再把 leaf 推到新 id;例如 appendMessage() 自动以旧 leaf 作为 parent。所谓“追加成树”,就是这个 parent 指针不要求父节点等于文件上一行。
第一条用户消息不一定已经有文件
持久化模式下,newSession() 会先算出目标文件名,但 _persist() 看到历史里尚无 assistant message 时,不会创建文件。第一条 user message、初始 model change 和 thinking change 都可能只留在 fileEntries。等首条 assistant message 已加入内存数组,_persist() 才用独占创建模式一次写入 header 与全部累积 entry;之后每条 entry 才直接 append。
这是产品选择,不是通用 JSONL 性质。它避免只输入一半就退出时产生大量空会话,却带来明确边界:终端刚显示用户输入时,sessionFile 可以已经有路径,文件本身仍不存在。若进程在首条 assistant 完成前退出,这段新会话不会靠该 JSONL 恢复。
文件写入使用同步 writeFileSync/appendFileSync,首次创建带 wx 防止覆盖同名文件。源码没有在每条 entry 后执行 fsync,也没有为 session 文件建立跨进程写锁;因此 append-only 说明历史不会通过普通 API 原地修改,不等于数据库级事务或 crash-safe journal。打开文件时,解析器还会跳过 malformed JSON 行,而不是把整份文件判死。
模型看到的是投影,不是所有行
getBranch() 从当前 leaf 沿 parentId 回溯到根,再反转为阅读顺序。getEntries() 则返回文件中的全部 session entry,包含已经离开的分支。两个 API 回答的问题不同:前者是当前路径,后者是完整历史。
buildSessionContext() 还会做第二次投影。它从当前路径还原最近的 model 与 thinking level;若存在 compaction,只保留最新 compaction summary、firstKeptEntryId 起的旧条目,以及 compaction 之后的新条目。最后按 entry 类型转换成 AgentMessage[],普通 custom entry 和 label 自然消失。
依赖已经安装时,可以用现成的确定性测试观察 leaf 改写而不调用模型:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
cd "$repo"
npx vitest --run packages/coding-agent/test/session-manager/tree-traversal.test.ts
# 无依赖时仍可静态核对写入与投影路径
git show v0.83.0:packages/coding-agent/src/core/session-manager.ts |
nl -ba | sed -n '1011,1094p;1255,1303p'
现有固定 checkout 没有 npm 依赖,这次校准只执行了源码核对。JSONL 能恢复已追加 entry 与当前会话结构,不能接回正在执行的工具或流式响应;文件中没有 durable run cursor。
有了树和 leaf,恢复会话似乎只需打开文件,分叉似乎只需换一个 leaf。Coding Agent 的产品入口做得更重。下一章沿 AgentSessionRuntime 看清 resume、fork、new 与同文件内 branch 的差别,以及旧 runtime 在什么时候失效。