Skip to main content

Session:JSONL 文件与树形结构

Pi 把「一次会话」抽象成一颗 JSONL 树。每条 entry 是一个节点,节点之间靠 idparentId 串成父子关系;当前活跃的位置是「叶子」。这个数据结构撑起了分支、回滚、/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)。

版本演进

Session 文件格式经历过三代:

版本形态说明
v1线性 entry 序列legacy,老 session 自动迁移
v2树形结构(id / parentId引入真正的分支
v3hookMessage role 改名为 custom当前版本

加载时自动迁移——你手上 v1 的老 session 不需要手动转。

文件结构

每行是一个 JSON 对象,靠 type 字段区分;节点间靠 id / parentId 串成树。第一行是 header:

{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path"}

后续行是各种 entry。

基础消息类型

Pi 的消息模型分两层:pi-ai 提供 LLM 协议层的基础类型;pi-coding-agent 在此之上扩展出 coding 场景需要的几种专用消息。

Base(pi-ai

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 缓存命中率。

Extended(pi-coding-agent

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_infosession display name

customcustom_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 可见的线性流。