第二篇说过,session log 是 dsh 的唯一事实来源。那这条日志怎么落盘、崩了怎么办、又能从里面长出什么?这篇讲 packages/session/ 这个组。
一个组,四个家族
packages/session/README.md 把整个组拆成四族:
| 家族 | 内容 |
|---|---|
| durable storage | 持久化接缝 + 后端 + 检查点策略 |
| projections | 把已提交事件折叠成整体当前值,加冷读缓存 |
| titles | 会话标题 |
| telemetry | 遥测 |
注意后三族全是「派生」——标题、投影视图、遥测都不是独立存储的事实,而是从日志算出来的。
两个后端:JSONL 默认,SQLite 可选
持久化本身是一条能力接缝(ctx.sessionPersistence),出厂两个后端。README 的原话:「JSONL is the shipped default, SQLite an opt-in single-database backend.」
- JSONL 后端:默认,一行一个事件,天然只追加。加了 Zstandard 帧压缩控制体积。
- SQLite 后端:opt-in,单数据库。用
node:sqlite,schema 版本 19,事件按 delta-run 分块。碰到旧 schema 的态度是拒绝,不做迁移。
「拒绝旧 schema 不迁移」又是那个预发布姿态:正式发版前不为旧格式背兼容包袱。
崩溃的 turn 怎么处理
这是整篇最值得一看的细节。进程在 turn 进行到一半时挂了,日志里留下一个开着没收尾的 turn。持久化层的修复动作不是把这段删掉,而是给它补一条合成事件:
it closes the orphaned turn with a synthetic
turn/end { reason: { kind: 'interrupted' } }…interruptedis the oneTurnEndReasonno loop emits.
interrupted 这个结束原因,任何正常运行的循环都不会自己发出来——它只属于持久化层在冷会话上做的修复。也就是说,「被崩溃打断」这个事实本身也进了日志,而且是唯一来源。活的会话(还有人在用的)开着 turn 则直接拒绝修复,不动它。
格式层面同样强硬:读到不认识的事件类型,宁可拒绝也不跳过,抛一个专门的 SessionFormatUnsupportedError,和数据损坏错误分开。不知道就是不知道,别猜。
从日志长出来的三样东西
- 标题:
session-title四个包,一个确定性兜底加两个 LLM 提供方——取不出好标题时也有个能用的。 - 遥测:
session-telemetry加 OpenTelemetry 后端,三档模式:FULL、FEEDBACK_ONLY、DISABLED。 - 查询:
packages/session-query/提供统一的ctx.sessionQuery服务,SQLite 后端上建 FTS5 全文索引,配 5 个给模型用的查询工具,Web 端还有/export打包 ZIP 下载。
顺带厘清一个容易混的包:packages/storage/ 和会话没关系,它管工作区记录这类非会话数据。README 特意声明:「registers no tools, injects no prompts, and writes no session events, so the model and the agent loop never see it.」模型永远看不到它。
小结
「只追加 + 崩溃补关闭事件 + 一切派生自日志」三件事合起来,效果是任何时刻的会话状态都能从一份不可变的记录重建。第五篇的压缩锁、第七篇的 plan 状态,全都站在这块地基上。