pi Coding Agent 会话文件格式(Session Format)详解:JSONL 存储、树状分支结构与上下文重建机制
pi 的 coding-agent 将每次会话持久化为 JSONL(JSON Lines)文件:每行一个带 type 字段的 JSON 对象,条目之间通过 id/parentId 字段形成树状结构,从而支持在同一个文件内原地分支(branching)而无需创建新文件。本文完整讲解 pi 会话文件的存储位置与命名规则、版本迁移机制、全部条目类型与消息类型定义,以及 SessionManager 如何从叶子节点回溯构建 LLM 上下文,并结合 session-manager.ts 等源码印证其实现细节,帮助你在解析会话文件、编写扩展或开发工具链时精确掌握该格式。
文件位置与删除方式
会话文件统一存放在用户 agent 目录下的 sessions 子目录中,路径模式为:
~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl
其中 <path> 是工作目录(cwd),将 / 替换为 - 后的安全目录名。该规则在源码中可以找到对应实现:getSessionsDir() 返回 ~/.pi/agent/sessions,而 getDefaultSessionDirPath() 进一步展示目录编码逻辑——将 cwd 解析为绝对路径后,去掉开头的路径分隔符,再把 /、\、:(如 Windows 盘符冒号)统一替换为 -,并包在 --...-- 中作为子目录名:
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
return join(resolvedAgentDir, "sessions", safePath);
文件名为 <timestamp>_<uuid>.jsonl,同一项目目录下的会话聚集在同一个 --<path>-- 目录内,便于按项目归档。
删除会话有两种方式:
- 直接删除文件:删除
~/.pi/agent/sessions/下对应的.jsonl文件即可; - 交互式删除:在 TUI 中执行
/resume,选中某个会话后按Ctrl+D并确认。当系统可用时,pi 会调用trashCLI 走回收站以避免永久删除。
会话版本与自动迁移
每个会话文件的头部(header)携带 version 字段。当前仓库中当前版本号为 3,定义在 session-manager.ts#L30:
export const CURRENT_SESSION_VERSION = 3;
三个版本的语义如下:
| 版本 | 结构特征 |
|---|---|
| Version 1 | 线性条目序列(legacy,加载时自动迁移) |
| Version 2 | 通过 id/parentId 链接的树状结构 |
| Version 3 | 将 hookMessage role 更名为 custom(扩展统一化) |
旧版会话在加载时会被自动迁移到 v3。迁移函数直接写在 session-manager.ts#L231-L291 中,值得注意的两个实现细节:
- v1 → v2(
migrateV1ToV2):为每个条目补发 8 位十六进制id,并按文件顺序把前一条的id写入parentId,从而把线性序列“缝合”成一条链式树;同时把 compaction 条目中的firstKeptEntryIndex(下标)转换为firstKeptEntryId(条目 ID),因为树状结构下下标不再稳定。 - v2 → v3(
migrateV2ToV3):遍历message条目,将role为hookMessage的消息改名为custom,与扩展统一后的CustomMessage对齐。
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
const header = entries.find((e) => "session" === e.type); // 取 header
const version = header?.version ?? 1;
if (version >= CURRENT_SESSION_VERSION) return false;
if (version < 2) migrateV1ToV2(entries);
if (version < 3) migrateV2ToV3(entries);
return true;
}
v1 会话的 header 甚至没有 version 字段(源码中 SessionHeader.version 注释为 "v1 sessions don't have this"),因此 version ?? 1 是解析时的兜底逻辑。
源码与类型定义位置
官方文档列出的核心源文件在本仓库中的对应路径:
- session-manager.ts —— 会话条目类型定义与
SessionManager类; - messages.ts —— 扩展消息类型(
BashExecutionMessage、CustomMessage等)及createBranchSummaryMessage、createCompactionSummaryMessage、createCustomMessage等构造函数; - packages/ai/src/types.ts —— 基础消息类型(
UserMessage、AssistantMessage、ToolResultMessage); - packages/agent/src/types.ts ——
AgentMessage联合类型。
另外,harness 层的会话存储与 compaction 类型(含下文 retainedTail 字段)定义在 packages/agent/src/harness/session/types.ts 与 packages/agent/src/harness/session/context.ts 中。如果你在自己的项目里使用发布版包,可以查看 node_modules/@earendil-works/pi-coding-agent/dist/ 与 node_modules/@earendil-works/pi-ai/dist/ 中的 TypeScript 声明。
消息类型详解
会话条目中的 message 字段承载 AgentMessage 对象,它是解析会话、编写扩展的核心。
内容块(Content Blocks)
消息内容由一组带类型的结构化块组成:
interface TextContent {
type: "text";
text: string;
}
interface ImageContent {
type: "image";
data: string; // base64 编码
mimeType: string; // 例如 "image/jpeg"、"image/png"
}
interface ThinkingContent {
type: "thinking";
thinking: string;
}
interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
}
基础消息类型(来自 pi-ai)
interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number; // Unix 毫秒
}
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; // 工具特定元数据
usage?: Usage; // 工具内部产生的嵌套 LLM 用量
isError: boolean;
timestamp: number;
}
interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
一个容易踩坑的细节:pi-ai 导出的 StopReason 类型其实还包含 "pending",但该值仅用于流式事件中的部分消息。终端的 done/error 事件会在 pi 持久化 assistant 消息前把它替换为最终完成原因,因此 "pending" 不应出现在任何会话 JSONL 中。
扩展消息类型(来自 pi-coding-agent)
定义于 messages.ts:
interface BashExecutionMessage {
role: "bashExecution";
command: string;
output: string;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
excludeFromContext?: boolean; // !! 前缀命令为 true
timestamp: number;
}
interface CustomMessage {
role: "custom";
customType: string; // 扩展标识
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;
}
BashExecutionMessage 的 excludeFromContext 字段与 !! 前缀命令直接相关:这类命令的输出只记录在会话文件中、不进入 LLM 上下文。
AgentMessage 联合类型
type AgentMessage =
| UserMessage
| AssistantMessage
| ToolResultMessage
| BashExecutionMessage
| CustomMessage
| BranchSummaryMessage
| CompactionSummaryMessage;
条目基础结构(Entry Base)
除 SessionHeader 外,所有条目都继承 SessionEntryBase(见 session-manager.ts#L46-L51):
interface SessionEntryBase {
type: string;
id: string; // 8 位十六进制 ID
parentId: string | null; // 父条目 ID(首条目为 null)
timestamp: string; // ISO 时间戳
}
id 的生成逻辑在 generateId():截取 randomUUID() 的前 8 个字符,并在传入的 ID 集合中做碰撞检查(最多重试 100 次,极端情况回退为完整 UUID)。parentId 为 null 表示该条目是树的首个节点。
条目类型全览
SessionHeader
文件第一行,仅含元数据,不属于树(没有 id/parentId)。类型定义见 session-manager.ts#L32-L39:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
对于有父会话的会话(通过 /fork、/clone 或 newSession({ parentSession }) 创建),header 会额外携带 parentSession 字段,指向原会话文件路径:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}
SessionMessageEntry
对话消息。message 字段即上述 AgentMessage:
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false}}
ModelChangeEntry
用户中途切换模型时写入:
{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
ThinkingLevelChangeEntry
用户更改思考/推理级别时写入:
{"type":"thinking_level_change","id":"e5f6g7h8","parentId":"d4e5f6g7","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
这两类条目本身不产生消息,但构建上下文时会被 buildSessionContext() 沿路径提取,用来还原“当前生效的模型与思考级别”。
CompactionEntry
上下文压缩(compaction)时创建,保存对更早消息的摘要。基础形态:
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
较新的 harness 生成的压缩条目会把压缩后保留的上下文直接内嵌为 retainedTail,替代 firstKeptEntryId:
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","tokensBefore":50000,"retainedTail":[{"role":"user","content":"latest request"},{"role":"assistant","content":[{"type":"text","text":"latest reply"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}]}
可选字段说明:
usage:生成摘要所消耗的 LLM 用量,计入会话 token 与成本总计;retainedTail:压缩后保留的物化AgentMessage[]。它“可选”只是为了向后兼容旧会话——新的 harness 生成压缩都会带上它,使该条目成为自包含检查点(checkpoint),重建上下文时无需回溯压缩点之前的旧条目;details:实现特定数据,如默认实现的{ readFiles: string[], modifiedFiles: string[] },或扩展自定义数据;fromHook:true表示由扩展生成,false/缺省表示 pi 生成(遗留字段名);firstKeptEntryId:兼容旧条目格式。
retainedTail 相关的类型与上下文重建逻辑位于 harness 层,可参考 packages/agent/src/harness/session/context.ts 与 packages/agent/src/harness/compaction/compaction.ts,对应测试有 packages/agent/test/harness/session/context.test.ts 和 packages/agent/test/harness/compaction.test.ts。
BranchSummaryEntry
通过 /tree 切换分支时创建,携带 LLM 生成的“被放弃分支到公共祖先”的摘要,用于捕获被弃路径的上下文:
{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:15:00.000Z","fromId":"f6g7h8i9","summary":"Branch explored approach A..."}
可选字段:
usage:生成摘要的 LLM 用量,计入会话总计;details:文件追踪数据(默认实现为{ readFiles: string[], modifiedFiles: string[] })或扩展自定义数据;fromHook:是否由扩展生成(遗留字段名)。
CustomEntry
扩展状态持久化,不参与 LLM 上下文。扩展在重载时可扫描 customType 恢复内部状态:
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}}
交互模式下可通过 pi.registerEntryRenderer(customType, renderer) 渲染自定义条目,但它们依然不进入 LLM 上下文。源码注释在 CustomEntry 定义处 明确了这一点:“Does NOT participate in LLM context (ignored by buildSessionContext)”。
CustomMessageEntry
扩展注入的、参与 LLM 上下文的消息,与 CustomEntry 的区别正在于此:
{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-extension","content":"Injected context...","display":true}
字段说明:
content:字符串或(TextContent | ImageContent)[](与UserMessage相同);display:true表示在 TUI 中以区别化样式显示,false表示隐藏;details:可选的扩展特定元数据(不会发送给 LLM)。
源码注释(session-manager.ts#L123-L141)指出:custom_message 在 buildSessionContext() 中会被转换为 user 消息进入上下文。
LabelEntry
用户为某条目定义的书签/标记:
{"type":"label","id":"j0k1l2m3","parentId":"i9j0k1l2","timestamp":"2024-12-03T14:30:00.000Z","targetId":"a1b2c3d4","label":"checkpoint-1"}
注意 targetId 与 parentId 语义不同:targetId 是被标记的目标条目,label 设为 undefined 表示清除标签(此时仍写入一条 label 条目作为变更记录)。
SessionInfoEntry
会话元数据,例如用户定义的显示名。可通过 /name、--name / -n 或扩展中的 pi.setSessionName() 设置:
{"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"}
设置会话名后,会话选择器(/resume)会显示该名称而非首条消息。
完整的条目联合类型 SessionEntry(共 9 种,另加 header 构成 FileEntry)定义在 session-manager.ts#L143-L156。
树状结构
条目构成一棵树:
- 首条目
parentId: null; - 每个后续条目通过
parentId指向上一个条目; - 分支从更早的某个条目长出新子节点;
- “叶子(leaf)”即当前在树中的位置。
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
│
└─ [branch_summary] ─── [user msg] ← alternate branch
这种“单文件、多分支”设计意味着 /tree 中切换分支只是移动叶子指针(branch(entryId)),不需要复制文件;而 createBranchedSession(leafId) 则能把某个分支抽取为独立的会话文件。
上下文构建(Context Building)
buildContextEntries()
从当前叶子回溯到根,产出“生效的条目列表”,并正确处理 compaction。buildContextEntries() 的实现 逻辑为:
- 先收集当前叶子路径上的所有条目;
- 若路径上存在
CompactionEntry:- 压缩条目本身放在结果开头;
- 若条目带有
retainedTail,它充当自包含检查点,压缩点之后的条目直接纳入; - 否则(旧格式)从
firstKeptEntryId到压缩条目之间的条目纳入; - 再把压缩条目之后的所有条目追加进来;
- 范围内保留非消息条目,以便交互模式可以渲染它们(如 model_change 记录)。
coding-agent 侧的 buildContextEntries() 实现展示了旧格式(firstKeptEntryId)的处理路径:找到路径上最新的 compaction 条目,把“从 firstKeptEntryId 起、到 compaction 之前”的条目挑出来,再拼接 compaction 之后的全部条目,更早的被摘要条目则被丢弃。
buildSessionContext()
buildSessionContext() 在条目列表之上产出发给 LLM 的消息列表,同时解析当前模型与思考级别:
- 从完整路径中提取当前的模型与思考级别设置(最近的
model_change/thinking_level_change); - 将选中的条目转换为消息:
message→ 存储的AgentMessage;compaction→compactionSummary,若存在则加retainedTail;branch_summary→branchSummary;custom_message→CustomMessage;custom→ 不产生上下文消息。
条目到消息的转换函数 sessionEntryToContextMessages() 位于 session-manager.ts#L400-L408,它调用 messages.ts 中的 createBranchSummaryMessage / createCompactionSummaryMessage 构造器。其结果是:新版压缩条目表现为自包含检查点;retainedTail 之所以“可选”,只是为了让仅存 firstKeptEntryId 的旧会话仍能正确加载。
会话文件解析示例
下面是一个按 type 分发的最小解析器,覆盖文档定义的全部条目类型:
import { readFileSync } from "fs";
const lines = readFileSync("session.jsonl", "utf8").trim().split("\n");
for (const line of lines) {
const entry = JSON.parse(line);
switch (entry.type) {
case "session":
console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
break;
case "message":
console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
break;
case "compaction":
console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);
break;
case "branch_summary":
console.log(`[${entry.id}] Branch from ${entry.fromId}`);
break;
case "custom":
console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
break;
case "custom_message":
console.log(`[${entry.id}] Extension message (${entry.customType}): ${entry.content}`);
break;
case "label":
console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
break;
case "model_change":
console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);
break;
case "thinking_level_change":
console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);
break;
}
}
这与仓库内部的解析策略一致:parseSessionEntries()(session-manager.ts#L299-L314)同样按行切分、逐行 JSON.parse,并跳过空行与格式损坏的行(malformed lines 直接丢弃而非抛错),因此解析第三方工具生成的会话文件时建议采用同样的容错策略。
SessionManager API 速查
SessionManager(session-manager.ts)是程序化操作会话的核心入口,主要方法分四组:
静态创建方法
SessionManager.create(cwd, sessionDir?)—— 新建会话;SessionManager.open(path, sessionDir?)—— 打开已有会话文件;SessionManager.continueRecent(cwd, sessionDir?)—— 继续最近一次会话,没有则新建;SessionManager.inMemory(cwd?)—— 不落盘的内存会话;SessionManager.forkFrom(sourcePath, targetCwd, sessionDir?)—— 从其他项目的会话分叉。
静态列举方法
SessionManager.list(cwd, sessionDir?, onProgress?)—— 列出某目录下的会话;SessionManager.listAll(onProgress?)—— 列出跨所有项目的全部会话。
实例方法 —— 会话管理
newSession(options?)—— 开始新会话(options:{ parentSession?: string });setSessionFile(path)—— 切换到另一个会话文件;createBranchedSession(leafId)—— 把当前分支抽取为新的会话文件。
实例方法 —— 追加(均返回条目 ID)
appendMessage(message)—— 追加消息;appendThinkingLevelChange(level)—— 记录思考级别变化;appendModelChange(provider, modelId)—— 记录模型切换;appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)—— 写入 compaction;appendCustomEntry(customType, data?)—— 扩展状态(不进上下文);appendSessionInfo(name)—— 设置会话显示名;appendCustomMessageEntry(customType, content, display, details?)—— 扩展消息(进上下文);appendLabelChange(targetId, label)—— 设置/清除标签。
实例方法 —— 树导航
getLeafId()—— 当前位置;getLeafEntry()—— 当前叶子条目;getEntry(id)—— 按 ID 取条目;getBranch(fromId?)—— 从条目回溯到根;getTree()—— 完整树结构;getChildren(parentId)—— 直接子节点;getLabel(id)—— 条目的标签;branch(entryId)—— 把叶子移回更早的条目;resetLeaf()—— 叶子重置为 null(所有条目之前);branchWithSummary(entryId, summary, details?, fromHook?)—— 带上下文摘要地分支。
实例方法 —— 上下文与信息
buildContextEntries()—— 应用 compaction 后的活跃分支条目列表;buildSessionContext()—— 供 LLM 使用的消息、thinkingLevel 与模型;getEntries()—— 全部条目(不含 header);getHeader()—— 会话 header 元数据;getSessionName()—— 取自最新session_info条目的显示名;getCwd()—— 工作目录;getSessionDir()—— 会话存储目录;getSessionId()—— 会话 UUID;getSessionFile()—— 会话文件路径(内存会话为 undefined);isPersisted()—— 是否已落盘。
只读场景可使用 ReadonlySessionManager 类型(session-manager.ts#L190-L206),它仅暴露查询与构建上下文所需的 15 个方法,适合在不需要写入权限的扩展中使用。
小结
pi coding-agent 的会话格式是一套“JSONL + 树状 ID 链接 + 自动版本迁移”的持久化方案:header 记录版本与来源(含 fork 溯源),id/parentId 让分支、回退、摘要都在单文件内完成,而 compaction 的 retainedTail 检查点机制则让长会话的上下文重建不依赖历史条目。理解 session-manager.ts 中 buildContextEntries / buildSessionContext 的双层构建流程(先选条目、再转消息),加上 custom 与 custom_message 两条扩展持久化通道,基本覆盖了读取、解析和扩展 pi 会话文件所需的全部要素。相关实现可用 packages/coding-agent/test/session-manager/ 下的测试集以及 harness 层的 packages/agent/test/harness/session/ 测试进一步验证行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00