Skip to content

6.5 Session 存储格式与会话树

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题5.5 讲的是会话什么时候创建、什么时候写盘、怎么恢复——那是生命周期。本章讲数据本身:磁盘上一行到底有哪些字段、11 种 entry 各记录什么、这些数据靠哪几个 interface 与存储介质解耦,以及为什么同一套会话逻辑能同时跑在文件、内存和 SQLite 上。 前置知识2.2 interface、type 与函数类型2.3 union、字面量与可辨识联合2.4 泛型、class 与类型收窄5.5 Session 的创建、保存与恢复学习目标:① 说清 SessionStorageSessionSessionRepo 三个抽象各自的职责边界;② 逐字段读懂一行 entry,并说出 11 种 entry 类型的分工;③ 解释 leaf 指针、分支与 getPathToRootOrCompaction 的关系;④ 对照三个后端说出「抽象换来了什么」;⑤ 亲手造一个合法的会话文件,用 Pi 的真实源码把它读出来。

建立直觉:把「存什么」和「存哪儿」拆开

假设你要给一个 Agent 加持久化。最直接的写法是在保存消息的地方直接写 appendFileSync(path, JSON.stringify(msg))——packages/coding-agent/src/core/session-manager.ts 走的正是这条路(5.5 已经精读过它的 _persistsession-manager.ts:1015-1042)。这条路很短,代价是「会话的领域逻辑」和「文件系统调用」焊死在一起:想换成 SQLite,就得把 branch()buildSessionContext() 这些跟文件毫无关系的方法一起重写一遍;想在单元测试里跑会话逻辑,就得真的建临时目录。

pi-agent-core 的 harness 把这件事拆成了三层:

📘 概念存储后端(SessionStorage)
一个 interface,只回答「一条 entry 怎么存、怎么取、当前叶子是谁」。它不知道什么叫消息、什么叫压缩,只知道 entry 是一棵靠 parentId 连起来的树。JSONL、内存、SQLite 各实现一份。
📘 概念领域层(Session)
一个 class,包着一个 SessionStorage。它知道「追加一条消息」要造一个 type: "message" 的 entry、知道 compaction 该怎么折叠成上下文(Context)。它只调 storage 的方法,从不碰文件系统。
📘 概念仓库层(SessionRepo)
一个 interface,管的是「一堆会话」而不是「一个会话」:新建、打开、列表、删除、fork。目录布局、文件命名、数据库路径这类问题都属于它。

类比一下:SessionStorage 像数据库驱动,Session 像 ORM 里的领域模型,SessionRepo 像连接池加 DAO。三层各自可替换,接缝处只有类型。

🌱 初学者提示仓库里有两套 Session 实现
5.5 已经点明:pi 命令行界面(CLI,Command-Line Interface)今天用的是 coding-agent 自己的 SessionManager(同步、只支持 JSONL、带版本迁移),而本章讲的三层抽象属于 pi-agent-core 的 AgentHarness。两者磁盘布局刻意保持一致,代码却互不引用。本章末尾会用 grep 证据把这件事说清楚。

三个抽象的契约

SessionStorage:存储后端要答应做的事

earendil-works/pi@c13ffe1第 498–514 行在 GitHub 查看 ↗
存储后端契约:12 个方法全部返回 Promise,泛型参数 TMetadata 让不同后端携带各自的元数据形状。
ts
// packages/agent/src/harness/types.ts:498-514(节选)
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
	getMetadata(): Promise<TMetadata>;
	getLeafId(): Promise<string | null>;
	/** Persist a leaf entry that records the active session-tree leaf. */
	setLeafId(leafId: string | null): Promise<void>;
	createEntryId(): Promise<string>;
	appendEntry(entry: SessionTreeEntry): Promise<void>;
	getEntry(id: string): Promise<SessionTreeEntry | undefined>;
	// …(省略:findEntries / getLabel / getSessionName / getSessionStats)
	getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]>;
	getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]>;
}

有三个细节值得停一下。

第一,全部异步。 连纯内存实现的 getEntry 也返回 Promisepackages/agent/src/harness/session/memory-storage.ts:102-104)。从源码结构看,这是为了让 SQLite、远程存储这类天然异步的后端不必改接口——把同步实现包成 Promise 很容易,反过来不可能。代价是内存后端多了一堆无意义的 await

第二,createEntryId() 属于 storage 而不是领域层。 因为 id 唯一性只有 storage 知道:JSONL 与内存实现各自有一份逐字相同的 generateEntryIdjsonl-storage.ts:43-51memory-storage.ts:28-36),都用 uuidv7().slice(-8) 取随机尾部、最多重试 100 次,冲突检测靠的是 storage 自己的 byId 映射。

第三,没有 update 也没有 delete 整个接口只有 appendEntry。这就是 append-only(只追加)在类型层面的表达——历史不可改写,不是靠纪律,是靠接口里根本没有那个方法。

Session:把消息翻译成 entry

Session 类(packages/agent/src/harness/session/session.ts:150-359)是唯一构造 entry 的地方——storage 只负责存与取,从不自己造节点。它的每个 appendXxx 方法都是同一个三步套路:

earendil-works/pi@c13ffe1第 219–227 行在 GitHub 查看 ↗
领域层追加消息的三步:向 storage 要一个新 id、把当前 leaf 当作 parentId、造出 MessageEntry 交给 storage 落盘。
ts
// packages/agent/src/harness/session/session.ts:219-227
async appendMessage(message: AgentMessage): Promise<string> {
	return this.appendTypedEntry({
		type: "message",
		id: await this.storage.createEntryId(),
		parentId: await this.storage.getLeafId(),
		timestamp: new Date().toISOString(),
		message,
	} satisfies MessageEntry);
}

appendThinkingLevelChangeappendModelChangeappendCompactionappendLabelappendSessionName 全是同一个模子(session.ts:229-336),差别只在多出来的字段。satisfies MessageEntry 让 TypeScript 在编译期检查字段齐不齐,同时保留字面量类型——这正是 2.3 讲过的可辨识联合(Discriminated Union)在写入侧的用法。

Session 另一半职责是getBranch()session.ts:179-182)取当前路径,buildContext()session.ts:188-190)把路径投影成消息数组。这两步的纯函数版本 buildSessionContextsession.ts:138-148)不依赖任何 storage,可以单独测试。

需要说清一个边界:「只有 Session 认识 entry」这句话对成立,对只成立一半。storage 为了维护索引和统计,也会窥探少数几个字段——updateLabelCache 认得 labeljsonl-storage.ts:25-33)、leafIdAfterEntry 认得 leafjsonl-storage.ts:134-136)、getSessionStats 认得 message/compaction/branch_summary 上的 usagejsonl-storage.ts:308-348)。除此之外的语义(一条 entry 该翻译成什么消息、compaction 怎么折叠)全在 Session 一侧。

SessionRepo:管一堆会话

earendil-works/pi@c13ffe1第 528–538 行在 GitHub 查看 ↗
仓库层契约:五个方法都以「元数据」为标识而不是以文件路径为标识,因此 SQLite 后端可以用主键代替路径。
ts
// packages/agent/src/harness/types.ts:528-538(节选)
export interface SessionRepo<
	TMetadata extends SessionMetadata = SessionMetadata,
	TCreateOptions extends SessionCreateOptions = SessionCreateOptions,
	TListOptions = void,
> {
	create(options: TCreateOptions): Promise<Session<TMetadata>>;
	open(metadata: TMetadata): Promise<Session<TMetadata>>;
	list(options?: TListOptions): Promise<TMetadata[]>;
	delete(metadata: TMetadata): Promise<void>;
	fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<Session<TMetadata>>;
}

三个泛型参数是这个接口最值得看的地方。TMetadata 让 JSONL 后端的元数据带上 cwd/path/parentSessionPathtypes.ts:486-491),而内存后端只需要 id + createdAttypes.ts:481-484)。TCreateOptions 让 JSONL 的 create() 能强制要求传 cwdtypes.ts:540-544),内存版则不需要。换句话说:共性写在接口里,差异写进类型参数,调用方仍然只面对一个 SessionRepo

具体后端并不直接 implements SessionRepo,而是先把三个类型参数填好、起一个别名:JsonlSessionRepoApitypes.ts:550-551)就是 SessionRepo<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions>,SQLite 侧同理(packages/storage/sqlite-node/src/sqlite/types.ts:52-53)。所以 JsonlSessionRepo implements JsonlSessionRepoApijsonl-repo.ts:38)在类型上仍然是一个 SessionRepo

图加载中…

图 6.5-1 Session 存储的三层抽象与三个后端
虚线空心箭头是「实现接口」,实心菱形是「持有」。读这张图时请关注中间那一列:`Session` 只连到 `SessionStorage` 这个 interface,没有任何一条线指向具体后端。

图中每个类都能在源码中定位:SessionStoragetypes.ts:498)、SessionRepotypes.ts:528)、Sessionsession.ts:150)、JsonlSessionStoragejsonl-storage.ts:187)、InMemorySessionStoragememory-storage.ts:43)、SqliteSessionStoragepackages/storage/sqlite-node/src/sqlite/storage/index.ts:116)、JsonlSessionRepojsonl-repo.ts:38)、InMemorySessionRepomemory-repo.ts:5)、SqliteSessionRepopackages/storage/sqlite-node/src/sqlite/repo.ts:43)。最上面的 AgentHarness 通过构造参数拿到一个现成的 Sessionpackages/agent/src/harness/types.ts:917,赋值在 packages/agent/src/harness/agent-harness.ts:200)——它连 SessionRepo 都不认识,谁来建会话是应用层的事。

一行 entry 里有什么

公共四字段

所有 entry 共享同一个基类(packages/agent/src/harness/types.ts:375-380):

ts
export interface SessionTreeEntryBase {
	type: string;          // 可辨识联合的判别字段
	id: string;            // 8 字符短 id,仅需在本会话内唯一
	parentId: string | null; // 父节点;第一个 entry 为 null
	timestamp: string;     // ISO 时间戳
}

type 之外的三个字段就是全部的结构信息:id 定身份,parentId 连成树,timestamp 记时间。剩下的字段由各 entry 类型自己补。

11 种 entry

earendil-works/pi@c13ffe1第 453–464 行在 GitHub 查看 ↗
harness 侧的 entry 联合共 11 种;coding-agent 的 SessionEntry 只有 9 种(session-manager.ts:144-153),少了 active_tools_change 与 leaf。
type记录什么定义位置CLI 侧是否有
message一条 AgentMessagetypes.ts:382-385
thinking_level_change思考等级切换types.ts:387-390
model_change模型切换types.ts:392-396
active_tools_change当前启用的工具名列表types.ts:398-401
compaction上下文压缩(Context Compaction)检查点types.ts:403-412有(无 retainedTail
branch_summary被放弃分支的摘要types.ts:414-421
custom扩展(Extension)私有状态,默认不进上下文types.ts:423-427
custom_message扩展注入的消息,会进上下文types.ts:429-435
label给某个 entry 打书签types.ts:437-441
session_info会话显示名types.ts:443-446
leaf记录当前叶子指针移动到哪里types.ts:448-451

多出来的两种恰好对应 harness 的两处能力升级。active_tools_change 让「这一轮开了哪些工具」也进入会话历史,buildContext 会据此还原 activeToolNamessession.ts:51-53)。leaf 则解决了 5.5 提过的痛点:CLI 侧 leaf 指针不落盘,重新加载时被粗暴地设成文件最后一个 entry(session-manager.ts:958-977);harness 把「叶子挪到哪」也写成一行。

compactionretainedTail?: AgentMessage[] 字段同样只在 harness 侧存在(types.ts:408)。它把压缩时保留的尾部消息直接物化进 entry,于是这个节点变成一个自包含的检查点——重建上下文时到此为止,不必再往上走。官方文档说明这一点:「较新的 harness 生成的 compaction 会包含它,好让我们从这个检查点重建上下文而不必回溯压缩之前的 entry」(packages/coding-agent/docs/session-format.md:245)。压缩本身留给 6.6 Context 构造与 Compaction

逐字段读一行

一行 message entry 在磁盘上长这样(本书为教学构造,不是运行输出):

json
{"type":"message","id":"aaaa0002","parentId":"aaaa0001","timestamp":"2026-07-30T10:00:01.000Z","message":{"role":"user","content":"first question","timestamp":1785000001000}}

外层四个字段是树结构,message 字段里装的才是发给大语言模型(LLM,Large Language Model)的东西。注意有两个 timestamp:外层是 entry 的 ISO 字符串,内层是消息自己的毫秒数——两套体系,别混。这也解释了 5.5 开头「三层状态」那一节的分层:entry 包着 message,而不是 message 带着 entry 字段

树:leaf 指针与分支

追加即长树

appendEntry 之后当前叶子怎么变?答案在一个三行的纯函数里:

ts
// packages/agent/src/harness/session/jsonl-storage.ts:134-136
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
	return entry.type === "leaf" ? entry.targetId : entry.id;
}

普通 entry 写完,自己就是新叶子;leaf entry 写完,叶子跳到它的 targetId。加载文件时逐行重放这个函数即可还原叶子(jsonl-storage.ts:179-183),内存后端在构造时做同样的事(memory-storage.ts:57)。一个函数同时服务写入与恢复,两条路径不可能走偏——从源码结构看,这是 harness 相对 CLI 实现最干净的一处改进。

分支就是移动叶子

earendil-works/pi@c13ffe1第 338–358 行在 GitHub 查看 ↗
moveTo:先校验目标存在,再让 storage 记录新叶子;若给了 summary,额外追加一条 branch_summary,其 parentId 直接指向新叶子。
ts
// packages/agent/src/harness/session/session.ts:338-358(节选)
async moveTo(entryId: string | null, summary?: { summary: string; /* …(省略:details/usage/fromHook) */ }) {
	if (entryId !== null && !(await this.storage.getEntry(entryId))) {
		throw new SessionError("not_found", `Entry ${entryId} not found`);
	}
	await this.storage.setLeafId(entryId);
	if (!summary) return undefined;
	return this.appendTypedEntry({
		type: "branch_summary",
		// …(省略:id / timestamp)
		parentId: entryId,
		fromId: entryId ?? "root",
		summary: summary.summary,
	} satisfies BranchSummaryEntry);
}

没有删除、没有重写,只有一次 setLeafId 加可选的一条摘要。下一次 appendMessage 自然挂在新叶子下面,于是老路径成了兄弟分支。

沿 parentId 回溯

读的一侧只有一个方法承担全部树遍历:

earendil-works/pi@c13ffe1第 350–369 行在 GitHub 查看 ↗
从叶子沿 parentId 上溯到根;遇到带 retainedTail 的 compaction 直接停,否则记下 firstKeptEntryId 作为提前停止点。
ts
// packages/agent/src/harness/session/jsonl-storage.ts:352-368(节选)
const path: SessionTreeEntry[] = [];
let stopAtEntryId: string | null = null;
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
	path.unshift(current);
	if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
	if (current.type === "compaction") {
		if (current.retainedTail) break;
		stopAtEntryId = current.firstKeptEntryId ?? null;
	}
	if (!current.parentId) break;
	// …(省略:取父节点,取不到则抛 invalid_session)
}

方法名里的 "OrCompaction" 就是这个提前停止:压缩点之前的历史不再进入路径,也就不会进入上下文。内存实现的同名方法逐字相同(memory-storage.ts:163-182),这也是「两个后端行为等价」这句话的来源之一。

图加载中…

图 6.5-2 一个含分支与 leaf entry 的 entry 树
实线是 parentId 构成的父子关系,虚线表示「不是父子、只是引用」。这棵树正是本章实践任务会亲手造出来的那一个。

请特别注意 L 这个节点的两条线:它的 parentId 指向写入时的旧叶子 aaaa0004(所以它在树上挂在 aaaa0004 下),而 targetId 指向 aaaa0003(所以重放后当前叶子是 aaaa0003)。这两个指针方向不同,是 leaf entry 最容易读错的地方,源码见 jsonl-storage.ts:258-264E2 有两个孩子,这就是分支;getPathToRootOrCompaction("aaaa0003") 返回的是 E1 → E2 → E3aaaa0004L 都不在路径上。

⚠️ 常见误解以为 leaf entry 会被投影成一条消息
不会。sessionEntryToContextMessagessession.ts:103-136)只处理 messagecustom_messagecompactionbranch_summarycustom 五种,其余一律返回空数组。leaflabelmodel_change 这些是「元数据 entry」:它们改变会话状态,但不进入发给模型的消息数组。

三个后端

JSONL:一行一次 append

earendil-works/pi@c13ffe1第 278–287 行在 GitHub 查看 ↗
JSONL 后端的写入全貌:一次 appendFile 追加一行 JSON,然后更新内存里的三份索引与叶子指针。
ts
// packages/agent/src/harness/session/jsonl-storage.ts:278-287
async appendEntry(entry: SessionTreeEntry): Promise<void> {
	getFileSystemResultOrThrow(
		await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
		`Failed to append session entry ${entry.id}`,
	);
	this.entries.push(entry);
	this.byId.set(entry.id, entry);
	updateLabelCache(this.labelsById, entry);
	this.currentLeafId = leafIdAfterEntry(entry);
}

对比 5.5 精读过的 CLI 版 _persist:这里没有「等第一条 assistant 消息才建文件」的缓冲逻辑,JsonlSessionStorage.create 一开就把 header 写进去了(jsonl-storage.ts:236-239)。一种看法是:harness 版更简单、崩溃时丢的更少,代价是会留下只有 header 的空会话文件;CLI 版换来干净的会话列表,代价是多一个 flushed 状态位和一个「路径已算出但文件不存在」的中间态。

还有一处值得看的设计:这个 class 依赖的不是 node:fs,而是一个被窄化到四个方法的能力接口(jsonl-storage.ts:13):

ts
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "readTextLines" | "writeFile" | "appendFile">;

FileSystem 本体在 types.ts:291-341,注释明确要求「操作方法绝不抛异常,所有失败都编码进返回的 Result」(types.ts:288-289)。于是 storage 里每次文件操作都套一层 getFileSystemResultOrThrowrepo-utils.ts:24-30)把 Result 翻译成 SessionError。这让同一份 JSONL 代码可以跑在 Node 之外的运行时上——只要那个运行时提供这四个方法。

JsonlSessionRepo 负责目录:encodeCwdjsonl-repo.ts:34-36)与 CLI 侧的 getDefaultSessionDirPathsession-manager.ts:476-481)用的是同一个正则;文件名同样是「时间戳 _ sessionId .jsonl」(jsonl-repo.ts:65-73)。list() 只读每个文件的第一行jsonl-repo.ts:116loadJsonlSessionMetadata,后者 readTextLines(..., { maxLines: 1 })jsonl-storage.ts:153-156),并且对 invalid_session 单独放行、其他错误照抛(jsonl-repo.ts:117-120)——一个坏文件不会毁掉整张会话列表。

fork 则把「抽取路径」和「写新文件」拆得很干净:

earendil-works/pi@c13ffe1第 134–161 行在 GitHub 查看 ↗
跨文件 fork:先用 getEntriesToFork 取出要复制的 entry 序列,再新建一个 storage 逐条 appendEntry,header 的 parentSession 默认指向源文件。

要复制哪些 entry 由一个共享的纯函数决定(repo-utils.ts:32-51):不给 entryId 就整份复制;position: "at" 从该 entry 起算;默认的 "before" 要求目标必须是一条 user 消息,然后取它的 parentId——也就是「回到这条提问之前重来」。内存版 fork 复用同一个函数(memory-repo.ts:40),SQLite 版也是(packages/storage/sqlite-node/src/sqlite/repo.ts:170,import 见 repo.ts:4)。三种后端的 fork「取哪些 entry」完全一致,不同的只有「写到哪里去」。

内存:测试与「不要落盘」的场景

earendil-works/pi@c13ffe1第 43–62 行在 GitHub 查看 ↗
内存后端的类声明与构造函数:拷贝一份初始 entry 数组(防止外部继续 push 影响内部),重建 byId 与 label 索引,逐条重放出叶子并校验。
ts
// packages/agent/src/harness/session/memory-storage.ts:52-62
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
	this.entries = options?.entries ? [...options.entries] : [];
	this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
	this.labelsById = buildLabelsById(this.entries);
	this.leafId = null;
	for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
	if (this.leafId !== null && !this.byId.has(this.leafId)) {
		throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
	}
	this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
}

[...options.entries] 这一行是有意的防御:packages/agent/test/harness/storage.test.ts:23-40 的用例 "copies initial entries and persists leaf changes" 专门断言了「构造之后再往原数组 push,storage 不受影响」。

它今天被谁用?源码事实:全仓搜 InMemorySessionStorage/InMemorySessionRepo,除 memory-storage.ts/memory-repo.ts 两个实现文件外,只命中 packages/agent/test/ 下的六个文件——五个测试加一个可运行的最小示例 packages/agent/test/scratch/simple.ts,后者第 54 行就是 new Session(new InMemorySessionStorage()),把一个完整的 AgentHarness 跑在纯内存会话上。这正是抽象的用途:想要「跑一个 Agent 但什么都别写盘」,换一个构造参数即可,不必给 SessionManager 加开关。

两个后端行为一致靠的不是人工审查,而是一套参数化测试:packages/agent/test/harness/session.test.ts:19 定义 runSessionSuite(name, createStorage, inspect?),然后在 session.test.ts:231session.test.ts:233 分别用内存与 JSONL 各跑一遍同样的用例。JSONL 那次还额外传了一个 inspect 回调,直接读回文件断言「首行 header 的 version 是 3」「entry 里出现过 type: "leaf"」(session.test.ts:240-254)。想确认本章讲的行为不是杜撰,跑这一个文件就够——不需要任何 API Key。

SQLite:一个独立包,默认不启用

@earendil-works/pi-storage-sqlite-node4.3 数过的 7 个 package 中唯一一个不在 packages/ 顶层的(packages/storage/sqlite-node/package.json:2):

earendil-works/pi@c13ffe1第 43–53 行在 GitHub 查看 ↗
SQLite 仓库实现:构造参数是「执行环境 + SQLite 工厂 + 数据库路径」,工厂由调用方注入,所以核心包不必依赖任何原生模块。

它从 @earendil-works/pi-agent-core 里 import 的正是本章前半段的东西——SessionSessionStoragegetEntriesToForktoSessionrepo.ts:1-8)。表结构(packages/storage/sqlite-node/src/sqlite/migrations/001_initial.sql)包含 sessionssession_entriessession_sequencesbranch_entriessession_materializedentry_materialized 六张表,连接打开时设 journal_mode=WALsynchronous=FULLrepo.ts:30-33)。

官方文档说明它为什么单独成包:SQLite 后端与 node:sqlite 适配器放在独立包里,「这样核心包默认不会引入运行时内建模块或原生 SQLite 依赖;后端接受一个运行时特定的 SQLite 工厂,将来其他存储后端也可以各自发包」(packages/agent/README.md:13)。

定位需要严格分级。源码事实:全仓搜索 pi-storage-sqlite-nodeSqliteSessionRepo,除包内自身外只出现在 packages/agent/README.md:13packages/agent/test/harness/sqlite-migrations.test.ts;另一个测试 packages/agent/test/harness/sqlite-node.test.ts:3 也只是用相对路径 ../../../storage/sqlite-node/src/index.ts 直接 import 源码,并没有把它当依赖装进来。packages/coding-agent/package.jsonpackages/server/package.json 的依赖里都没有它。也就是说,本 commit 下它没有任何生产使用者。至于「server 包将来会用它」这类说法——packages/server/src/storage.ts 目前只负责 machine/instance 记录的 JSON 文件读写,与会话存储无关,据此推断(尚未在源码中直接证实)二者暂无关联计划。

coding-agent 消费了这些抽象吗

这是本章必须给出的一个诚实答案:没有。在锁定 commit 上运行

bash
grep -rn "harness/\|SessionStorage\|SessionRepo\|AgentHarness" packages/coding-agent/src --include="*.ts"

结果为空(源码事实,你可以自己复核)。AgentSessionpackages/coding-agent/src/core/agent-session.ts:303)的第二个只读字段就是 sessionManager: SessionManageragent-session.ts:305),而 SessionManager 在文件头直接 import 了 Node 的同步文件 API——appendFileSync/openSync/writeFileSync 等十项来自 "fs"session-manager.ts:4-15),没有任何一层能力接口挡在中间。

那么这三层抽象今天的消费者是谁?只有 AgentHarness 一个:它在构造参数里收下一个现成的 Sessiontypes.ts:917agent-harness.ts:200),此后所有写入都经 SessionappendXxx 走(agent-harness.ts:512-536538-543)。谁去建这个 Session(用哪个 SessionRepo、落到文件还是内存)是应用层的选择,harness 不参与——这正是 6.11 讲 SDK 时会用到的接缝。

那两套实现是什么关系?下面这张对照表能帮你在读任一侧源码时快速换算:

概念harness(pi-agent-core)CLI(pi-coding-agent)
存储契约SessionStoragetypes.ts:498无接口,逻辑内联在 class 里
领域层Sessionsession.ts:150SessionManagersession-manager.ts:855
仓库层SessionRepotypes.ts:528静态方法 create/open/continueRecent/forkFrom
追加消息Session.appendMessagesession.ts:219SessionManager.appendMessagesession-manager.ts:1057
移动叶子Session.moveTosession.ts:338branch / resetLeafsession-manager.ts:13601372
叶子持久化leaf entry不持久化,加载时取最后一个 entry
跨文件 forkSessionRepo.forkjsonl-repo.ts:134createBranchedSessionsession-manager.ts:1412
同步/异步全异步全同步
版本迁移无,v≠3 直接抛 unsupported session versionjsonl-storage.ts:77v1→v2→v3 自动迁移并重写文件

据此推断(尚未在源码中直接证实):CLI 将来会迁移到 harness 这套抽象。理由是 harness 刻意复刻了同样的目录编码、文件名规则与 entry 结构,看起来是为兼容读写做准备;但仓库里没有任何迁移 TODO、注释或引用能佐证,也没有任何跨实现互读的测试。反过来说,「两套 JSONL 完全互读」这件事大概率不成立:harness 拒绝 v1/v2 文件;而 CLI 的 SessionEntry 联合里没有 leaf_buildIndexsession-manager.ts:958-977)会把 leaf entry 当成普通节点,叶子指向 leaf entry 自身而非它的 targetId,语义就偏了。

图加载中…

图 6.5-3 一次 appendMessage 穿过三层的时序
自上而下按时间读。注意 `Session` 一共只跟 storage 说了三句话,而且没有一句提到文件。

这张图对应的源码:AgentHarness 那一步在 agent-harness.ts:540message_end 立即写)或 agent-harness.ts:512-536(其余变更排队到 turn_end 统一 flush);Session 的三步在 session.ts:219-227;storage 的写入在 jsonl-storage.ts:278-287;最后的 Result 检查在 repo-utils.ts:24-30。把 T 换成 InMemorySessionStorage,只有最后两步消失,前面完全一样——这就是抽象换来的东西。

与官方文档的出入

5.5 已经逐条核对过 session-format.md 并列出三处出入(retainedTail、缺失的两种 entry、两处 API 签名),这里不重复。站在「数据与抽象」的角度只补一条解释:这些出入几乎都能由文档自己的定位推出来。

文档的 Source Files 一节(session-format.md:29-36)明确写出它对照的源码是 packages/coding-agent/src/core/session-manager.ts 等四个文件,没有一个来自 packages/agent/src/harness/;文档后半段的 API 清单(session-format.md:386-439)也只列 SessionManager 的方法,本章讲的 Session/SessionStorage/SessionRepo 一个都没出现。于是:文档的 Entry Types 一节(session-format.md:187-305)列 9 种 entry,恰好是 CLI 侧 SessionEntry 联合的 9 种(session-manager.ts:144-153),而不是 harness 的 11 种;唯一「越界」的是 retainedTailsession-format.md:245327342),它描述的是 harness 的 defaultContextEntryTransformsession.ts:72-77)。

结论:把 session-format.md 当作 CLI 侧格式的权威说明是准确的;当作两套实现的公共规范来读,会漏掉 leafactive_tools_change 与 header 的 metadata 字段(jsonl-storage.ts:22)——而这三样恰恰是 harness JSONL 文件里真实会出现的行。

实践任务

🛠 实践任务手工构造一个 session.jsonl,用 Pi 的真实 storage 读出来

目标:不依赖任何 API Key,亲手写出一个合法的会话文件(含一个分支),用 JsonlSessionStorage + Session 把它加载出来,观察 entry 树、当前分支与上下文投影;再调一次 moveTo,亲眼看见 leaf entry 被追加到文件末尾。

为什么无需 Key:整条链路只碰文件系统。JsonlSessionStorage.openjsonl-storage.ts:212-215)、Session.buildContextsession.ts:188-190)都不接触 Provider(模型服务提供方)。

步骤 1 · 建目录与数据文件。在任意位置建一个空目录(例如 /tmp/pi-session-demo),在其中创建 demo.jsonl,写入下面 5 行——每行必须是完整的一行 JSON,不能折行:

{"type":"session","version":3,"id":"01930000-0000-7000-8000-0000000000aa","timestamp":"2026-07-30T10:00:00.000Z","cwd":"/tmp/pi-session-demo"}
{"type":"model_change","id":"aaaa0001","parentId":null,"timestamp":"2026-07-30T10:00:00.100Z","provider":"anthropic","modelId":"claude-sonnet-4"}
{"type":"message","id":"aaaa0002","parentId":"aaaa0001","timestamp":"2026-07-30T10:00:01.000Z","message":{"role":"user","content":"first question","timestamp":1785000001000}}
{"type":"message","id":"aaaa0003","parentId":"aaaa0002","timestamp":"2026-07-30T10:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"answer A"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4","usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785000002000}}
{"type":"message","id":"aaaa0004","parentId":"aaaa0002","timestamp":"2026-07-30T10:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"answer B"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4","usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785000003000}}

注意最后两行的 parentId 都是 aaaa0002——它们是兄弟,这就是图 6.5-2 里那个分支。

步骤 2 · 写一个只读脚本 inspect.ts(放在同一个目录里;把 PI 改成你本地 pi 仓库的绝对路径):

ts
const PI = "/绝对路径/到/pi";
const { NodeExecutionEnv } = await import(`${PI}/packages/agent/src/harness/env/nodejs.ts`);
const { JsonlSessionStorage } = await import(`${PI}/packages/agent/src/harness/session/jsonl-storage.ts`);
const { Session } = await import(`${PI}/packages/agent/src/harness/session/session.ts`);

const env = new NodeExecutionEnv({ cwd: process.cwd() });
const storage = await JsonlSessionStorage.open(env, `${process.cwd()}/demo.jsonl`);
const session = new Session(storage);

console.log("leafId:", await session.getLeafId());
console.log("--- all entries ---");
for (const e of await session.getEntries()) console.log(" ", e.type, e.id, "<-", e.parentId);
console.log("--- current branch ---");
for (const e of await session.getBranch()) console.log(" ", e.type, e.id);
const ctx = await session.buildContext();
console.log("context roles:", ctx.messages.map((m: any) => m.role));
console.log("model:", ctx.model, "thinkingLevel:", ctx.thinkingLevel);
console.log("stats:", await session.getSessionStats());

步骤 3 · 运行(在 demo 目录下执行,--tsconfig 让 tsx 使用 pi 仓库根的 paths 映射,把 @earendil-works/pi-ai 指向源码):

<pi 仓库根>/node_modules/.bin/tsx --tsconfig <pi 仓库根>/tsconfig.json inspect.ts

预期现象(本书在 pi@c13ffe18 上真实运行所得,原样粘贴):

leafId: aaaa0004
--- all entries ---
  model_change aaaa0001 <- null
  message aaaa0002 <- aaaa0001
  message aaaa0003 <- aaaa0002
  message aaaa0004 <- aaaa0002
--- current branch ---
  model_change aaaa0001
  message aaaa0002
  message aaaa0004
context roles: [ 'user', 'assistant' ]
model: { provider: 'anthropic', modelId: 'claude-sonnet-4' } thinkingLevel: off
stats: {
  messageCount: 3,
  cachedTokens: 0,
  uncachedTokens: 20,
  totalTokens: 30,
  costTotal: 0
}

三处值得琢磨:① 文件里有 3 条 message,但当前分支只有 2 条——aaaa0003 不在路径上;② context roles 只有两项,因为 model_change 不投影成消息(session.ts:103-136);③ messageCount: 3 统计的是**全部** entry 而不是当前分支(jsonl-storage.ts:314-316)——统计口径和上下文口径不是一回事。

步骤 4 · 移动叶子。再写一个 move.ts,前 7 行与 inspect.ts 相同,之后改为:

await session.moveTo("aaaa0003");
console.log("leafId after moveTo:", await session.getLeafId());
console.log("branch:", (await session.getBranch()).map((e: any) => e.id));

用同样的命令跑 move.ts,本书实测输出:

leafId after moveTo: aaaa0003
branch: [ 'aaaa0001', 'aaaa0002', 'aaaa0003' ]

然后看 demo.jsonl 的最后一行,会多出一条 leaf entry(id 是随机的,你的会不一样):

{"type":"leaf","id":"0e0b5f99","parentId":"aaaa0004","timestamp":"…","targetId":"aaaa0003"}

如何判断成功:重新跑一次 inspect.tsleafId 变成 aaaa0003、当前分支变成 aaaa0001 → aaaa0002 → aaaa0003,而 all entries 里多了一条 leaf。你能解释这个结果是由 leafIdAfterEntryjsonl-storage.ts:134-136)在加载时逐行重放得到的,而不是从某个「当前指针」字段读出来的——那就说明你真的读懂了这套设计。

常见错误:① 忘了 --tsconfig,会看到 ERR_MODULE_NOT_FOUND … @earendil-works/pi-ai/dist/index.js(本书实测),因为 pi-ai 的 dist 尚未构建,必须靠 tsconfig 的 paths 映射到源码;② header 的 version 不是 3,会抛 Invalid JSONL session file …: unsupported session versionjsonl-storage.ts:77)——harness 不做版本迁移;③ 某行缺 timestampid,会抛 line N is missing timestamp 一类错误(jsonl-storage.ts:120-127)——注意这里与 CLI 的 loadEntriesFromFile 不同,harness 是**整个文件失败**而不是跳过坏行;④ parentId 指向一个不存在的 id,加载时不报错,但 getBranch() 会抛 Entry … not foundjsonl-storage.ts:365)。

对应源码位置packages/agent/src/harness/session/jsonl-storage.ts:162-185(加载与重放)、:212-215(open)、:278-287(appendEntry)、:350-369(getPathToRootOrCompaction)、packages/agent/src/harness/session/session.ts:188-190(buildContext)、:338-358(moveTo)。

补充验证:想确认这些行为不是本书杜撰,在 pi 仓库根执行 npm test --workspace=@earendil-works/pi-agent-core -- test/harness/session.test.ts test/harness/storage.test.ts test/harness/repo.test.ts。本书在锁定 commit 上实测:3 个测试文件、56 个用例全部通过,无需任何 API Key。

本章小结

  • Session 存储被拆成三层:SessionStorage(存储后端契约,12 个方法全异步,只有 append 没有 update/delete)、Session(领域层,唯一构造 entry 的地方)、SessionRepo(管一堆会话:create/open/list/delete/fork)。
  • 一行 entry = 公共四字段(type/id/parentId/timestamp)+ 各类型自己的字段。harness 侧共 11 种 entry,比 CLI 侧多 active_tools_changeleafcompaction 多一个 retainedTail 让它成为自包含检查点。
  • 树只靠两件事运转:写入侧 leafIdAfterEntry 决定新叶子,读取侧 getPathToRootOrCompaction 沿 parentId 上溯并在压缩点提前停止。分支 = moveTo 移动叶子,历史永不改写。
  • 三个后端:JSONL(一行一次 appendFile,依赖被窄化到四个方法的 FileSystem 能力接口)、内存(测试与嵌入场景,行为由参数化测试与 JSONL 对齐)、SQLite(独立包 pi-storage-sqlite-node,本 commit 下无生产使用者)。
  • coding-agent 没有使用这套抽象(grep 可复核),它有一套同步的 SessionManager。两者磁盘布局相似但不保证互读。
  • 关键术语:存储后端(SessionStorage)、领域层(Session)、仓库层(SessionRepo)、entry(会话条目)、leaf(当前叶子指针)、branch(分支)、append-only(只追加)、能力接口(capability interface)。
  • 关键源码索引packages/agent/src/harness/types.tsSessionTreeEntryBase:375SessionTreeEntry:453SessionStorage:498SessionRepo:528)、packages/agent/src/harness/session/session.tsappendMessage:219moveTo:338buildSessionContext:138)、jsonl-storage.tsleafIdAfterEntry:134loadJsonlStorage:162appendEntry:278getPathToRootOrCompaction:350)、jsonl-repo.tsencodeCwd:34fork:134)、memory-storage.ts:43repo-utils.ts:32packages/storage/sqlite-node/src/sqlite/repo.ts:43;测试 packages/agent/test/harness/session.test.tsstorage.test.tsrepo.test.ts
  • 自测问题:① SessionStorage 里为什么没有 updateEntry?这对「历史可否被改写」意味着什么?② 一条 leaf entry 的 parentIdtargetId 分别指向谁?为什么必须是两个不同的指针?③ getPathToRootOrCompaction 在什么情况下会在 compaction 处立刻停止、什么情况下会继续往上走?④ 把 JsonlSessionStorage 换成 InMemorySessionStorageSession 的代码需要改几行?
  • 下一章6.6 Context 构造与 Compaction——本章反复出现的 compaction entry 到底是怎么生成的、摘要由谁写。
  • 尚未展开AgentHarness 如何编排这些写入(pendingSessionWrites 队列与 save_point 事件)属于 6.3 pi-agent-core:Agent 与循环6.11 SDK:把 Pi 当作库使用 的范围;SQLite 后端的 materialized 表与迁移机制、以及 custom entry 的投影器(entryProjectors)如何被扩展使用,留给 7.1 Extension 系统

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)