05 会话文件格式与分支树
05 会话文件格式与分支树
读这一篇的价值:Pi 的“分支、压缩、恢复、导出、扩展持久化”全部落在这份文件格式上;它会直接决定你能否写工具去分析和加工 Pi 的会话。
1. 文件位置与命名
~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl
<session-id>默认是 UUID,也可通过 SDK 或--session-id自定义。<path>是工作目录:去掉前导路径分隔符,并把/、\、:替换为-。- 存储位置可用
--session-dir、PI_CODING_AGENT_SESSION_DIR或sessionDir设置覆盖(CLI 参数优先级最高)。
删除会话就是删对应的 .jsonl;交互式的 /resume 里也可以选会话按 Ctrl+D 确认删除(可用时会调 trash 命令避免永久删除)。
2. 版本演进
| version | 形态 |
|---|---|
| 1 | 线性 entry 序列(遗留,加载时自动迁移) |
| 2 | 树结构,用 id / parentId 链接 |
| 3 | 把 hookMessage 角色重命名为 custom(扩展体系统一) |
会话在加载时自动迁移到当前版本(v3)。
3. 基本结构
JSONL:每行一个 JSON 对象,靠 type 区分。树结构靠 id / parentId,因此同一文件内就能原地分支,不需要新建文件。
除 SessionHeader 外,所有 entry 继承:
interface SessionEntryBase {
type: string;
id: string; // 通常是 8 位 hex;也可能回退为完整 UUID
parentId: string | null; // 根 entry 为 null
timestamp: string; // ISO 8601 字符串
}
注意两种时间戳完全不同:entry 的 timestamp 是 ISO 8601 字符串,而嵌套 message 的 timestamp 是 Unix 毫秒数。
4. 头部
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
由 fork / clone / newSession({ parentSession }) 创建的会话多一个 parentSession 字段,指向原会话文件。
5. Entry 类型全表
| type | 作用 | 是否进入 LLM 上下文 |
|---|---|---|
session | 头部元数据(不在树中) | — |
message | 对话消息(含 system 消息) | 是 |
model_change | 会话内切模型 | 否 |
thinking_level_change | 切推理强度 | 否 |
usage | 模型用量(非 assistant 消息) | 否 |
compaction | 压缩摘要 + 完整 prompt/工具检查点 | 是(摘要) |
context_edit | 只追加的“改上下文而不改历史” | 间接(改目标) |
branch_summary | 分支摘要 | 是 |
custom | 扩展状态持久化 | 否 |
custom_message | 扩展注入的消息 | 是 |
label | 用户书签 | 否 |
session_info | 会话元数据(如名称) | 否 |
5.1 message 与 system 消息的妙处
message 的 message 字段就是一个 AgentMessage。其中 system 角色用来承载 prompt 与工具装载状态:
- 会话第一次请求会持久化一条 system 消息,包含所有 prompt 分区与工具声明;
- 之后的变化持久化为新的 system 消息,按名字 patch
sections(null表示删除该分区),并列出toolsAdded/toolsRemoved。
{"type":"message","id":"a0b1c2d3","parentId":null,"timestamp":"...","message":{"role":"system","content":"","sections":{"preamble":"You are an expert coding assistant...","tools":"<tools>...</tools>","cwd":"/project"},"toolsAdded":[{"name":"read","description":"..."}],"timestamp":1733234400000}}
{"type":"message","id":"d4e5f6g7","parentId":"c3d4e5f6","message":{"role":"system","sections":{"skills":"<skills>...</skills>"},"toolsRemoved":[{"name":"write"}]}}
按顺序回放这些 system 消息就得到当前 prompt 与工具集,不存在单独的 prompt 状态 entry。replace: true 的 system 消息会丢弃先前状态、建立全新基线。
其他消息样例(关键字段):
- user:
content字符串或(TextContent | ImageContent)[]; - assistant:
content为 text/thinking/toolCall 块数组,带api、provider、model、usage、stopReason,较新的还会有thinkingLevel; - toolResult:
toolCallId、toolName、content、isError,可选details/usage。
5.2 compaction 的关键字段
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"...","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000,"systemMessage":{"role":"system","content":"You are a coding assistant.","toolsAdded":[]}}
firstKeptEntryId必填,指向压缩后仍保留的第一条 entry;retain-none 压缩会写入自己的 ID(表示前面全不保留)。systemMessage(可选):压缩边界处的 prompt 分区与工具声明回放;它会成为压缩后上下文的头一条 system 消息,保留区域里的 system 消息在它面前被丢弃。- 其他可选字段:
usage(生成摘要的用量,计入会话总量)、details(默认是{ readFiles, modifiedFiles })、fromHook(旧字段名,标记由扩展生成)。
5.3 context_edit:Pi 的“只改视图不改历史”
{"type":"context_edit","id":"g6h7i8j9","parentId":"f6g7h8i9","targetId":"c3d4e5f6","replacement":null}
replacement: null表示把目标从模型上下文中省略;非 null 则替换目标消息内容。- 目标可以是 user / assistant / toolResult / custom_message entry。
- assistant 与 toolResult 的字符串替换会被规范化为单个 text block(这两个角色必须用 content 数组)。
- 同一目标多条编辑时,活动分支上最新的一条胜出;编辑是分支相对的,导航到编辑之前的位置,目标又会恢复原样。
- 原始 entry 及其元数据在原始历史、UI、导出与会话计费中完全不变。
5.4 其他 entry 要点
branch_summary:parentId是新分支继续的起点,fromId是被舍弃分支原先的 leaf。custom/custom_message:靠customType区分扩展;前者不进上下文,后者进(display控制终端渲染,details不发给模型)。Pi 自身把 virtual model 路由状态存为custom,customType为pi.virtual-model-state。usage:kind是任意字符串(如 cache warming 用"cache_warm");计入会话 token 与费用总数,但在会话树里隐藏;消费者应把未知kind当普通用量处理而不是报错。label:label: undefined表示清除。session_info:/name、--name、pi.setSessionName()写入;会话选择器优先显示它而不是首条消息。
6. 树结构
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← 当前 leaf
│
└─ [branch_summary] ─── [user msg] ← 另一条分支
- 根 entry 的
parentId为null;第一条 entry 初始为根。 - 从较早 entry 继续就会产生新的子节点。
- leaf 就是当前位置。
- 导航 API 可以造出多个根(
resetLeaf()或branchWithSummary(null, ...))。
7. 上下文构建(三个函数看懂 Pi 的读取逻辑)
7.1 buildContextEntries() —— 从 leaf 走到 root
- 收集路径上所有 entry;
- 若路径上有
CompactionEntry(可能多个),取最新的那个:先放压缩 entry,再放firstKeptEntryId到压缩 entry(不含)之间的非 system entry,最后放压缩 entry 之后的 entry; - 保留选中范围内的非 message entry,供交互式界面渲染。
7.2 buildSessionProjection() —— 应用上下文编辑
对每个选中目标应用最新的 context_edit,返回“模型可见消息 + 它们的源 entry”。被省略的目标不产生消息;被替换的保留源 entry 的角色与元数据,只改内容。原始选中 entry 不被修改。
7.3 buildSessionContext() —— 变成 LLM 消息列表
- 从完整路径提取当前模型与 thinking level;
- 转换各 entry:
| entry | 产生的上下文消息 |
|---|---|
message | 存储的 AgentMessage |
compaction | 完整 system 检查点,后跟 compactionSummary |
branch_summary | branchSummary |
custom_message | CustomMessage |
context_edit | 自身无消息 |
usage / custom | 无消息 |
压缩摘要会替换 firstKeptEntryId 之前的 entry;压缩前的 system 消息会被折进完整检查点,而不是从保留区重放。
8. 自己写一个会话分析器
官方给了最小可用的解析示例(节选要点):按行 JSON.parse,用 switch (entry.type) 分支处理 session / message / compaction / branch_summary / usage / custom / custom_message / label / model_change / thinking_level_change。
需要编程创建、持久化与树导航时,用 SDK 的 SessionManager API(见《 SDK 集成》一篇)。
溯源
- 官方
docs/session-format.md、docs/message-types.md - 源码:
packages/coding-agent/src/core/session-manager.ts、core/messages.ts、packages/ai/src/types.ts、packages/agent/src/types.ts