
Pi 的 SessionManager 负责管理 AI 对话的完整生命周期——从创建、持久化、恢复,到分支与压缩。每一次对话都以 append-only 的 .jsonl 文件保存在磁盘上,session 内部以树状结构组织,支持从任意历史节点分叉出新的对话分支。
本文从源码角度,依次梳理 SessionManager 的创建方式、文件格式、entry 类型、写入机制、上下文构建,以及分支和历史树导航的完整流程。
SessionManager 的创建方式
SessionManager 的 constructor 是 private 的,外部只能通过静态工厂方法创建实例,每个方法对应一种 CLI 使用场景:
| 方法 | 对应 CLI 场景 | 说明 |
|---|---|---|
SessionManager.create(cwd) |
默认(新 session) | sessionFile=undefined,按时间戳 + UUID 生成新文件名,懒创建 |
SessionManager.open(path) |
--session <id> |
加载指定文件,从 header 读取 cwd |
SessionManager.continueRecent(cwd) |
--continue |
找最近修改的 .jsonl 文件打开,找不到则新建 |
SessionManager.inMemory() |
--no-session |
persist=false,不写磁盘 |
SessionManager.forkFrom(src, cwd) |
--fork <id> |
复制源文件所有 entry 到新文件,parentSession 记录来源 |
constructor 内部做两件事:若需要持久化则创建目录;若传入了 sessionFile 则加载已有文件(含版本迁移),否则调 newSession() 初始化一个空 session。
Session 文件格式
每个 session 是一个 .jsonl 文件,每行一个 JSON 对象。第一行是 SessionHeader,后续每行是一个 SessionEntry。
SessionHeader
{
type: "session";
version?: number; // 当前版本为 3,v1 没有此字段
id: string; // session UUID
timestamp: string; // 创建时间 ISO 字符串
cwd: string; // 创建时的工作目录
parentSession?: string; // fork 来源的 session 文件路径
}
parentSession 在两种情况下有值:
--forkCLI:跨项目复制时记录源文件路径/forkTUI 命令:在同项目内从历史节点创建新 session 文件时记录原文件路径
SessionEntry 公共字段
{
type: string;
id: string; // 8 位 hex 短 ID(碰撞时回退到完整 UUID)
parentId: string | null; // 父 entry 的 id,null 表示根节点
timestamp: string;
}
parentId 构成树状结构,支持分支。leafId 指针记录当前激活的末端节点,沿 leafId → root 回溯即为当前对话路径。
Session 文件命名规则
目录命名
session 文件按 cwd 分组存放,目录由 getDefaultSessionDir() 生成:
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(agentDir, "sessions", safePath);
规则:去掉 cwd 开头的 / 或 \,将所有 /、\、: 替换为 -,前后加 -- 包裹。
cwd = /Users/fanfei/monorepo/pi-mono
→ ~/.pi/agent/sessions/--Users-fanfei-monorepo-pi-mono--/
文件命名
由 newSession() 生成:
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.sessionFile = join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
new Date().toISOString() 生成的 2026-03-24T14:08:44.123Z,将 : 和 . 替换为 - 后得到:
2026-03-24T14-08-44-123Z_a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl
完整路径示例:
~/.pi/agent/sessions/--Users-fanfei-monorepo-pi-mono--/2026-03-24T14-08-44-123Z_a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl
设计意图:
- 目录按 cwd 分组:同一项目的 session 集中存放,方便
--continue找最近的 session - 文件名含时间戳:按字母序排列即为时间序,无需读取文件内容即可排序
- UUID 保证唯一性:同一毫秒内也不会冲突
SessionEntry 类型
SessionMessageEntry
{ type: "message", message: AgentMessage }
核心 entry。保存一条对话消息,对应 AgentMessage 的所有角色:user、assistant、toolResult,以及内部类型 compactionSummary、branchSummary、bashExecution、custom。
每次对话产生的消息都会写一条这个 entry,是 session 恢复时重建对话历史的主要来源。
ThinkingLevelChangeEntry
{ type: "thinking_level_change", thinkingLevel: string }
记录 thinking level(off / minimal / medium / high / xhigh)的变更时间点。session 恢复时从最后一条此 entry 读取 thinking level,而非每次都用 settings 默认值。
ModelChangeEntry
{ type: "model_change", provider: string, modelId: string }
记录模型切换事件。session 恢复时从最后一条此 entry 知道应恢复到哪个模型。新 session 创建时写入一条初始记录。
CompactionEntry
{
type: "compaction",
summary: string, // 压缩生成的摘要文本(注入给 LLM)
firstKeptEntryId: string, // 压缩后保留的第一条 entry 的 id
tokensBefore: number, // 压缩前的 token 数
details?: T, // 扩展可存储额外数据(如结构化索引)
fromHook?: boolean // 是否由扩展生成
}
context 压缩的结果记录。当对话历史太长(接近 context window 上限)时触发,把历史浓缩成摘要。firstKeptEntryId 是关键字段:恢复时这条 id 之前的消息都被丢弃,只保留摘要 + 之后的消息。
BranchSummaryEntry
{
type: "branch_summary",
fromId: string, // 分叉点的 entry id
summary: string, // 被放弃分支的摘要
details?: T,
fromHook?: boolean
}
分支摘要。用户从历史节点导航到新分支时,LLM 对被放弃的旧分支生成摘要并注入新分支,保留上下文。与 CompactionEntry 的区别:compaction 是线性压缩当前对话,branch_summary 是跨分支的上下文传递。
CustomEntry
{
type: "custom",
customType: string, // 扩展自定义的类型标识
data?: T
}
扩展专用的持久化存储,不参与 LLM context。 扩展在 session 中存储私有状态(如索引、版本标记),session 恢复时扫描这些 entry 重建内部状态。
CustomMessageEntry
{
type: "custom_message",
customType: string,
content: string | (TextContent | ImageContent)[],
details?: T, // 扩展私有元数据(不进 LLM)
display: boolean // 是否在 TUI 中渲染
}
扩展专用,参与 LLM context(被 buildSessionContext() 转换为 user 消息)。display: false 可实现"给 LLM 看但界面不显示"的隐藏注入。
LabelEntry
{
type: "label",
targetId: string,
label: string | undefined // undefined 表示删除标签
}
用户给某条 entry 打书签。label: undefined 通过追加新 entry 实现删除,符合 append-only 设计。
SessionInfoEntry
{ type: "session_info", name?: string }
session 级别的元数据。目前只有 name 字段,对应 /session rename 设置的名称,显示在 session 列表中。
汇总
| Entry 类型 | 进 LLM context | 用途 |
|---|---|---|
message |
✓ | 对话消息(user/assistant/tool) |
thinking_level_change |
✗ | 恢复 thinking level |
model_change |
✗ | 恢复使用的模型 |
compaction |
✓(摘要部分) | 历史压缩记录 |
branch_summary |
✓(摘要部分) | 分支上下文摘要 |
custom |
✗ | 扩展私有状态存储 |
custom_message |
✓ | 扩展注入 LLM 的消息 |
label |
✗ | 用户书签/标注 |
session_info |
✗ | session 元数据(名称等) |
Session Entry 写入逻辑
写入路径
所有写入都经过同一条路径:
appendXxx()
↓
_appendEntry() ← 更新内存(fileEntries / byId / leafId)
↓
_persist()
├── persist=false? → 跳过(--no-session in-memory 模式)
├── 无 assistant 消息? → 积压在内存,flushed=false
├── flushed=false? → 一次性 flush 所有积压 entry(appendFileSync × N)
└── flushed=true? → 增量追加单条(appendFileSync × 1)
特殊情况:
_rewriteFile() ← 版本迁移或文件损坏时全量覆写(writeFileSync)
懒写入设计:新 session 等到第一条 assistant 消息出现后才真正落盘,避免产生只有用户消息、没有 AI 响应的空 session 文件。
各 Entry 类型的写入时机
sdk.ts(session 初始化)
appendModelChange— 新 session 创建时写入初始模型appendThinkingLevelChange— 新 session 创建时写入初始 thinking level(续写 session 若无记录则补写)
agent-session.ts(主体)
| 方法 | 触发时机 |
|---|---|
appendMessage |
Agent message_end 事件自动写入;bash 执行完成后写入执行记录 |
appendThinkingLevelChange |
/thinking 命令、setThinkingLevel()、applyThinkingLevel() |
appendModelChange |
setModel()、switchModel()(/model)、cycleModel()(Ctrl+P) |
appendCompaction |
/compact 命令、自动压缩完成后 |
appendCustomEntry |
扩展调用 persistCustomEntry() 存储私有状态 |
appendCustomMessageEntry |
扩展主动注入 custom message;Agent 事件检测到 custom 消息 |
appendSessionInfo |
/session rename;扩展设置 session 名称 |
appendLabelChange |
setLabel();分支后给 summary entry 打标签;扩展操作 label |
branchWithSummary |
历史树导航时选择生成摘要(内含 branch_summary entry) |
interactive-mode.ts(UI 层)
appendLabelChange— 用户在 TUI 中直接操作 labelappendSessionInfo— TUI 中设置 session 名称
Session 文件与 LLM 上下文的关系
核心设计
session 文件是只写的持久化日志,Agent 内存(_state.messages)是读写的工作状态。LLM 每次收到的上下文来自 Agent 内存,不是实时从 session 文件读取。
启动 / 切换 session
↓
buildSessionContext() ← 读 session 文件,构造 AgentMessage[]
↓
agent.replaceMessages() ← 装入 Agent 内存
─────────────────────────────────────
正常对话过程(不读文件):
用户发消息
↓
agent.prompt() ← 用 _state.messages 构造 LLM 请求
↓
LLM 响应 → message_end 事件
↓
agent._state.messages.push() ← 追加到内存
sessionManager.appendMessage() ← 同时 append 写入文件(持久化)
buildSessionContext() 的调用时机
只在需要重建 Agent 内存状态时调用,调用后立即执行 agent.replaceMessages():
| 触发场景 |
|---|
createAgentSession() 初始化时,有已有 session 则恢复历史消息 |
newSession() setup 回调后,同步 agent 状态 |
| 手动或自动压缩完成后,用压缩后的上下文替换 agent 消息 |
switchSession() 切换到另一个 session 文件后 |
fork() 创建新 session 文件后 |
navigateTree() 在历史树中导航后 |
buildSessionContext() 内部逻辑
从 session entry 树重建 AgentMessage[] 的过程:
- 找到 leaf:从
leafId出发(未指定时取最后一条 entry) - 回溯路径:沿
parentId链从 leaf 走到 root,收集完整路径 - 扫描路径提取元数据:
thinking_level_change→ 更新thinkingLevelmodel_change或assistant消息 → 更新modelcompaction→ 记录压缩点
- 构建消息列表:
- 有 compaction:先注入压缩摘要消息,再从
firstKeptEntryId开始追加保留的消息,最后追加压缩点之后的所有消息 - 无 compaction:按路径顺序追加所有
message、custom_message、branch_summaryentry(label、model_change、thinking_level_change不进消息列表)
- 有 compaction:先注入压缩摘要消息,再从
为什么 compaction 之前还要保留一部分消息?
firstKeptEntryId 划了一条线:线之前的消息被压进摘要丢弃,线之后到 compaction entry 之间的消息原样保留。这是因为 compaction 只压缩较早的历史,最近的消息(如正在进行中的 tool call 序列、刚刚发生的操作)不适合被压缩——压缩后语义会损失,LLM 需要看完整的原始内容才能正确继续对话。
简言之:摘要覆盖较早的历史,最近的消息原样保留,保证 LLM 对近期上下文有完整感知。
Fork、/fork、Branch 的区别
三个概念名字相似,但操作层次完全不同。
--fork CLI flag → SessionManager.forkFrom()
跨项目复制 session。把另一个项目的整个 session 文件完整复制到当前项目目录,产生新文件,新 header 的 parentSession 记录源文件路径,两者之后完全独立。
TUI /fork 命令 → AgentSession.fork()
同项目内,从选中的某条用户消息处创建新 session 文件:
- 选中消息有父节点 →
createBranchedSession(parentId):把 root 到该消息父节点的路径复制到新文件 - 选中消息是第一条 →
newSession():全新空 session
选中的用户消息本身不复制,而是填回编辑器供重新编辑发送。
TUI /tree 历史树导航 → SessionManager.branch()
不产生新文件,在同一 .jsonl 文件内移动 leafId 指针,之后新消息从新位置长出新分支,旧分支仍保留在文件中。
对比表
--fork CLI |
/fork TUI |
/tree 导航 |
|
|---|---|---|---|
| 产生新文件 | ✓(跨项目复制) | ✓(同项目新文件) | ✗(同一文件内) |
| 历史来源 | 完整复制源文件 | 复制到选中消息之前的历史 | leafId 指针移动 |
| 选中消息本身 | 保留 | 不复制,填回编辑器 | 保留(作为新分支起点) |
| 典型用途 | 把别的项目的对话带过来 | 从某个问题重新开始问 | 探索不同回答方向 |
/tree 导航完整流程
用户执行 /tree 命令并选中历史节点后:
步骤一:询问是否生成摘要
TUI 弹出选项:No summary / Summarize / Summarize with custom prompt。若 settings 中设置了 branchSummarySkipPrompt 则跳过。
步骤二:navigateTree(targetId, { summarize }) 核心逻辑
-
收集被放弃的分支:
collectEntriesForBranchSummary()找出当前 leaf 到目标节点公共祖先之间将被抛弃的 entry -
通知扩展:触发
session_before_tree事件,扩展可取消、提供自定义摘要或修改摘要指令 -
生成摘要(可选):调用 LLM 对被放弃的分支生成摘要,记录涉及的
readFiles/modifiedFiles -
确定新 leafId:
目标节点类型 新 leafId 额外处理 user消息该节点的 parentId用户消息文本填回编辑器 custom_message该节点的 parentId内容填回编辑器 其他(assistant 等) 目标节点本身 无 选择用户消息时 leaf 落在父节点,因为用户将重新编辑并发送这条消息作为新 entry。
-
移动 leaf 指针:
- 有摘要 →
branchWithSummary(newLeafId, summaryText),写入branch_summaryentry - 无摘要且 leafId=null →
resetLeaf() - 无摘要且 leafId≠null →
branch(newLeafId),仅移动指针不写新 entry
- 有摘要 →
-
重建 Agent 内存:
buildSessionContext()+agent.replaceMessages() -
通知扩展:触发
session_tree事件
步骤三:TUI 刷新界面
重新渲染对话历史,若有 editorText 则填回编辑器。