Pi 的 Session 是什么?JSONL 树形结构撑起分支、回滚、压缩
Pi 把「一次会话」抽象成一颗 JSONL 树。每条 entry 是一个节点,节点之间靠 id 和 parentId 串成父子关系;当前活跃的位置是「叶子」。
- 存储:JSONL 树形文件,按工作目录分组存到
~/.pi/agent/sessions/ - 数据格式:每行一条 entry,类型由
type字段区分,节点靠id/parentId串成树 - 消息模型分两层:
pi-ai给基础 LLM 类型,pi-coding-agent扩展出BashExecution/Custom等专用消息 BashExecution是 Pi 特有:把!、!!命令结构化地放进会话,跟普通 tool result 分开customvscustom_message:前者只存盘不进入 LLM context,后者进入 context- 树形的设计:撑起分支探索、无损压缩、多事件流并行
Pi 把 Session 设计成树而不是线,不是「数据结构的偏好」,而是支撑所有核心能力的基础。
Pi 把「一次会话」抽象成一颗 JSONL 树。每条 entry 是一个节点,节点之间靠
id和parentId串成父子关系;当前活跃的位置是「叶子」。这个数据结构撑起了分支、回滚、/tree导航、压缩摘要等所有能力。
存储位置与会话命令
会话自动存到 ~/.pi/agent/sessions/,按工作目录分组:
~/.pi/agent/sessions/
--Users-kimi-Projects-mysite--/
2026-06-30T10-23-45_a1b2c3d4.jsonl
2026-06-30T14-08-12_e5f6g7h8.jsonl
每个 .jsonl 文件就是一次完整的 session(包含它的分支树)。最常用的 CLI flags:
| Flag | 行为 |
|---|---|
pi -c | 续接最近一次 session |
pi -r | 打开 picker 选择历史 session |
pi --no-session | 临时模式,不写盘 |
pi --name "..." | 启动时设置 display name |
pi --session <path|id> | 用指定文件或 partial id |
pi --fork <path|id> | 从指定 session fork |
交互模式里也有 /resume、/new、/name、/session、/tree、/fork、/clone、/compact、/export、/share 等命令。/share 会把 session 上传到私有 GitHub gist 并生成可分享的 HTML 链接——这是 Pi 鼓励公开 session 的一个具体机制(社区有 badlogic/pi-share-hf 工具一键发到 Hugging Face)。
版本演进:v1 → v2 → v3
Session 文件格式经历过三代:
| 版本 | 形态 | 说明 |
|---|---|---|
| v1 | 线性 entry 序列 | legacy,老 session 自动迁移 |
| v2 | 树形结构(id / parentId) | 引入真正的分支 |
| v3 | hookMessage role 改名为 custom | 当前版本 |
加载时自动迁移——你手上 v1 的老 session 不需要手动转。
文件结构
每行是一个 JSON 对象,靠 type 字段区分;节点间靠 id / parentId 串成树。第一行是 header:
{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path"}
后续行是各种 entry。
消息类型
Pi 的消息模型分两层:
基础消息类型 pi-ai
pi-ai 提供 LLM 协议层的基础类型:
interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: string; provider: string; model: string;
usage: Usage;
stopReason: "stop" | "length" | "toolUse" | "error" | "aborted";
errorMessage?: string;
timestamp: number;
}
interface ToolResultMessage {
role: "toolResult";
toolCallId: string; toolName: string;
content: (TextContent | ImageContent)[];
details?: any;
isError: boolean;
timestamp: number;
}
interface Usage {
input: number; output: number;
cacheRead: number; cacheWrite: number;
totalTokens: number;
cost: { input, output, cacheRead, cacheWrite, total: number };
}
Usage 字段已经把 cost 都算好了——cacheRead / cacheWrite 单独列项,方便优化 prompt 缓存命中率。
扩展消息类型(pi-coding-agent)
pi-coding-agent 在基础类型之上扩展出 coding 场景需要的几种专用消息:
interface BashExecutionMessage {
role: "bashExecution";
command: string; output: string;
exitCode: number | undefined;
cancelled: boolean; truncated: boolean;
fullOutputPath?: string;
excludeFromContext?: boolean; // !! 前缀的命令不进入 context
timestamp: number;
}
interface CustomMessage {
role: "custom"; // 旧名 hookMessage,v3 重命名
customType: string; // extension identifier
content: string | (TextContent | ImageContent)[];
display: boolean; // 是否在 TUI 渲染
details?: any;
timestamp: number;
}
interface BranchSummaryMessage {
role: "branchSummary";
summary: string;
fromId: string;
timestamp: number;
}
interface CompactionSummaryMessage {
role: "compactionSummary";
summary: string;
tokensBefore: number;
timestamp: number;
}
type AgentMessage =
| UserMessage | AssistantMessage | ToolResultMessage
| BashExecutionMessage | CustomMessage
| BranchSummaryMessage | CompactionSummaryMessage;
注意 BashExecutionMessage 是 Pi 特有的——Claude Code 把 bash 执行结果当成普通的 tool result,Pi 单独建了一种消息类型。这让 !command 和 !!command(输出不进入 context)这种特殊行为可以被结构化地表达。
Entry 类型
JSONL 文件里实际存的 entry(每行一个 JSON),除了上面那些 message,还有几种控制型 entry:
| Entry | 用途 |
|---|---|
message | 一条对话消息(携带 message: AgentMessage) |
model_change | 用户切换模型 |
thinking_level_change | 推理等级变化 |
compaction | 上下文压缩摘要 |
branch_summary | 切换分支时摘要废弃的分支 |
custom | 扩展状态,不进 LLM context |
custom_message | 扩展消息,进入 LLM context |
label | 用户书签(label: undefined 清除) |
session_info | session display name |
custom 和 custom_message 的区别是工程上非常重要的:前者只存在磁盘上供扩展恢复状态,后者会作为消息送给 LLM。扩展可以借此「给未来的自己留便条」而不污染 context。
树形结构示例
[user]─[assistant]─[user]─[assistant]─┬─[user] ← 当前 leaf
└─[branch_summary]─[user] ← 分支
每条 entry 有 id(8 字符 hex)和 parentId:
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"...","message":{"role":"user","content":"Hello"}}
「Leaf」就是当前会话活跃位置。所有 appendXxx 操作都会以当前 leaf 作为父节点;branch(entryId) 把 leaf 退回到更早的某个节点,让你从那里继续(产生新分支);resetLeaf() 把 leaf 退到 null,从空会话开始。
SessionManager API
SessionManager 是树操作的入口。静态工厂方法:
SessionManager.create(cwd, sessionDir?) // 新建
SessionManager.open(path, sessionDir?) // 打开已有
SessionManager.continueRecent(cwd, sessionDir?) // 续接最近或新建
SessionManager.inMemory(cwd?) // 纯内存,不持久化
SessionManager.list(cwd, sessionDir?, onProgress?)// 列出某个 cwd 的所有 session
SessionManager.listAll(onProgress?) // 列出全部
SessionManager.forkFrom(srcPath, targetCwd, ...) // 从别的项目 fork
实例上的「追加」方法全部返回新 entry 的 id:
sessionManager.appendMessage(message)
sessionManager.appendThinkingLevelChange(level)
sessionManager.appendModelChange(provider, modelId)
sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)
sessionManager.appendCustomEntry(customType, data?) // 不进 context
sessionManager.appendCustomMessageEntry(customType, content, display, details?) // 进 context
sessionManager.appendLabelChange(targetId, label) // 设置/清除书签
sessionManager.appendSessionInfo(name)
树操作:
sessionManager.getLeafId()
sessionManager.getLeafEntry()
sessionManager.getEntry(id)
sessionManager.getBranch(fromId?) // 从某节点走到 root
sessionManager.getTree()
sessionManager.getChildren(parentId)
sessionManager.getLabel(id)
sessionManager.branch(entryId) // 把 leaf 退到 entryId
sessionManager.resetLeaf()
sessionManager.branchWithSummary(entryId, summary, details?, fromHook?) // 退到 entryId 并插入 branch summary
buildSessionContext() 是最关键的「把树转回线性消息流」的方法:它从当前 leaf 往 root 走,收集路径上的 entries,遇到 CompactionEntry 时把 summary 替换掉被压缩的旧消息,最终输出 LLM 需要的 { messages, thinkingLevel, model }。
CLI 与 SDK 两种使用姿势
CLI 直接传 path 即可:
pi --session /path/to/session.jsonl
pi --session a1b2c3d4
SDK 用 SessionManager:
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
// 纯内存
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
// 持久化
const { session: persisted } = await createAgentSession({
sessionManager: SessionManager.create(process.cwd()),
});
// 续接最近
const { session: continued } = await createAgentSession({
sessionManager: SessionManager.continueRecent(process.cwd()),
});
// 打开指定
const { session: opened } = await createAgentSession({
sessionManager: SessionManager.open("/path/to/session.jsonl"),
});
运行时切换:
runtime.newSession({ parentSession: "..." }) // 派生新 session
runtime.switchSession(path) // 切到指定
runtime.fork(entryId) // 从某 entry fork
runtime.fork(entryId, { position: "at" }) // 等价于 /clone
为什么是「树」而不是「线」
把 session 设计成树而非线性,带来三个具体好处。
第一,分支探索不需要 commit。 在一次会话里你可能想让模型同时尝试两个方案:写一个 !/fork 或在 TUI 里 /tree 跳到前面那条消息重新提交,整棵树自动长出两条分支——不需要新建文件、不需要给分支起名字、不需要决定 commit message。失败的分支可以丢弃,成功的分支作为新的 leaf 继续。
第二,压缩可以无损保留历史。 CompactionEntry 是树上一种特殊的节点——它 不是某条消息的替代品,而是一段历史区间的总结。buildSessionContext() 在构建 LLM context 时才把它替换进 上下文,但磁盘上原始消息还在。所以你可以随时 branch() 到压缩区间内的任意一条 entry,让 Pi 从那里重新展开完整历史。
第三,多个事件流并行可分叉。 用户输入、模型输出、tool result、扩展事件、bookmark label——所有这些事件都是同一种 entry,可以被一视同仁地放进树里。Pi 不需要为每种事件类型单独设计数据结构和存储逻辑。
下一篇讲 Compaction。压缩是树形结构上最复杂的操作:要在不丢历史的前提下、用一段 summary 替换掉成百上千条旧消息,并保证 buildSessionContext() 仍然能正确产出 LLM 可见的线性流。