24  会话文件格式

24.1 概述

Pi 的会话(Session)以 JSONL(JSON Lines)格式存储。每个会话文件中,每一行是一个独立的 JSON 对象,包含 type 字段用于标识条目类型。会话条目通过 id / parentId 字段构成树形结构,支持原地分支(branching)而无需创建新文件。

理解会话文件格式对于以下场景至关重要:

  • 编写解析脚本,从会话文件中提取信息
  • 开发自定义扩展(Extensions),注入或修改会话内容
  • 通过 SDK 编程式操作会话
  • 调试会话分支、压缩(compaction)等高级功能
Tip

如果你只是日常使用 Pi,不需要深入理解本章内容。本章面向需要编程式处理会话的开发者。

24.2 文件位置

会话文件存储在以下路径:

~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl

其中 <path> 是当前工作目录路径,将 / 替换为 -。例如,工作目录 /home/user/myproject 对应的会话目录为 --home-user-myproject--

这种命名方式确保不同项目的会话相互隔离,同时便于按项目查找历史会话。

24.3 删除会话

可以通过直接删除 ~/.pi/agent/sessions/ 下的 .jsonl 文件来移除会话。

Pi 还支持在交互模式下通过 /resume 删除会话:选中某个会话后按 Ctrl+D,然后确认删除。当系统中安装了 trash CLI 工具时,Pi 会优先使用它,将文件移入回收站而非永久删除。

Warning

直接删除 .jsonl 文件是永久性操作,无法恢复。建议使用 trash CLI 或通过 /resume 交互删除。

24.4 会话版本

会话头部(header)包含 version 字段,标识会话格式版本:

版本 说明
v1 线性条目序列(旧版格式,加载时自动迁移)
v2 树形结构,使用 id / parentId 链接条目
v3 hookMessage 角色重命名为 custom(扩展统一)

旧版会话在加载时会自动迁移到当前版本(v3)。

24.5 消息类型

会话条目中包含 AgentMessage 对象。理解这些类型对于解析会话和编写扩展至关重要。

24.5.1 内容块(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>;
}

24.5.2 基础消息类型(来自 pi-ai)

这些类型定义在 @earendil-works/pi-ai 包中,是 Pi 最核心的消息类型:

// 用户消息
interface UserMessage {
  role: "user";
  content: string | (TextContent | ImageContent)[];
  timestamp: number;  // Unix 毫秒时间戳
}

// 助手消息
interface AssistantMessage {
  role: "assistant";
  content: (TextContent | ThinkingContent | ToolCall)[];
  api: string;
  provider: string;       // 提供商,如 "anthropic"
  model: string;          // 模型名,如 "claude-sonnet-4-5"
  usage: Usage;           // token 用量统计
  stopReason: "stop" | "length" | "toolUse" | "error" | "aborted";
  errorMessage?: string;  // 错误时的详细信息
  timestamp: number;
}

// 工具结果消息
interface ToolResultMessage {
  role: "toolResult";
  toolCallId: string;     // 对应的 ToolCall ID
  toolName: string;       // 工具名称
  content: (TextContent | ImageContent)[];
  details?: any;          // 工具特定的元数据
  usage?: Usage;          // 工具内部嵌套 LLM 调用的用量
  isError: boolean;       // 是否执行出错
  timestamp: number;
}

// token 用量统计
interface Usage {
  input: number;
  output: number;
  cacheRead: number;
  cacheWrite: number;
  totalTokens: number;
  cost: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    total: number;
  };
}
Note

StopReason 类型还包含 "pending" 值,但该值仅用于流式事件中的部分消息。当 Pi 将助手消息持久化到会话文件时,"pending" 会被替换为实际的完成原因,因此会话 JSONL 中永远不会出现 "pending"

24.5.3 扩展消息类型(来自 pi-coding-agent)

这些类型定义在 @earendil-works/pi-coding-agent 包中,是 Pi 在基础消息类型之上扩展的专有消息:

// Bash 命令执行记录
interface BashExecutionMessage {
  role: "bashExecution";
  command: string;
  output: string;
  exitCode: number | undefined;
  cancelled: boolean;       // 是否被用户取消
  truncated: boolean;       // 输出是否被截断
  fullOutputPath?: string;  // 完整输出的文件路径
  excludeFromContext?: boolean;  // true 表示 !! 前缀命令,不送入 LLM
  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;           // 分支起点的条目 ID
  timestamp: number;
}

// 压缩摘要消息
interface CompactionSummaryMessage {
  role: "compactionSummary";
  summary: string;          // 被压缩内容的摘要
  tokensBefore: number;     // 压缩前的 token 数
  timestamp: number;
}

24.5.4 AgentMessage 联合类型

所有消息类型组成 AgentMessage 联合类型:

type AgentMessage =
  | UserMessage
  | AssistantMessage
  | ToolResultMessage
  | BashExecutionMessage
  | CustomMessage
  | BranchSummaryMessage
  | CompactionSummaryMessage;

编写扩展或解析器时,通过 role 字段判断消息类型即可。

24.6 条目基础结构(Entry Base)

所有条目(SessionHeader 除外)都继承自 SessionEntryBase

interface SessionEntryBase {
  type: string;            // 条目类型标识
  id: string;              // 8 位十六进制 ID
  parentId: string | null; // 父条目 ID(首条目为 null)
  timestamp: string;       // ISO 8601 时间戳
}

idparentId 构成了会话的树形结构。通过 parentId 可以追溯到根条目,形成从叶子到根的路径。

24.7 条目类型详解

24.7.1 SessionHeader(会话头)

会话文件的第一行,包含会话元数据。它不是树结构的一部分(没有 id / parentId)。

{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}

如果是通过 /fork/clonenewSession({ parentSession }) 创建的会话,还会包含 parentSession 字段,指向原始会话文件路径:

{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}

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

24.7.3 ModelChangeEntry(模型切换条目)

当用户在会话中途切换模型时生成:

{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}

24.7.4 ThinkingLevelChangeEntry(思考等级变更条目)

当用户改变思考/推理(thinking/reasoning)等级时生成:

{"type":"thinking_level_change","id":"e5f6g7h8","parentId":"d4e5f6g7","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}

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

较新的 Pi 生成的压缩条目会直接在条目上嵌入压缩后保留的上下文(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[];可选仅为向后兼容旧会话
details 实现特定的数据(如 { readFiles: string[], modifiedFiles: string[] }
fromHook true 表示由扩展生成,false/undefined 表示 Pi 自动生成(旧字段名)
firstKeptEntryId 旧版格式兼容字段
Note

retainedTail 使得新版压缩条目成为自包含的检查点——无需遍历更早的条目即可重建上下文。旧会话如果只有 firstKeptEntryId,仍然可以通过回溯旧条目正确加载。

24.7.6 BranchSummaryEntry(分支摘要条目)

通过 /tree 切换分支时创建,包含被放弃分支的 LLM 生成摘要,捕获离开路径的上下文:

{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:15:00.000Z","fromId":"f6g7h8i9","summary":"Branch explored approach A..."}

可选字段与 CompactionEntry 类似:usagedetails(文件追踪数据)、fromHook

24.7.7 CustomEntry(自定义条目)

用于扩展状态的持久化存储。不参与 LLM 上下文构建:

{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}}

使用 customType 标识你的扩展条目,便于重新加载时识别。在交互模式下,可以通过 pi.registerEntryRenderer(customType, renderer) 为自定义条目注册 TUI 渲染器,但这些条目不会被发送到 LLM。

24.7.8 CustomMessageEntry(自定义消息条目)

扩展注入的消息,参与 LLM 上下文构建:

{"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)

24.7.9 LabelEntry(标签条目)

用户在条目上定义的书签/标记:

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

label 设为 undefined 即可清除标签。标签在 /tree 视图中显示,便于快速定位重要节点。

24.7.10 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 会话选择器中将显示该名称而非首条消息预览。

24.8 树形结构

会话条目通过 id / parentId 构成树形结构:

  • 首条目parentIdnull
  • 每个后续条目通过 parentId 指向其父条目
  • 分支(branching) 从已有条目创建新的子节点
  • 叶子节点(leaf) 是当前在树中的位置
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← 当前叶子
                                                            │
                                                            └─ [branch_summary] ─── [user msg] ← 替代分支

这种设计使得原地分支成为可能:无需创建新的会话文件,直接从历史某点继续对话即可形成新分支。通过 /tree 命令可以在树中自由导航。

24.9 上下文构建

Pi 使用两个函数从树形结构构建发送给 LLM 的上下文。

24.9.1 buildContextEntries()

从当前叶子节点向根节点遍历,生成活动条目列表,同时处理压缩逻辑:

  1. 收集路径条目:收集叶子上溯到根的所有条目
  2. 处理压缩条目:如果路径上存在 CompactionEntry
    • 如果有 retainedTail:作为自包含检查点,直接使用其中保存的消息
    • 否则:使用 firstKeptEntryId 到压缩条目之间的条目
  3. 保留非消息条目:保留选中范围内的非消息条目(如 model_changelabel 等),以便交互模式正确渲染

24.9.2 buildSessionContext()

buildContextEntries() 的基础上,生成发送给 LLM 的最终消息列表:

  1. 从完整路径中提取当前的模型和思考等级设置
  2. 将选中条目转换为消息:
    • message → 存储的 AgentMessage
    • compactioncompactionSummary 消息 + retainedTail(如果存在)
    • branch_summarybranchSummary 消息
    • custom_messageCustomMessage
    • custom → 不生成上下文消息
Tip

新版压缩条目通过 retainedTail 充当自包含检查点。这意味着即使不回溯到压缩点之前的条目,也能完整重建上下文。retainedTail 可选仅为向后兼容旧版会话。

24.10 解析示例

以下 TypeScript 示例展示如何解析会话 JSONL 文件:

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

24.11 SessionManager API

SessionManager 是 Pi 提供的用于编程式操作会话的核心类。以下是其完整接口。

24.11.1 静态创建方法

// 创建新会话
SessionManager.create(cwd, sessionDir?)

// 打开已有会话文件
SessionManager.open(path, sessionDir?)

// 继续最近的会话,如果没有则创建新会话
SessionManager.continueRecent(cwd, sessionDir?)

// 创建内存会话(不持久化到文件)
SessionManager.inMemory(cwd?)

// 从其他项目的会话派生(fork)
SessionManager.forkFrom(sourcePath, targetCwd, sessionDir?)

24.11.2 静态列表方法

// 列出指定目录的会话
SessionManager.list(cwd, sessionDir?, onProgress?)

// 列出所有项目的会话
SessionManager.listAll(onProgress?)

24.11.3 实例方法 — 会话管理

// 开始新会话(可选指定父会话)
newSession(options?: { parentSession?: string })

// 切换到其他会话文件
setSessionFile(path)

// 将当前分支提取到新会话文件
createBranchedSession(leafId)

24.11.4 实例方法 — 追加条目(均返回条目 ID)

// 追加消息
appendMessage(message: AgentMessage): string

// 记录思考等级变更
appendThinkingLevelChange(level: string): string

// 记录模型切换
appendModelChange(provider: string, modelId: string): string

// 添加压缩条目
appendCompaction(
  summary: string,
  firstKeptEntryId: string,
  tokensBefore: number,
  details?: unknown,
  fromHook?: boolean
): string

// 添加扩展状态条目(不参与 LLM 上下文)
appendCustomEntry(customType: string, data?: unknown): string

// 设置会话显示名称
appendSessionInfo(name: string): string

// 添加扩展消息条目(参与 LLM 上下文)
appendCustomMessageEntry(
  customType: string,
  content: string | (TextContent | ImageContent)[],
  display: boolean,
  details?: unknown
): string

// 设置/清除标签
appendLabelChange(targetId: string, label: string | undefined): string

24.11.5 实例方法 — 树导航

// 获取当前位置(叶子 ID)
getLeafId(): string | null

// 获取当前叶子条目
getLeafEntry(): SessionEntry | null

// 按 ID 获取条目
getEntry(id: string): SessionEntry | undefined

// 从指定条目遍历到根
getBranch(fromId?: string): SessionEntry[]

// 获取完整树结构
getTree(): TreeNode[]

// 获取直接子节点
getChildren(parentId: string): SessionEntry[]

// 获取条目的标签
getLabel(id: string): string | undefined

// 跳转到更早的条目(移动叶子位置)
branch(entryId: string): void

// 重置叶子为 null(回到空对话)
resetLeaf(): void

// 带摘要的分支切换
branchWithSummary(
  entryId: string,
  summary: string,
  details?: unknown,
  fromHook?: boolean
): void

24.11.6 实例方法 — 上下文与信息

// 获取活动分支条目(应用压缩后的)
buildContextEntries(): SessionEntry[]

// 获取发送给 LLM 的消息列表
buildSessionContext(): {
  messages: AgentMessage[];
  thinkingLevel: string;
  model: { provider: string; modelId: string } | null;
}

// 获取所有条目(不含头部)
getEntries(): SessionEntry[]

// 获取会话头部元数据
getHeader(): SessionHeader

// 获取会话显示名称
getSessionName(): string | undefined

// 获取工作目录
getCwd(): string

// 获取会话存储目录
getSessionDir(): string

// 获取会话 UUID
getSessionId(): string

// 获取会话文件路径(内存会话返回 undefined)
getSessionFile(): string | undefined

// 是否持久化到磁盘
isPersisted(): boolean
Tip

通过 SDK 使用 SessionManager 时,导入路径为 @earendil-works/pi-coding-agent。在你的项目 node_modules/@earendil-works/pi-coding-agent/dist/ 中可以查看完整的 TypeScript 类型定义。

24.12 源文件参考

相关源代码位于 pi-mono 仓库:

文件 说明
packages/coding-agent/src/core/session-manager.ts 会话条目类型定义和 SessionManager 实现
packages/coding-agent/src/core/messages.ts 扩展消息类型(BashExecutionMessageCustomMessage 等)
packages/ai/src/types.ts 基础消息类型(UserMessageAssistantMessageToolResultMessage
packages/agent/src/types.ts AgentMessage 联合类型