首页
/ pi Coding Agent 会话文件格式(Session Format)详解:JSONL 存储、树状分支结构与上下文重建机制

pi Coding Agent 会话文件格式(Session Format)详解:JSONL 存储、树状分支结构与上下文重建机制

2026-09-05 23:26:02作者:裴锟轩Denise

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 会调用 trash CLI 走回收站以避免永久删除。

会话版本与自动迁移

每个会话文件的头部(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 条目,将 rolehookMessage 的消息改名为 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 —— 扩展消息类型(BashExecutionMessageCustomMessage 等)及 createBranchSummaryMessagecreateCompactionSummaryMessagecreateCustomMessage 等构造函数;
  • packages/ai/src/types.ts —— 基础消息类型(UserMessageAssistantMessageToolResultMessage);
  • packages/agent/src/types.ts —— AgentMessage 联合类型。

另外,harness 层的会话存储与 compaction 类型(含下文 retainedTail 字段)定义在 packages/agent/src/harness/session/types.tspackages/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)

定义于 packages/ai/src/types.ts

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;
}

BashExecutionMessageexcludeFromContext 字段与 !! 前缀命令直接相关:这类命令的输出只记录在会话文件中、不进入 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)。parentIdnull 表示该条目是树的首个节点。

条目类型全览

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/clonenewSession({ 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[] },或扩展自定义数据;
  • fromHooktrue 表示由扩展生成,false/缺省表示 pi 生成(遗留字段名);
  • firstKeptEntryId:兼容旧条目格式。

retainedTail 相关的类型与上下文重建逻辑位于 harness 层,可参考 packages/agent/src/harness/session/context.tspackages/agent/src/harness/compaction/compaction.ts,对应测试有 packages/agent/test/harness/session/context.test.tspackages/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 相同);
  • displaytrue 表示在 TUI 中以区别化样式显示,false 表示隐藏;
  • details:可选的扩展特定元数据(不会发送给 LLM)。

源码注释(session-manager.ts#L123-L141)指出:custom_messagebuildSessionContext() 中会被转换为 user 消息进入上下文。

LabelEntry

用户为某条目定义的书签/标记:

{"type":"label","id":"j0k1l2m3","parentId":"i9j0k1l2","timestamp":"2024-12-03T14:30:00.000Z","targetId":"a1b2c3d4","label":"checkpoint-1"}

注意 targetIdparentId 语义不同: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() 的实现 逻辑为:

  1. 先收集当前叶子路径上的所有条目;
  2. 若路径上存在 CompactionEntry
    • 压缩条目本身放在结果开头;
    • 若条目带有 retainedTail,它充当自包含检查点,压缩点之后的条目直接纳入;
    • 否则(旧格式)从 firstKeptEntryId 到压缩条目之间的条目纳入;
    • 再把压缩条目之后的所有条目追加进来;
  3. 范围内保留非消息条目,以便交互模式可以渲染它们(如 model_change 记录)。

coding-agent 侧的 buildContextEntries() 实现展示了旧格式(firstKeptEntryId)的处理路径:找到路径上最新的 compaction 条目,把“从 firstKeptEntryId 起、到 compaction 之前”的条目挑出来,再拼接 compaction 之后的全部条目,更早的被摘要条目则被丢弃。

buildSessionContext()

buildSessionContext() 在条目列表之上产出发给 LLM 的消息列表,同时解析当前模型与思考级别:

  1. 从完整路径中提取当前的模型与思考级别设置(最近的 model_change / thinking_level_change);
  2. 将选中的条目转换为消息:
    • message → 存储的 AgentMessage
    • compactioncompactionSummary,若存在则加 retainedTail
    • branch_summarybranchSummary
    • custom_messageCustomMessage
    • 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 速查

SessionManagersession-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 让分支、回退、摘要都在单文件内完成,而 compactionretainedTail 检查点机制则让长会话的上下文重建不依赖历史条目。理解 session-manager.tsbuildContextEntries / buildSessionContext 的双层构建流程(先选条目、再转消息),加上 customcustom_message 两条扩展持久化通道,基本覆盖了读取、解析和扩展 pi 会话文件所需的全部要素。相关实现可用 packages/coding-agent/test/session-manager/ 下的测试集以及 harness 层的 packages/agent/test/harness/session/ 测试进一步验证行为。

登录后查看全文
热门项目推荐
相关项目推荐