会话存储:保存、恢复与分支
用 JSONL 会话树保存可恢复的运行记录,设计会话标识、恢复入口与损坏记录的处理策略。
会话存储:保存、恢复与分支
跑到一半的智能体会话要不要能接着跑?能的话,存什么、存在哪、怎么找回来、文件坏了怎么办,这四件事必须一次定清楚。
Pi 的会话存储格式是 JSONL——每行一个 JSON 对象,所有行组成一棵树。这篇讲清这棵树的结构,然后把它变成你能依赖的恢复入口。
想象这样一个场景:用户今天让 Pi 修了一半类型错误,第二天回来想接着修。会话文件就是那个"接着修"的数据源。
读它需要能读 JSON、知道文件系统基本概念,并且用过至少一个 JSONL 格式的日志或记录文件。读完你能找到 Pi 为当前目录建的会话文件、从中恢复上下文、给损坏行设计兜底策略。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
最终要跑通什么
- 找到 Pi 为当前工作目录创建的会话文件,读出它的树结构。
- 用
SessionManager打开一个会话,确认上下文能被正确重建。 - 制造一个损坏行,确认解析不会整体崩溃。
- 从已有会话派生一个新会话,两个文件互不影响。
第一步:会话是什么
一个持久会话是一个 JSONL 文件,每个会话目录下按工作目录分组。默认位置是 ~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl,其中 <path> 是工作目录去掉开头分隔符、再把 /、\、: 换成 - 的结果。
文件里每行的类型由 type 字段区分。文件第一行永远是会话头,它只有元数据,不参与树结构:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}这行里的 version 是格式版本(当前是 3,历史版本加载时自动迁移),id 是会话标识,cwd 是创建会话时的工作目录——它决定这个会话归到哪个分组目录,也决定恢复时按哪份工作目录的上下文文件装配。type 之外的字段都不参与模型上下文。
树结构由其余条目的 id 和 parentId 两个字段表达:
// 会话条目的公共字段:所有非会话头的条目都继承这一组
interface SessionEntryBase {
type: string;
id: string; // 通常是 8 字符十六进制 ID,可能退化为完整 UUID
parentId: string | null; // 父条目 ID,根条目为 null
timestamp: string; // ISO 时间戳
}id 与 parentId 一起构成树:parentId 为 null 的是根,当前条目(leaf)标识活动分支——从 leaf 沿 parentId 回溯到根的那条路径,就是下一次模型请求看到的历史。
会话文件有版本号,当前是 3。历史版本会自动迁移:v1 是线性条目序列(legacy),v2 引入树结构,v3 把 hookMessage 角色改名为 custom。
第二步:条目类型
会话文件里不只有对话消息。搞清楚每种条目"参不参与模型上下文"很关键:
有一条规则值得单独记住:系统提示和工具声明不单独存一份状态。第一个请求会持久化一条带完整提示段和工具声明的 system 消息,之后的变更以 system 消息记录,按名字 patch sections(null 表示删除某段),并列出 toolsAdded / toolsRemoved。顺序重放这些消息就能得到当前的提示与工具状态。
usage 条目有个细节:官方要求消费者把未知的 kind 值当作普通用量处理,而不是拒绝它。
第三步:会话目录与优先级
会话存放位置的三个配置入口,官方文档明确了优先级:
CLI 文档的原话是:--session-dir 覆盖存储与查找,它优先于 PI_CODING_AGENT_SESSION_DIR 和 sessionDir 设置。
有个例外值得记住:sessionDir 是在解析项目信任之前读取的。官方安全文档说明了原因——Pi 需要先知道去哪里找会话,才能决定加载哪个会话。项目信任控制的是"要不要加载这个目录提供的设置和资源",但它拦不住这一次目录查找。同样地,--session-dir 指定的目录不按工作目录分组。
第四步:四种会话入口
一次运行要用哪种会话,取决于它是一次性的还是要能恢复的:
CLI 还有几条约束:--fork 不能和 --session、--continue、--resume、--no-session 组合;--session-id 不能和 --session、--continue、--resume 组合,但可以和 --fork 一起指定新 ID。会话 ID 必须以字母或数字开头结尾,只允许字母、数字、.、_、-。
代码路径和 CLI 一一对应:
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
// 临时运行:不落盘
const { session: ephemeral } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
// sessionFile 对内存会话是 undefined,所以这里要兜底
console.log("临时会话文件:", ephemeral.sessionFile ?? "(无)");
ephemeral.dispose();
// 新建持久会话:文件落在 sessions/ 下按工作目录分组的目录里
const { session: fresh } = await createAgentSession({
sessionManager: SessionManager.create(cwd),
});
console.log("新会话文件:", fresh.sessionFile);
fresh.dispose();
// 继续当前目录最近的会话:没有就新建一个
const { session: continued } = await createAgentSession({
sessionManager: SessionManager.continueRecent(cwd),
});
console.log("继续的会话:", continued.sessionFile);
continued.dispose();
// 列出并打开指定会话
// list(cwd) 只列当前工作目录的会话;listAll() 扫全部目录,慎用
const sessions = await SessionManager.list(cwd);
for (const info of sessions.slice(0, 3)) {
console.log(`${info.id.slice(0, 8)} — ${info.firstMessage.slice(0, 40)}`);
}
if (sessions.length > 0) {
const { session: opened } = await createAgentSession({
// open 按路径打开:恢复的上下文就是文件里的活动分支
sessionManager: SessionManager.open(sessions[0].path),
});
console.log("打开的会话 ID:", opened.sessionId);
opened.dispose();
}SessionManager.list(cwd, sessionDir?, onProgress?, signal?) 返回的 SessionInfo 包含 path、id、cwd、name、parentSessionPath、created、modified、messageCount、firstMessage、allMessagesText。扫描大目录时可以用 onProgress 报告进度,用 signal 取消。
第五步:三种分支方式
Pi 把条目存成树,所以回到之前的某个点不会抹掉你离开的那条分支。三种方式看起来相似,作用完全不同:
/tree 的交互细节:选中一条用户消息会把它的文本放回编辑器,编辑后提交就创建出另一条分支;选中助手回复或其他条目则从该条目之后继续,编辑器保持空白。
离开分支时,Pi 可以为你正在放弃的那条分支生成摘要,并把它附到你要进入的分支上。这样保留了被放弃路径里的相关工作,又不用把每条消息都带过去。
对应的 SDK API 是 session.navigateTree(targetId, options),它要求当前没有运行中的响应、也没有压缩在进行中,否则会抛错。
这三个操作都不改变"模型看到什么"的规则:模型永远只看活动分支。会话文件里保留的其他分支是给人看的存档,不进上下文。
第六步:SessionManager 是权威
这是本篇最重要的一条。官方 SDK 文档的原话:
SessionManageris authoritative for finalized model context. Restore external history by constructing the session with a manager containing those entries. Assigningsession.agent.state.messagesdoes not replace persisted context.
翻译过来:直接给 session.agent.state.messages 赋值,不会替换掉持久化的上下文。
这个坑很容易踩。你以为改完 agent.state.messages 就能控制模型看到什么,实际上下一次请求时 Pi 会用 SessionManager 重建上下文,把改动覆盖掉。正确做法是构造一个带有你想要的条目的 manager:
import { createAgentSession, SessionManager, type FileEntry } from "@earendil-works/pi-coding-agent";
// 外部历史:从别处读来的条目
const externalEntries: FileEntry[] = [
{
// 会话头:type 为 session 的那条不参与树结构
type: "session",
version: 3,
id: "restored-session",
timestamp: new Date().toISOString(),
cwd: process.cwd(),
},
{
// 根条目:parentId 为 null
type: "message",
id: "m0000001",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "继续修复类型错误", timestamp: Date.now() },
},
];
const { session } = await createAgentSession({
// inMemory 的第三个参数就是初始条目数组,这是恢复外部历史的官方途径
sessionManager: SessionManager.inMemory(process.cwd(), undefined, externalEntries),
});
try {
await session.prompt("从刚才的进度继续");
} finally {
session.dispose();
}如果目标是"往当前会话里追加历史",对应的方法是 sessionManager.appendMessage(...),它会正确地挂到当前叶子上并更新父子关系。
还有一个容易混淆的边界:session.agent.state.messages = newMessages 本身是合法的,Agent Core 文档说赋值时会先复制顶层数组再存储。但它只影响 Agent 内部状态,不影响持久化——两份状态从此分叉。除非你有明确理由,不要碰它。
第七步:损坏记录的处理
JSONL 的好处是每行独立,坏一行不影响其他行。官方源码里 parseSessionEntries 的做法正是逐行解析、跳过解析失败的行:
// 官方源码的容错解析:逐行解析,坏行跳过,不中断整体
export function parseSessionEntries(content: string): FileEntry[] {
const entries: FileEntry[] = [];
// 末尾可能有多余空行,trim 后再切
const lines = content.trim().split("\n");
for (const line of lines) {
// 空行直接跳过,不当作损坏
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as FileEntry;
entries.push(entry);
} catch {
// Skip malformed lines
}
}
return entries;
}细节有三个:空行被跳过,坏行被跳过,坏行不会中断整个解析。从文件读取时(loadEntriesFromFile)走的是同一套容错逻辑,而且会在修复文件前先校验会话头——如果第一行不是合法的会话头(type 不是 session 或 id 不是字符串),直接返回空数组。
官方文档没有明确规定宿主应该怎么应对损坏记录。 上面是源码事实,但"读到多少条算够""要不要备份原文件""要不要报警"要宿主自己定。下面是一个参考实现:
import { readFileSync } from "node:fs";
import type { FileEntry } from "@earendil-works/pi-coding-agent";
export interface RecoverResult {
entries: FileEntry[];
/** 因格式错误被跳过的行号(从 1 开始) */
skippedLines: number[];
/** 文件头是否合法;不合法时整个会话不可用 */
headerValid: boolean;
}
/**
* 读取会话文件并跳过损坏行。
* 策略:保留能解析的全部条目,把坏行位置报给宿主,由宿主决定是否告警。
*/
export function recoverSessionFile(path: string): RecoverResult {
const content = readFileSync(path, "utf-8");
const entries: FileEntry[] = [];
const skippedLines: number[] = [];
// 逐行解析:尽量恢复,而不是因为一行坏 JSON 把整个文件丢掉
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
try {
entries.push(JSON.parse(line) as FileEntry);
} catch {
// 记下行号(从 1 开始):静默跳过会让人误以为会话是完整的
skippedLines.push(i + 1);
}
}
const header = entries[0];
// 头非法说明这压根不是一个会话文件:条目能解析也缺父子关系,重建不出活动分支
const headerValid = header?.type === "session" && typeof (header as { id?: unknown }).id === "string";
return { entries, skippedLines, headerValid };
}这个策略的取舍是明确的:尽量恢复,而不是整体丢弃。一个会话文件里可能混着几十轮有价值的对话,为了一行坏 JSON 把整个文件丢掉不划算。但 headerValid 为 false 时要区别对待——那说明文件根本不是一个会话,条目即使能解析也缺少父子关系,无法重建出活动分支。
宿主还应该补两件官方不管的事:读之前先复制一份原文件,以及把 skippedLines 记进日志——静默跳过会让人误以为会话是完整的。
谁负责什么
三种场景的可观察结果
你现在能做什么
- 在
~/.pi/agent/sessions/下找到当前目录对应的那一格(目录名是工作目录把分隔符换成-的结果),打开最新的.jsonl,数一数有多少行、能不能用jq逐行解析出来。 - 跑一次
pi --continue,确认它打开的就是那个目录下的会话;再跑一次pi --session <部分ID>,确认按部分 ID 也能命中同一个文件。 - 复制一份会话文件,往中间插一行
{bad json,然后用recoverSessionFile跑一遍,确认skippedLines指出了那一行、其余条目仍然可用。 - 用
SessionManager.forkFrom(源文件, 另一个目录)派生一个会话,比较两个文件的条目数量和parentSession字段,确认派生是复制而不是移动。