pi session tree 的实现原理:/tree、/fork、/clone 到底动了什么
读 pi 源码(session-manager、agent-session、agent-session-runtime、interactive-mode)拆解 session tree 的实现:append-only JSONL 的 id/parentId 指针、leafId 活动指针、getBranch 的路径提取,以及 /tree 移动指针、/fork 提取路径到新文件、/clone 复制活动分支三者的真实差异,并修正「fork 仍在原文件」「clone 复制子树」两处常见误解。
起点:一棵不是线性列表的会话
pi 的 session 约定并不复杂:每次对话自动保存成一个 session,除非用 --no-session 启动。每个 session 是一个 JSONL 文件,放在 ~/.pi/agent/sessions/--<路径>--/<时间戳>_<uuid>.jsonl,按工作目录分组。
入口命令也直白:--continue 打开当前目录最近的 session;--resume / /resume 打开 session 选择器(可搜索、重命名、删除);/new 开新会话;/session 查看当前 session 文件、消息数、token 用量、费用。
真正需要读源码才能讲清的是它的树形结构:session 内的条目通过 id / parentId 构成一棵树,而不是线性列表,所以回到早期节点再开新分支不会丢掉旧分支。围绕这棵树有三个命令——/tree、/fork、/clone——它们看起来都是”切分支”,但实现路径完全不同。本文基于 dist/core/session-manager.js、agent-session.js、agent-session-runtime.js 和 modes/interactive/interactive-mode.js 逐个核对。
一、数据结构:append-only JSONL + 两个指针
每个 entry 一行 JSON,id(8 位 hex)+ parentId 构成树。关键约束是:没有任何删除或修改,全部 append。
SessionManager 在内存里维护两样东西:byId: Map<id, entry> 索引,以及一个 leafId 指针,表示当前活动位置。
// 树的全部"导航"本质只是移动这个指针
branch(branchFromId) { this.leafId = branchFromId; } // 指到某节点
resetLeaf() { this.leafId = null; } // 指到 root 之前
branchWithSummary(from, summary) { this.leafId = from; ...append({type:"branch_summary", parentId: from, fromId: 旧leaf, summary}) }
这里最容易被误解的一点是:没有任何”分支对象”被创建。所谓分支,只是 leafId 取值不同;后续 appendXXX() 会把这一个新 entry 的 parentId 设为当前 leafId,于是从那个位置长出新子树。整棵树永远留在文件里,只有活动分支会进 LLM 上下文。
活动分支提取:getBranch(fromId)
getBranch(fromId) {
let current = byId.get(startId);
while (current) { path.push(current); current = byId.get(current.parentId); }
path.reverse(); // 从 root 到 leaf 的顺序
return path;
}
从 leaf 沿 parentId 链一路走到 root,再反转。上下文构建走三步——buildContextEntries()(处理 compaction)→ buildSessionProjection()(应用 context_edit)→ buildSessionContext()(转成 LLM 消息)——全部基于这一条路径。
模型只收到活动分支,即当前 leaf 回溯到 root 的路径,而不是整棵树。接近模型上限时自动 compact(旧的合并成 summary 保留),/compact 可手动触发。
二、条目类型
每个 JSONL 一行,用 type 字段区分:
session表头(version 3 当前版,含 cwd)message(system/user/assistant/toolResult;system 消息携带 prompt sections 和工具声明,按顺序重放即得当前 prompt 和工具集)model_change/thinking_level_change(切模型、切思考级别)compaction(压缩点:summary +firstKeptEntryId+ system 检查点)context_edit(对历史条目的追加式替换,只影响未来模型上下文,不改原始记录)branch_summary、usage、custom(扩展状态,不进上下文)、custom_message(扩展注入的消息,进上下文)、label、session_info(/name起的名字)
三、/tree:在同文件内移动指针
UI 侧是 TreeSelectorComponent,用 getTree() 渲染(内存中按 parentId 建父子关系、按时间戳排序),只显示用户消息的文本。
选中目标后调用 session.navigateTree(entryId, {summarize, customInstructions}):
navigateTree(targetId):
├─ 安全检查:正在 streaming / compacting 则拒绝
├─ collectEntriesForBranchSummary(oldLeaf, target)
│ ├─ oldPath = set(getBranch(oldLeaf)) // 旧叶子到 root 的 id 集合
│ ├─ targetPath = getBranch(target)
│ └─ 从 targetPath 尾部往前扫,第一个也在 oldPath 里的
│ 就是【共同祖先 commonAncestorId】
│ └─ 从 oldLeaf 沿 parentId 往上走到 commonAncestor 为止,
│ 收集这些 entry → 这就是"要遗弃的分支"
├─ 若选择 Summarize → generateBranchSummary(entriesToSummarize)
│ ├─ 专门的 BRANCH_SUMMARY_PROMPT:Goal / Constraints /
│ │ Progress / Key Decisions / Next Steps 结构化格式
│ ├─ 按 branchSummarySettings.reserveTokens 做 token 预算
│ │ (太长时从新到旧截取,保证最近上下文;compaction/branch_summary
│ │ 条目即使超预算也会尽量塞进去)
│ └─ AbortController 支持 Esc 中断;扩展可 hook session_before_tree
│ 提供自定义 summary / 指令 / label
├─ 确定新 leaf 位置:
│ ├─ 目标是 user message → newLeafId = 它的 parentId,
│ │ 用户消息文本被放回编辑器(可修改后重新提交=新分支)
│ ├─ 目标是 assistant/custom_message → newLeafId = 目标本身
│ └─ 目标是 root → resetLeaf()(leafId = null)
├─ 切换:
│ ├─ 有 summary → branchWithSummary(newLeafId, summary):
│ │ 移动指针 + append 一条 branch_summary entry
│ │ (parentId=新位置,fromId=被遗弃的旧叶子)
│ └─ 无 summary → branch(newLeafId) 只是改指针
└─ emit session_tree 事件给扩展
也就是说 /tree 不做任何文件操作——它在本文件内移动 leafId。可选生成 branch_summary 时,也只是在原文件 append 一条 entry(parentId=新位置,fromId=被遗弃的旧 leaf)。此后上下文请求里,这条 branch_summary 会转成一条 branchSummary 消息,让 LLM 知道”你去过那条分支,干了这些”。
四、/fork:把路径提取到新文件
UI 侧是 getUserMessagesForForking(),它扫 全部 entry(整棵树,不只当前分支),列出所有有文本的 user 消息,选一条。
执行 runtimeHost.fork(entryId)(默认 position: "before"):
targetLeafId = 该用户消息的 parentId(新分支从”这条消息之前”重新开始),selectedText = 该消息文本→ 放回编辑器,相当于从它之前重新开聊createBranchedSession(targetLeafId):getBranch(targetLeafId)取出 root→该点的路径- 过滤掉 label entry 并重新链接——label 是真实树节点,别人可能挂在它下面,摘掉后要补齐父子链,用
replacementByLabelId把 label 的后代重挂到 label 的父节点 - 生成新 session 文件(新 uuid 文件名),header 带
parentSession: 原文件路径回链,内容只含这条路径
- runtime 切换到新 session,发
session_start reason: "fork", previousSessionFile
原文件留在磁盘(含全部分支),新文件是提取出来的单一路径。
这里有一个容易混淆的点:交互式 /fork 是”截取路径到新文件”,而 CLI 的 --fork 用的是另一个实现 SessionManager.forkFrom()——它把整棵会话树逐行拷贝到目标 cwd 的新文件(适合跨目录迁移)。两个”fork”语义不同。
五、/clone:fork(position: "at")
async handleCloneCommand() {
const leafId = this.sessionManager.getLeafId();
const result = await this.runtimeHost.fork(leafId, { position: "at" });
...
}
clone 就是 fork 的特例:position: "at" 表示 targetLeafId = 当前 leaf 本身(而不是往前挪到 parent)。于是 createBranchedSession(leafId) 把 root → 当前 leaf 的完整活动分支复制成新 session——相当于”当前状态的一个干净单分支副本”,不带被放弃的其他分支,编辑器清空。
注意它是一条线性路径,不是”子树”:被放弃的兄弟分支不会带过去。
六、一次判断修正:错的、对的、需要精确化的
在核对源码之前,我先把这套机制归纳成了三句话,结果被源码纠正了一处、精确化了三处。
对的:
- parentId + leafId 的多叉树。append-only JSONL,每个 entry 的
id+parentId就是树指针,SessionManager.leafId是当前活动位置。树永远完整存在文件里,没有分支对象被”创建”,分支只是指针移动 + 后续 entry 的parentId指向新位置。 - 不同 leaf 通过 parentId 拼独立的上下文。
getBranch(fromId)从 leaf 沿parentId链走到 root 再反转,这就是该分支独有的消息序列;compaction / context_edit 都作用在这条路径上。
错的:
- “/fork 依然处于原始 jsonl 文件中” —— 反了。交互式
/fork恰恰是离开原文件:targetLeafId取所选 user 消息的parentId,createBranchedSession取出 root→该点的路径、过滤 label 并重链、生成新 uuid 文件名的新 session 文件(header 带parentSession),runtime 随后切换到新 session。原文件保留全部分支,新文件只含这一条路径。
需要精确化的:
- “/clone 复制一个同样的子树到新文件” —— 大方向对(复制到新文件),但复制的是 root → 当前 leaf 的活动分支,是一条线性路径,被放弃的兄弟分支不会带过去。
- “/tree 就是把整棵树拿出来” —— 渲染层确实会扫到 root 并按
parentId建父子关系展示整棵树,但选中目标后navigateTree做的是移动leafId指针,没有任何文件操作;可选地在原文件 append 一条branch_summary。
修正后的一句话版本:
树 = append-only JSONL +
parentId指针 +leafId活动指针;上下文 = leaf 沿 parentId 走到 root 的路径。/tree 在同文件内移动指针(不动文件,可选生成 branch_summary),/fork 提取 root→目标点的路径到新文件(原文件保留),/clone 是 fork(position:“at”),把当前活动分支复制到新文件。三者中唯一不改文件的是 /tree。
小结
| 命令 | 本质 | 新 leaf | 编辑器 |
|---|---|---|---|
/tree | 同文件内移动 leafId 指针(可选生成 branch_summary) | 目标或目标.parent | 目标 user 消息文本 |
/fork | 提取 root→某历史点路径到新文件 | 所选 user 消息的 parent | 该消息文本 |
/clone | fork(当前leaf, position:“at”) | 当前 leaf 本身 | 清空 |
贯穿三者的是一个”重新编辑用户消息”模型:当目标是 user 消息时,新分支点都落在它之前,把文本还给你改,改动后提交即产生树上的新分支;而原始消息永远留在树上。
与 mini-agent 的关联
mini-agent 的 PiRpcClient 用的是 pi --mode rpc --no-session,即 RPC 模式下不持久化 session:每次调用起一个全新子进程,无状态,上下文由自己通过 _build_prompt 注入(CodeTaskExecutor 里 wrap 了 <untrusted data> 隔离和 /organization 上下文),与本地交互式 session 完全隔离。理解 session tree 的实现,也就清楚了这条边界为什么是硬的:持久化、树、分支摘要全都属于交互式 session 那一侧。