pi SQLite 会话后端深度解析:node:sqlite 适配器、SqliteSessionRepository 与 FTS 会话搜索
本文围绕 pi 仓库中的 @earendil-works/pi-session-backend-sqlite-node 包展开,完整讲解该 SQLite 会话后端的 node:sqlite 适配器、SqliteSessionRepository 会话仓库、数据库迁移与物化视图、可选 FTS5 全文搜索的实现细节与接入方式。读完你可以掌握:如何在 pi agent 体系中以 SQLite 单文件数据库持久化 agent 会话(创建、追加、分叉、列表、删除),以及会话跨库搜索的触发时机与同步机制,并理解其背后的写者租约(writer lease)、PRAGMA 配置与迁移管理设计。
包定位:pi 会话层的 Node SQLite 实现
@earendil-works/pi-session-backend-sqlite-node 是 @earendil-works/pi-agent-core 会话模型的 Node 端 SQLite 后端,它提供四样东西:node:sqlite 适配器(SqliteDatabase 实现)、SQLite 会话仓库、迁移(migrations)、物化视图,以及可选的 FTS 搜索(见 README)。
从 package.json 可以看到关键约束与依赖:
- 运行环境要求
"engines": { "node": ">=22.19.0" },因为适配器直接依赖 Node 内置的node:sqlite(DatabaseSync)模块,无第三方 SQLite 驱动; - 依赖
@earendil-works/pi-agent-core(会话契约:SessionRepo、Session、SessionStorage、Entry、LaneRecord等)与@earendil-works/pi-ai(uuidv7会话 ID 生成); - 包名经历了演进:从源码与 CHANGELOG 可确认,它在 0.84.0 版本从
@earendil-works/pi-storage-sqlite-node更名为现名,同时用 v4 lane-basedSessionRepo契约替换了旧版 schema 与仓库,且旧工作数据库不做迁移——这是使用该后端时必须注意的前提。
快速上手:仓库 + 搜索 + 会话生命周期
README 给出的最小可用示例如下,这里结合源码补全了 options 的真实字段:
import { createNodeSqliteFactory, SqliteSessionRepository, createSqliteSessionSearch } from "@earendil-works/pi-session-backend-sqlite-node";
import type { FileSystem } from "@earendil-works/pi-agent-core";
// env 只需要 pi-agent-core FileSystem 的三个能力(见 types.ts 中 SqliteSessionRepositoryEnv 的定义)
const repository = new SqliteSessionRepository({
env: { absolutePath, createDir, exists } as Pick<FileSystem, "absolutePath" | "createDir" | "exists">,
sqlite: createNodeSqliteFactory(), // 默认的 node:sqlite 工厂
databasePath: "~/.pi/sessions.db", // 支持相对路径,仓库内部会做绝对路径解析并自动创建父目录
writerLease: { ttlMs: 30_000, heartbeatIntervalMs: 10_000 }, // 可选,默认即 30s / 10s
});
await using session = await repository.create({ cwd: process.cwd() });
await session.appendMessage(message);
const search = createSqliteSessionSearch({
env,
sqlite: createNodeSqliteFactory(),
databasePath: "~/.pi/sessions.db", // 与仓库指向同一个“规范数据库”
});
const hits = [];
for await (const hit of search.search("needle")) hits.push(hit);
// hit: { sessionId, entryId, metadata, timestamp, score }
两个设计要点直接来自 README 并被源码印证:
- 仓库惰性持有唯一共享数据库连接:在 repo.ts 中,
getDatabase()只在首次被调用时执行openDatabase()(解析绝对路径 →env.createDir建目录 →sqlite.open→ 配置 PRAGMA → 应用迁移),之后所有create/open/list/delete/fork操作复用同一连接。 - 搜索是独立服务,仓库不暴露
search():createSqliteSessionSearch返回的是实现了 pi-agent-coreSessionSearch契约的独立对象(见 search-backend.ts),它自行打开同一规范数据库连接完成查询,查询结束即关闭。
仓库实现 AsyncDisposable([Symbol.asyncDispose] 中调用 close()),因此 await using 结束时会自动排空操作队列、释放所有活跃会话的写者租约并关闭数据库连接。
node:sqlite 适配器:SqliteDatabase 抽象与事务语义
包入口 src/index.ts 实现了 node:sqlite 的封装层,导出两个工厂函数:
createNodeSqliteFactory():返回SqliteDatabaseFactory,其open(path)内部new DatabaseSync(path)并包装为SqliteDatabase;wrapNodeSqliteDatabase(db):如果你已经在别处打开了DatabaseSync(例如为了测试或共享连接),可直接包装复用。
SqliteDatabase 接口(定义于 types.ts)只有四个能力:exec、prepare、transaction、close。其中 transaction<T>(fn) 的语义值得注意,源码实现是:
transaction<T>(fn: () => T): T {
sql`BEGIN IMMEDIATE`.exec(this);
try {
const result = fn();
if (isAsyncResult(result)) {
throw new TypeError("SQLite transaction callbacks must be synchronous");
}
sql`COMMIT`.exec(this);
return result;
} catch (error) {
try { sql`ROLLBACK`.exec(this); } catch { /* 忽略回滚错误以保留原始错误 */ }
throw error;
}
}
即:事务必须是同步回调——使用 BEGIN IMMEDIATE 抢占写锁,回调返回 Promise 会直接抛 TypeError,异常时回滚并保留原始错误。这解释了为什么仓库的所有写路径(追加条目、写记录、写 facts)都包在一个同步 db.transaction 内完成,而跨步骤的并发控制交给外层 SerialOperationQueue(一个 Promise 链串行队列)实现。
语句层 NodeSqliteStatement 支持 run/get/all/iterate 四个方法,并自动识别“命名参数对象”(普通对象作为第一个参数)与位置参数两种绑定方式;run 的返回被规整为 { changes, lastInsertRowid },对 BigInt 型 rowid 做了 Number 转换。
此外,0.84.1 版本加入了可组合的参数化 sql 模板标签(见 CHANGELOG 与 sql.ts),源码中大量使用它编写查询,例如 PRAGMA 配置与迁移记录插入:
sql`PRAGMA journal_mode=WAL`.exec(db);
sql`INSERT INTO migrations (id, applied_at) VALUES (${migration.id}, ${new Date().toISOString()})`.run(db);
数据库初始化:PRAGMA 配置与迁移管理
每次打开数据库连接(仓库和搜索服务各自独立)都会先执行统一的 PRAGMA 配置(configureSqliteDatabase,在 repo.ts 与 search-backend.ts 中各有一份相同实现):
PRAGMA journal_mode=WAL; -- 写前日志模式,读不阻塞写
PRAGMA synchronous=FULL; -- 完整同步,优先数据完整性
PRAGMA busy_timeout=5000; -- 库被锁时最多等待 5 秒
迁移管理在 migrations.ts 中:先确保 migrations(id, applied_at) 表存在,然后按 loadMigrations() 返回的列表逐一检查是否已应用,未应用的在单个事务内执行 SQL 并写入记录。当前仓库内置唯一迁移 001_initial.sql。
逻辑 schema 与“物化视图”
001 初始迁移定义了整套会话存储的表结构,可以按职责分组理解:
- 会话目录:
sessions(id, created_at, cwd, parent_session_id, metadata),WITHOUT ROWID,配(created_at DESC)与(cwd, created_at DESC)两个索引——后者正是list({ cwd })按目录过滤的高效路径; - 规范条目:
entries(session_id, seq, id, parent_id, type, timestamp, payload),seq每会话唯一、id每会话唯一,payload是 JSON; - 序号与统计:
session_sequences(next_seq)提供每会话单调序列分配,session_stats(message_count, cached_tokens, uncached_tokens, total_tokens, cost_total)是 README 所称“物化统计视图”的落点; - 分支缓存:
branch_entries是派生表(迁移文件注释明确写道:“entries 中的父链接仍是规范的;此缓存只为了让分支扫描变快”),另有branch_tips记录各分支 tip,二者共同构成活跃分支的物化视图,损坏时可用repository.repairBranchCache(metadata)从规范父链接重建; - 车道与记录:
lanes(session_id, lane, leaf_id, open_operation_id)维护每条 lane 的叶节点;records存储 lane 上的运行记录(操作开始/结束、用量等),lane_moves记录 lane 叶节点移动历史; - 事实:
facts(session_id, seq, kind, key, value)以追加方式存储name/label等全局事实,最新一条生效(getName/setName/getLabel/setLabel即读写此表); - 写者租约:
writer_leases(session_id, owner_id, fence, expires_at_ms),fence字段在迁移注释中被说明为“防止过期属主在新属主接管后继续写入”的屏障。
仓库对 entries 的 payload 有严格的类型解码(repo.ts 中 decodeEntry),支持 message、model_change、thinking_level_change、active_tools_change、compaction、branch_summary、custom 七类条目;任何解码失败都会包装为带 invalid_entry 代码的 SessionError 抛出,而不是静默吞掉。
写者租约:多进程安全写入
SQLite 文件可被多个进程并发打开,pi 用带围栏(fence)的写者租约保证同一会话同一时刻只有一个有效写者:
SqliteSessionRepositoryOptions.writerLease提供两个可调参数(repo.ts):ttlMs:无成功心跳后多久允许其他写者接管,默认 30 秒;heartbeatIntervalMs:空闲心跳间隔,默认 10 秒,构造时会校验其为正数且必须小于ttlMs,否则抛RangeError。
- 打开/创建会话时通过
claimWriterLease抢占租约;已有活跃写者则抛already has an active writer的SessionError; - 每次写入在事务内先
renewWriterLease续租并校验所有权,续租失败立即置为writer lease was lost错误并停止心跳,之后所有写入直接失败; - 心跳定时器
unref(),不会阻止进程退出;单次心跳失败会被静默重试,因为“每次写入都会事务性地再验证所有权”(源码注释原话)。
这套机制使得 open/create 返回的 Session 是“带独占写权限”的对象:同进程内重复 open 同一会话会复用已有 storage,跨进程则会因租约被拒。
会话生命周期:create / open / list / fork / delete
create({ cwd, id?, parentSessionId?, metadata? }):未指定id时生成uuidv7();整个创建过程在一个事务内完成——插入 session 行、初始化序列、统计、初始 lane,并抢占写者租约,返回即处于“已持有写锁”状态;open(metadata):按元数据取回已有会话并抢占租约;list({ cwd? }):只读目录查询,源码注释明确它“不获取或续租任何 per-session 写者租约”,因此即使有会话正被活跃写者占用也能安全枚举(这是 0.84.0 修复的行为,见 CHANGELOG);fork(source, options):支持两种范围——scope: "tree"全量复制条目、lanes 与分支 tip;否则基于mainlane 按entryId定位(目标必须是message条目)并在at/before位置截断分叉,同时拷贝最新的 name 与相关 label 事实、初始化消息计数;分出的新会话继承parentSessionId(可用options.parentSessionId覆盖);delete(metadata):单事务内先释放/删除该会话的所有活动 storage,再依次清理分支缓存、facts、lanes、records、entries、writer leases、stats、sequences 与 session 行;repairBranchCache(metadata):先释放该会话的活跃 storage,再在事务内重建分支缓存,用于分支物化视图与规范数据不一致时的修复。
仓库顶层还有自己的 SerialOperationQueue 串行队列,create/open/list/delete/fork 等仓库级操作互相排队执行,避免同一连接上的元数据竞争。
可选 FTS 搜索:惰性建表、触发器同步与一次性重建
createSqliteSessionSearch 是独立于仓库的搜索服务,其核心行为与 README 描述一一对应,实现见 search-backend.ts:
-
惰性创建 FTS 表:只有第一次收到非空白查询时,
ensureSearchSchema才执行建表 DDL。FTS5 虚拟表以外部内容表形式挂载在entries.payload上:CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( payload, content = 'entries', content_rowid = 'rowid', tokenize = 'trigram remove_diacritics 1' );采用
trigram分词器(配合去重音),意味着搜索是子串级匹配而非词级分词,对中文、代码标识符等场景比较友好; -
一次性重建:若 FTS 表是首次创建但
entries已有数据,立即执行INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')从规范条目全量构建索引; -
触发器持续同步:同时创建
AFTER INSERT、AFTER DELETE、AFTER UPDATE OF payload三个触发器,把后续条目写入、删除、payload 更新实时同步进 FTS 索引,无需应用层维护; -
查询语义:
search(text, options)把查询词按短语匹配(内部对"做转义),可叠加options.entryTypes(条目类型过滤)、options.limit(默认不限)、options.signal(AbortSignal支持,迭代中检查取消);结果按bm25得分排序,逐条yield{ sessionId, entryId, metadata, timestamp, score }(SqliteSessionSearchHit在核心SessionSearchHit上增加了 SQLite 特有的metadata、timestamp、score字段)。
搜索每次调用自行打开、配置、迁移数据库并在 finally 中关闭连接,因此与仓库连接互不干扰;空白查询词、limit <= 0、entryTypes: [] 会直接返回空迭代。
验证与测试
该包的测试位于 test/ 目录,通过 vitest --run 执行,覆盖面与源码职责一一对应:
- conformance.test.ts:针对 pi-agent-core 会话契约的一致性测试,确认 SQLite 实现满足统一
SessionRepo行为; - repository.test.ts、migrations.test.ts:仓库生命周期与迁移应用;
- search.test.ts:FTS 惰性建表、重建与触发器同步;
- writer-leases.test.ts、branch-cache.test.ts、branch-query.test.ts:租约抢占/接管与分支物化视图。
小结
@earendil-works/pi-session-backend-sqlite-node 用零依赖的 node:sqlite 把 pi 的会话模型完整落到单文件 SQLite 上:SqliteDatabase 抽象屏蔽驱动差异并以同步事务保证原子性;SqliteSessionRepository 提供惰性共享连接、带围栏的写者租约、分支物化视图与 fork/repair 等高级操作;createSqliteSessionSearch 则以独立服务的方式提供基于 FTS5 trigram 的跨会话搜索,靠惰性建表、一次性重建与触发器保持索引与规范条目一致。接入时只需提供 env(绝对路径/建目录/存在性检查)、sqlite 工厂与 databasePath 三项配置,并注意其要求 Node 22.19+ 且 0.84 起不兼容旧版数据库这两条前提。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00