JSONL 树形 session:append-only + 两种 fork 本文以 earendil-works/pi-mono 为样本分析其会话存储设计。所引用的代码位于packages/agent/src/harness/session/、packages/coding-agent/src/core/session-manager.ts。一、为什么不是一条线性消息列表大多数 chat 应用的会话存储是这样的{id:chat-123,messages:[{role:user,content:...},{role:assistant,content:...},{role:user,content:...},...]}够用但有几个先天问题不能精确回放历史的某一节点。如果用户在第 5 条消息时反悔想从那里再问一个不同的问题要么覆盖第 5 条之后的所有内容要么复制整个会话。不能并行探索分支。给 LLM 同一个上下文问两个不同问题结果应该并存比较但线性结构里没办法表达。崩溃恢复粒度大。整个文件需要事务写入部分成功部分失败时难以处理。多端编辑容易冲突。两个客户端同时往同一会话追加消息简单 append 也能撞车。pi 的解法会话文件是一棵 append-only 的事件树。每条 entry 携带id和parentId逻辑上构成树物理上仍然按时间顺序追加到同一个.jsonl文件。“当前活跃位置” 用一个独立的leafentry 显式标记。下文从文件格式开始逐步展开它的两种 fork 与上下文构建。二、文件格式2.1 路径与命名~/.pi/agent/sessions/encoded-cwd/timestamp_uuid.jsonlencoded-cwd是把工作目录绝对路径编码后的子目录名这样不同项目的会话天然隔离每个会话一个.jsonl文件不可变更名文件名带时间戳便于排序带 UUID 防止冲突。2.2 行格式文件每一行是一个 JSON 对象第一行是 header之后每行是一个 entry{type:session,version:3,id:uuid,timestamp:...,cwd:/path,parentSession:/abs/path/to/parent.jsonl} {type:model_change,id:8hex,parentId:null,timestamp:...,provider:...,modelId:...} {type:message,id:8hex,parentId:8hex,timestamp:...,message:{role:user,content:...}} {type:message,id:8hex,parentId:8hex,timestamp:...,message:{role:assistant,content:[...],...}} {type:message,id:8hex,parentId:8hex,timestamp:...,message:{role:toolResult,toolCallId:...,content:[...]}} {type:compaction,id:8hex,parentId:8hex,timestamp:...,summary:...,firstKeptEntryId:8hex,tokensBefore:N} {type:label,id:8hex,parentId:8hex,timestamp:...,targetId:8hex,label:v1 attempt} {type:leaf,id:8hex,parentId:8hex,timestamp:...,targetId:8hex} {type:session_info,id:8hex,parentId:8hex,timestamp:...,name:refactor auth flow}类型一览type用途session文件 header仅第一行message用户 / assistant / toolResult 消息model_change切换模型记录thinking_level_change切换 thinking 等级记录compaction历史折叠摘要branch_summary分支被放弃前的摘要label给某个 entry 打的人类可读标签leaf当前活跃叶子指针可多次写session_info显示名等元信息custom_message/custom扩展自定义消息 / 数据每个 entry 必有四个字段type、id在文件内唯一、parentId指向上一条逻辑前驱、timestampISO string。2.3 树结构示例下面这段对话产生的 entry 树id 简化为字母[session header] A: model_change (parentId: null) B: message (parentId: A, role: user, 改一下登录) C: message (parentId: B, role: assistant, 好的先看一下文件) D: message (parentId: C, role: assistant, toolCall: read auth.ts) E: message (parentId: D, role: toolResult, ...) F: message (parentId: E, role: assistant, 用 X 方案改) leaf: { targetId: F }物理上是 7 行 JSON。逻辑上是一条从A到F的线性链leaf指向F表示当前活跃位置。三、append-only 与 leaf 指针整个 session 文件只追加不修改。这是 pi 设计中最强的不变量。3.1 写入只走 appendFile存储后端实现里只有appendEntry方法做写入// packages/agent/src/harness/session/jsonl-storage.tsasyncappendEntry(entry:SessionTreeEntry):Promisevoid{getFileSystemResultOrThrow(awaitthis.fs.appendFile(this.filePath,${JSON.stringify(entry)}\n),...);this.byId.set(entry.id,entry);updateLabelCache(this.labelsById,entry);this.currentLeafIdleafIdAfterEntry(entry);}没有updateEntry、没有deleteEntry。session 一旦写入就是历史。3.2 leaf 是一种独立的 entry 类型当前我们在哪一条 message 上这种状态如果存在内存里崩溃后就丢了。pi 的做法是leaf 也是一条持久化的 entry// packages/agent/src/harness/session/jsonl-storage.tsasyncsetLeafId(leafId:string|null):Promisevoid{if(leafId!null!this.byId.has(leafId)){thrownewSessionError(not_found,Entry${leafId}not found);}constentry:LeafEntry{type:leaf,id:generateEntryId(this.byId),parentId:this.currentLeafId,timestamp:newDate().toISOString(),targetId:leafId,};getFileSystemResultOrThrow(awaitthis.fs.appendFile(this.filePath,${JSON.stringify(entry)}\n),Failed to append session leaf${entry.id},);this.entries.push(entry);this.byId.set(entry.id,entry);this.currentLeafIdleafId;}每次切换叶子用户回退、navigate 到旧分支都会 append 一个leafentry。重开文件时只需读到最后一个leaf就能恢复出上次活跃在哪functionleafIdAfterEntry(entry:SessionTreeEntry):string|null{returnentry.typeleaf?entry.targetId:entry.id;}// loadJsonlStorage 加载时letleafId:string|nullnull;for(leti1;ilines.length;i){constentryparseEntryLine(lines[i]!,filePath,i1);entries.push(entry);leafIdleafIdAfterEntry(entry);// ← 普通 entry 推进 leafleaf entry 显式跳转}普通消息 entry 写入会让 leaf 推进到自己只有leafentry 是显式的跳过去。3.3 上层包装SessionManagercoding-agent在 storage 之上加了一层SessionManager把 entry 创建的细节封住// packages/coding-agent/src/core/session-manager.tsappendMessage(message:AgentMessage):string{constentry:MessageEntry{type:message,id:generateId(this.byId),parentId:this.leafId,// ← 当前 leaf 作为 parenttimestamp:newDate().toISOString(),message,};this.fileEntries.push(entry);this.byId.set(entry.id,entry);this.leafIdentry.id;// ← 自动推进 leafthis._persist(entry);returnentry.id;}appendCompactionT(summary:string,firstKeptEntryId:string,tokensBefore:number,details?:T):string{constentry:CompactionEntry{type:compaction,id:generateId(this.byId),parentId:this.leafId,timestamp:newDate().toISOString(),summary,firstKeptEntryId,tokensBefore,details,};// ... 同样 append}SessionManager还提供branch(branchFromId)来切换 leaf 到一个旧 entry// packages/coding-agent/src/core/session-manager.tsbranch(branchFromId:string):void{if(!this.byId.has(branchFromId)){thrownewError(Entry${branchFromId}not found);}this.leafIdbranchFromId;}切完 leaf 后下一次appendMessage写入的 entryparentId就是branchFromId——这样多个孩子共享同一个 parentId树就长出新分支了。四、两种 forkfork 与 navigateTreepi 提供两种分叉底层数据结构相同但物理位置不同用法也不同。4.1 fork复制路径到新文件SessionManager.createBranchedSession(leafId)把从根到指定 leaf 的路径复制到一个新的 .jsonl 文件// packages/coding-agent/src/core/session-manager.tscreateBranchedSession(leafId:string):string|undefined{constpaththis.getBranch(leafId);if(path.length0){thrownewError(Entry${leafId}not found);}// 创建新 session 文件header 里 parentSession 指回原文件// 然后把 path 上的所有 entry 重新 appendid 不变...}效果原文件 a.jsonl 新文件 b.jsonl ───────────────── ───────────────── session header (parentSession: -) session header (parentSession: a.jsonl) A: model_change A: model_change ← 复制 B: message (user) B: message (user) ← 复制 C: message (asst) C: message (asst) ← 复制 D: message (asst, toolCall) leaf: { target: C } E: toolResult F: message (asst, X 方案) leaf: { target: F }新文件从C之后是空的可以独立追加完全不同的对话。两份文件完全独立互不影响。适用场景用户在某个用户消息处点 “Fork from here”想从这里发起一个完全独立的探索以一个旧会话作为模板开新会话给团队成员分享一段对话历史让他从某点继续。UI 上通常表现为 sidebar 树里多出一个子会话节点通过 header 里的parentSession字段反查得到。4.2 navigateTree在同一文件里建多分支AgentSession.navigateTree(targetId)不复制文件而是把 leaf 指针跳到targetId下次写入就会形成树的新分支单个文件leaf 跳到 C 之后再 append M1 [session header] A: model_change B: message (user) C: message (asst) D: message (asst, toolCall, parentIdC) ← 旧分支 E: toolResult (parentIdD) F: message (asst, parentIdE) leaf: { target: F } [这里调 navigateTree(C)写一个 leaf entry] leaf: { target: C } [现在再 prompt 一句会 append 一个 messageparentIdC] M1: message (user, parentIdC) ← 新分支起点 M2: message (asst, parentIdM1) leaf: { target: M2 }文件里同时存在两个分支C → D → E → F和C → M1 → M2共享前面的B → C。适用场景用户在对话里点回退到第 N 条消息从那里换个问法比较同一个上下文下两种问法的效果LLM 自己生成多个候选答案让用户挑每个候选作为一个分支。UI 上通常表现为同一会话里的分支选择器pi-web 里叫 BranchNavigator用户能在分支间来回切每次切都 append 一个leafentry 做持久化。4.3 两种 fork 的对比维度fork(createBranchedSession)navigateTree物理位置新文件同文件文件 header新文件parentSession反指原文件不变是否复制 entry是路径上的所有 entry 重写一份否只 append 新的 leaf 跳转元数据可继承性弱独立会话强共享所有元数据sidebar 表现树里新增子节点同会话内分支选择器适用完全独立的探索当前对话内换个问法它们底层是同一套树结构只是物理上的分割粒度不同。这是 pi 比许多框架更成熟的一处设计——大多数项目要么只支持一种、要么把两种概念混淆。五、上下文构建buildSessionContext写入是 append-only 的但喂给 LLM 的对话历史必须是线性的。buildSessionContext把树降维成线// packages/agent/src/harness/session/session.tsexportfunctionbuildSessionContext(pathEntries:SessionTreeEntry[]):SessionContext{letthinkingLeveloff;letmodel:{provider:string;modelId:string}|nullnull;letcompaction:CompactionEntry|nullnull;for(constentryofpathEntries){if(entry.typethinking_level_change){thinkingLevelentry.thinkingLevel;}elseif(entry.typemodel_change){model{provider:entry.provider,modelId:entry.modelId};}elseif(entry.typemessageentry.message.roleassistant){model{provider:entry.message.provider,modelId:entry.message.model};}elseif(entry.typecompaction){compactionentry;}}constmessages:AgentMessage[][];constappendMessage(entry:SessionTreeEntry){if(entry.typemessage){messages.push(entry.messageasAgentMessage);}elseif(entry.typecustom_message){messages.push(createCustomMessage(...));}elseif(entry.typebranch_summaryentry.summary){messages.push(createBranchSummaryMessage(...));}};if(compaction){// 用 summary 替换 compaction 之前的所有内容保留 firstKeptEntryId 之后的messages.push(createCompactionSummaryMessage(...));constcompactionIdxpathEntries.findIndex((e)e.typecompactione.idcompaction.id);letfoundFirstKeptfalse;for(leti0;icompactionIdx;i){constentrypathEntries[i]!;if(entry.idcompaction.firstKeptEntryId)foundFirstKepttrue;if(foundFirstKept)appendMessage(entry);}for(leticompactionIdx1;ipathEntries.length;i){appendMessage(pathEntries[i]!);}}else{for(constentryofpathEntries){appendMessage(entry);}}return{messages,thinkingLevel,model};}输入是pathEntries从 leaf 沿parentId回溯到根的 entry 序列。处理三件事沿路径累计配置thinkingLevel、model用最后一次出现的为准assistant message 也会更新 model因为它记录了这条消息实际用的什么模型跳过非消息类 entryleaf、label、session_info、thinking_level_change、model_change都不进入 messagesCompaction 折叠如果路径上有compactionentry把它之前的内容用 summary 替换但保留firstKeptEntryId之后的 entry这是 compaction 算法决定要保留的近期上下文。注意这是纯函数——每次 turn 开始都会重算一次。pi 不缓存对话历史而是从 source of truthsession 文件每次推导。这让任何对 session 的修改都能立刻反映到下一个 turn。SessionManager暴露的buildSessionContext()包了一层从当前 leaf 开始buildSessionContext():SessionContext{returnbuildSessionContext(this.getEntries(),this.leafId,this.byId);}getBranch(fromId?:string):SessionEntry[]{constpath:SessionEntry[][];conststartIdfromId??this.leafId;letcurrentstartId?this.byId.get(startId):undefined;while(current){path.unshift(current);currentcurrent.parentId?this.byId.get(current.parentId):undefined;}returnpath;}getBranch沿parentId回溯得到从根到 leaf 的线性路径。即使文件里有多个分支共存只有当前 leaf 所在的那一条会被读出来。六、和线性消息列表的对比总结能力线性 messagespi JSONL 树写入整文件覆盖或 appendappend-only回退到旧消息截断后续 / 复制全部写一条leafentry同一上下文多分支不支持navigateTree 原生支持跨会话分支复制整个文件fork (createBranchedSession)历史折叠重写文件写一条compactionentry崩溃恢复整文件事务行级最后一行损坏只丢一个 entry多端编辑易冲突append 天然顺序但仍需 leader元数据model 切换、label混在 message 里独立 entry 类型代价文件读取要解析整棵树复杂度比线性高pi 的实现是把 entry 全部读进Mapid, entry一次构建历史 entry 不能删除pi 通过 compaction 来逻辑删除把折叠的内容用 summary 替换文件会持续增长需要 compaction 必要时手工归档。收益任意一条 entry 都能稳定引用id 永久有效任何修改 session的操作都不会破坏历史重开文件能精确恢复到上次活跃位置分支不需要新文件单文件能表达完整对话探索过程。七、设计中的几个关键不变量session 文件 append-only。任何代码不允许 truncate 或 update 已有行需要重写整个文件的场景比如 cascade reparentpi 通过完全重写新文件来实现原文件保持只读。leaf 由 entry 承载。内存里的leafId字段只是缓存source of truth 是文件里最后一个leafentry或最后一条普通 entry。id 在文件内唯一。generateEntryId(byId)在每次 append 前确保 id 不重复跨文件可以重复。parentId 可以为 null。表示这是路径的根通常是第一条 model_change 或 message。compaction entry 替换历史不删除。LLM 看到的是 summary但原始 entry 仍在文件里——如果将来需要可以读取原始内容做审计或重新 compact。buildSessionContext 是纯函数。从 entry 数组到 messages 数组的转换不依赖任何外部状态意味着任何对 session 的改动都能在下个 turn 立即生效。八、可借鉴的工程要点如果你正在做需要历史回放或分支探索的对话系统从 pi 这套设计里能直接搬走的几条每条记录配 id parentId。即使一开始只用线性后续要加分支也只是切 leaf。当前位置作为独立 entry 持久化。运行时缓存可以丢文件里的leafentry 不能丢。复制文件和同文件分支分别提供 API因为它们是两种用户意图。混用会让 UI 设计变得困难。元数据用独立 entry 类型model 切换、label、session_info不要塞 message 字段。这让文件的语义层级更清晰。历史折叠用 entry 而不是重写。compactionentry 是逻辑层的折叠物理层文件不变给审计、回滚、二次处理留了空间。buildContext 永远从 source 重算。不要做增量维护一个内存对话历史的优化每次 turn 重算才能保证多分支切换 / compaction / extension 写入都立即生效。写在最后把会话历史从线性数组升级成append-only 事件树看上去只是数据结构的小调整落到产品上是一系列能力解锁精确回放、并行探索、历史折叠、崩溃恢复、跨会话 fork。这些能力 ChatGPT 与 Claude 在产品层都做了但在公开的开源项目里pi 是少有的把这套数据结构清晰暴露出来的样本。更难得的是它的实现非常克制——核心代码不到一千行没有引入任何额外的数据库、没有索引、没有事务系统只用appendFile加几个 entry 类型就把所有能力承载起来。这是一种用约束换能力的工程哲学越是对写入做严格约束append-only就越能稳定地暴露丰富的读取语义历史回放、分支、折叠。仓库地址https://github.com/earendil-works/pi-mono关键文件packages/agent/src/harness/session/jsonl-storage.tspackages/agent/src/harness/session/session.tspackages/coding-agent/src/core/session-manager.ts