@springbrand/agent-runtime 0.2.0-alpha.68 → 0.2.0-alpha.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,353 +1,565 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
1
2
  import {
2
- SessionError,
3
- uuidv7,
4
- type AgentMessage,
3
+ prepareStorageCommit,
4
+ validateCommittedWrites,
5
+ resolveListReadOptions,
6
+ value,
7
+ branchTip,
8
+ setValue,
9
+ insertEntry,
10
+ insertUsage,
5
11
  type CompactionEntry,
12
+ type Storage,
13
+ type Write,
14
+ type CommitResult,
15
+ type Entry,
16
+ type EntryStructure,
17
+ type EntryScan,
18
+ type StorageBranchScan,
19
+ type UsageRow,
20
+ type UsageScan,
21
+ type Value,
22
+ type ValueList,
23
+ type StoredValue,
24
+ type ListElement,
25
+ type ListReadOptions,
26
+ type SessionStats,
6
27
  type SessionMetadata,
7
- type SessionTreeEntry,
8
- } from "@earendil-works/pi-agent-core";
9
- import type { SqlTaggedTemplate } from "agents/chat";
28
+ } from "@earendil-works/pi-agent-core/harness/session";
29
+ import type { Usage } from "@earendil-works/pi-ai";
30
+ import type { Context } from "@earendil-works/pi-agent-core/harness/context";
10
31
 
11
- type EntryRow = {
12
- id: string;
13
- body: string;
14
- };
32
+ /** Pi's Storage contract, with every physical commit confined to one DO transaction. */
33
+ export class CloudflareSessionStorage implements Storage {
34
+ private closed = false;
35
+ private readonly sql: SqlStorage;
36
+ readonly metadata: SessionMetadata;
15
37
 
16
- type LeafRow = {
17
- id: string;
18
- type: string;
19
- target_id: string | null;
20
- };
21
-
22
- function errorCause(error: unknown): Error {
23
- return error instanceof Error ? error : new Error(String(error));
24
- }
25
-
26
- function parseEntry(row: EntryRow): SessionTreeEntry {
27
- try {
28
- return JSON.parse(row.body) as SessionTreeEntry;
29
- } catch (error) {
30
- throw new SessionError(
31
- "invalid_entry",
32
- `Invalid SQLite session entry ${row.id}`,
33
- errorCause(error),
38
+ constructor(private readonly storage: DurableObjectStorage) {
39
+ this.sql = storage.sql;
40
+ const hasVersion =
41
+ this.sql
42
+ .exec(
43
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pi_storage_state'",
44
+ )
45
+ .toArray().length > 0;
46
+ const hasLegacy =
47
+ this.sql
48
+ .exec(
49
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pi_session_entries'",
50
+ )
51
+ .toArray().length > 0;
52
+ if (!hasVersion && hasLegacy) {
53
+ throw new Error(
54
+ "Pi session requires an explicit legacy-to-0.85 migration",
55
+ );
56
+ }
57
+ if (hasVersion) {
58
+ const version = this.sql
59
+ .exec<{
60
+ storage_version: number;
61
+ }>("SELECT storage_version FROM pi_storage_state WHERE singleton = 1")
62
+ .one().storage_version;
63
+ if (version !== 1)
64
+ throw new Error(
65
+ `Unsupported Cloudflare Pi storage version: ${version}`,
66
+ );
67
+ }
68
+ this.sql.exec(`
69
+ CREATE TABLE IF NOT EXISTS pi_session_message_submission (message_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL);
70
+ CREATE TABLE IF NOT EXISTS pi_entries (
71
+ id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, parent_id TEXT,
72
+ type TEXT NOT NULL, custom_type TEXT, timestamp INTEGER NOT NULL, body TEXT NOT NULL
73
+ );
74
+ CREATE TABLE IF NOT EXISTS pi_values (
75
+ namespace TEXT NOT NULL, key TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT NOT NULL,
76
+ PRIMARY KEY(namespace, key)
77
+ );
78
+ CREATE TABLE IF NOT EXISTS pi_lists (
79
+ namespace TEXT NOT NULL, key TEXT NOT NULL, seq INTEGER PRIMARY KEY, body TEXT NOT NULL
80
+ );
81
+ CREATE INDEX IF NOT EXISTS pi_lists_address ON pi_lists(namespace, key, seq);
82
+ CREATE TABLE IF NOT EXISTS pi_usage (id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, body TEXT NOT NULL);
83
+ CREATE TABLE IF NOT EXISTS pi_storage_state (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), next_seq INTEGER NOT NULL, storage_version INTEGER NOT NULL, session_id TEXT NOT NULL, created_at INTEGER NOT NULL, stats TEXT NOT NULL);
84
+ `);
85
+ this.sql.exec(
86
+ "INSERT OR IGNORE INTO pi_storage_state VALUES (1, 1, 1, ?, ?, ?)",
87
+ crypto.randomUUID(),
88
+ Date.now(),
89
+ JSON.stringify({
90
+ messageCount: 0,
91
+ usage: {
92
+ input: 0,
93
+ output: 0,
94
+ cacheRead: 0,
95
+ cacheWrite: 0,
96
+ totalTokens: 0,
97
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
98
+ },
99
+ }),
34
100
  );
101
+ const row = this.sql
102
+ .exec<{
103
+ storage_version: number;
104
+ session_id: string;
105
+ created_at: number;
106
+ }>(
107
+ "SELECT storage_version, session_id, created_at FROM pi_storage_state WHERE singleton = 1",
108
+ )
109
+ .one();
110
+ this.metadata = {
111
+ id: row.session_id,
112
+ createdAt: row.created_at,
113
+ storageVersion: row.storage_version,
114
+ };
35
115
  }
36
- }
37
116
 
38
- function sessionEntryTargetId(entry: SessionTreeEntry): string | null {
39
- return entry.type === "leaf" || entry.type === "label"
40
- ? entry.targetId
41
- : null;
42
- }
117
+ private assertOpen(context?: Context): void {
118
+ if (this.closed) throw new Error("Session storage is closed");
119
+ context?.abortSignal?.throwIfAborted();
120
+ }
43
121
 
44
- function getEntrySync(
45
- sql: SqlTaggedTemplate,
46
- id: string,
47
- ): SessionTreeEntry | undefined {
48
- const row = sql<EntryRow>`
49
- SELECT id, body
50
- FROM pi_session_entries
51
- WHERE id = ${id}
52
- `[0];
53
- return row ? parseEntry(row) : undefined;
54
- }
122
+ /** Synchronous boundary also used by the host's admission/settlement transaction. */
123
+ commitSync(writes: Write[], context?: Context): CommitResult {
124
+ this.assertOpen(context);
125
+ return this.storage.transactionSync(() => {
126
+ const firstSeq = this.sql
127
+ .exec<{
128
+ next_seq: number;
129
+ }>("SELECT next_seq FROM pi_storage_state WHERE singleton = 1")
130
+ .one().next_seq;
131
+ const prepared = prepareStorageCommit(writes, firstSeq, Date.now());
132
+ validateCommittedWrites(prepared.writes, firstSeq, {
133
+ hasEntryId: (id) =>
134
+ this.sql.exec("SELECT 1 FROM pi_entries WHERE id = ?", id).toArray()
135
+ .length > 0,
136
+ hasEntryOrUsageId: (id) =>
137
+ this.sql
138
+ .exec(
139
+ "SELECT 1 FROM pi_entries WHERE id = ? UNION ALL SELECT 1 FROM pi_usage WHERE id = ?",
140
+ id,
141
+ id,
142
+ )
143
+ .toArray().length > 0,
144
+ });
145
+ const stats = this.statsSync();
146
+ for (const write of prepared.writes) {
147
+ switch (write.kind) {
148
+ case "entry": {
149
+ const { kind: _kind, ...entry } = write;
150
+ if (entry.type === "message") stats.messageCount += 1;
151
+ this.sql.exec(
152
+ "INSERT INTO pi_entries VALUES (?, ?, ?, ?, ?, ?, ?)",
153
+ entry.id,
154
+ entry.seq,
155
+ entry.parentId,
156
+ entry.type,
157
+ entry.customType ?? null,
158
+ entry.timestamp,
159
+ JSON.stringify(entry),
160
+ );
161
+ break;
162
+ }
163
+ case "usage": {
164
+ const { kind: _kind, ...row } = write;
165
+ for (const key of [
166
+ "input",
167
+ "output",
168
+ "cacheRead",
169
+ "cacheWrite",
170
+ "totalTokens",
171
+ "cacheWrite1h",
172
+ "reasoning",
173
+ ] as const) {
174
+ if (row.usage[key] !== undefined)
175
+ stats.usage[key] = (stats.usage[key] ?? 0) + row.usage[key];
176
+ }
177
+ for (const key of [
178
+ "input",
179
+ "output",
180
+ "cacheRead",
181
+ "cacheWrite",
182
+ "total",
183
+ ] as const)
184
+ stats.usage.cost[key] += row.usage.cost[key];
185
+ this.sql.exec(
186
+ "INSERT INTO pi_usage VALUES (?, ?, ?)",
187
+ row.id,
188
+ row.seq,
189
+ JSON.stringify(row),
190
+ );
191
+ break;
192
+ }
193
+ case "value":
194
+ if (write.op === "delete")
195
+ this.sql.exec(
196
+ "DELETE FROM pi_values WHERE namespace = ? AND key = ?",
197
+ write.namespace,
198
+ write.key,
199
+ );
200
+ else
201
+ this.sql.exec(
202
+ "INSERT INTO pi_values VALUES (?, ?, ?, ?) ON CONFLICT(namespace, key) DO UPDATE SET seq = excluded.seq, body = excluded.body",
203
+ write.namespace,
204
+ write.key,
205
+ write.seq,
206
+ JSON.stringify(write.value),
207
+ );
208
+ break;
209
+ case "list":
210
+ if (write.op === "delete")
211
+ this.sql.exec(
212
+ "DELETE FROM pi_lists WHERE namespace = ? AND key = ?",
213
+ write.namespace,
214
+ write.key,
215
+ );
216
+ else
217
+ this.sql.exec(
218
+ "INSERT INTO pi_lists VALUES (?, ?, ?, ?)",
219
+ write.namespace,
220
+ write.key,
221
+ write.seq,
222
+ JSON.stringify(write.value),
223
+ );
224
+ break;
225
+ }
226
+ }
227
+ this.sql.exec(
228
+ "UPDATE pi_storage_state SET next_seq = ?, stats = ? WHERE singleton = 1",
229
+ firstSeq + writes.length,
230
+ JSON.stringify(stats),
231
+ );
232
+ return { ...prepared.result, stats };
233
+ });
234
+ }
55
235
 
56
- function getLeafIdSync(sql: SqlTaggedTemplate): string | null {
57
- const row = sql<LeafRow>`
58
- SELECT id, type, target_id
59
- FROM pi_session_entries
60
- ORDER BY seq DESC
61
- LIMIT 1
62
- `[0];
63
- if (!row) return null;
236
+ async commit(writes: Write[], context: Context): Promise<CommitResult> {
237
+ return this.commitSync(writes, context);
238
+ }
64
239
 
65
- const leafId = row.type === "leaf" ? row.target_id : row.id;
66
- if (leafId !== null && !getEntrySync(sql, leafId)) {
67
- throw new SessionError(
68
- "invalid_session",
69
- `Entry ${leafId} not found`,
70
- );
240
+ async getEntries(
241
+ ids: string[],
242
+ context: Context,
243
+ ): Promise<Map<string, Entry>> {
244
+ return this.getEntriesSync(ids, context);
71
245
  }
72
- return leafId;
73
- }
74
246
 
75
- function appendEntrySync(
76
- sql: SqlTaggedTemplate,
77
- entry: SessionTreeEntry,
78
- ): boolean {
79
- const before = sql<{ count: number }>`
80
- SELECT COUNT(*) AS count
81
- FROM pi_session_entries
82
- WHERE id = ${entry.id}
83
- `[0]?.count ?? 0;
84
- if (before > 0) return false;
247
+ getEntriesSync(ids: string[], context?: Context): Map<string, Entry> {
248
+ this.assertOpen(context);
249
+ const entries = new Map<string, Entry>();
250
+ for (const id of ids) {
251
+ const row = this.sql
252
+ .exec<{ body: string }>("SELECT body FROM pi_entries WHERE id = ?", id)
253
+ .toArray()[0];
254
+ if (row) entries.set(id, JSON.parse(row.body));
255
+ }
256
+ return entries;
257
+ }
85
258
 
86
- sql`
87
- INSERT INTO pi_session_entries (
88
- id, type, parent_id, target_id, body
89
- ) VALUES (
90
- ${entry.id},
91
- ${entry.type},
92
- ${entry.parentId},
93
- ${sessionEntryTargetId(entry)},
94
- ${JSON.stringify(entry)}
95
- )
96
- `;
97
- return true;
98
- }
259
+ async getValue<T>(
260
+ address: Value<T>,
261
+ context: Context,
262
+ ): Promise<StoredValue<T> | undefined> {
263
+ return this.getValueSync(address, context);
264
+ }
99
265
 
100
- /**
101
- * 给 Durable Object 的 `transactionSync` 调用方用的同步写原语。
102
- *
103
- * 必须是同步的:admission 走在 DO 的同步事务里,任何 await 都会破坏那次事务的原子性。
104
- */
105
- export function appendSessionMessageSync(
106
- sql: SqlTaggedTemplate,
107
- input: {
108
- id: string;
109
- message: AgentMessage;
110
- submissionId?: string;
111
- createdAt?: number;
112
- },
113
- ): boolean {
114
- const timestamp = new Date(
115
- input.createdAt ??
116
- ("timestamp" in input.message &&
117
- typeof input.message.timestamp === "number"
118
- ? input.message.timestamp
119
- : Date.now()),
120
- ).toISOString();
121
- const inserted = appendEntrySync(sql, {
122
- type: "message",
123
- id: input.id,
124
- parentId: getLeafIdSync(sql),
125
- timestamp,
126
- message: input.message,
127
- });
128
- if (inserted && input.submissionId) {
129
- sql`
130
- INSERT INTO pi_session_message_submission (
131
- message_id, submission_id
132
- ) VALUES (
133
- ${input.id}, ${input.submissionId}
266
+ getValueSync<T>(
267
+ address: Value<T>,
268
+ context?: Context,
269
+ ): StoredValue<T> | undefined {
270
+ this.assertOpen(context);
271
+ const row = this.sql
272
+ .exec<{
273
+ seq: number;
274
+ body: string;
275
+ }>(
276
+ "SELECT seq, body FROM pi_values WHERE namespace = ? AND key = ?",
277
+ address.namespace,
278
+ address.key,
134
279
  )
135
- `;
280
+ .toArray()[0];
281
+ return row
282
+ ? { address, seq: row.seq, value: JSON.parse(row.body) }
283
+ : undefined;
136
284
  }
137
- return inserted;
138
- }
139
285
 
140
- export function moveSessionLeafSync(
141
- sql: SqlTaggedTemplate,
142
- targetId: string | null,
143
- ): void {
144
- if (targetId !== null && !getEntrySync(sql, targetId)) {
145
- throw new SessionError("not_found", `Entry ${targetId} not found`);
146
- }
147
- const id = uuidv7();
148
- if (!appendEntrySync(sql, {
149
- type: "leaf",
150
- id,
151
- parentId: getLeafIdSync(sql),
152
- timestamp: new Date().toISOString(),
153
- targetId,
154
- })) {
155
- throw new SessionError(
156
- "storage",
157
- `Session entry ${id} already exists`,
158
- );
286
+ async scanValues<T>(
287
+ prefix: Value<T>,
288
+ context: Context,
289
+ ): Promise<StoredValue<T>[]> {
290
+ this.assertOpen(context);
291
+ return this.sql
292
+ .exec<{ key: string; seq: number; body: string }>(
293
+ "SELECT key, seq, body FROM pi_values WHERE namespace = ? AND substr(key, 1, length(?)) = ? ORDER BY key COLLATE BINARY",
294
+ prefix.namespace,
295
+ prefix.key,
296
+ prefix.key,
297
+ )
298
+ .toArray()
299
+ .map((row) => ({
300
+ address: value<T>(prefix.namespace, row.key),
301
+ seq: row.seq,
302
+ value: JSON.parse(row.body),
303
+ }));
159
304
  }
160
- }
161
305
 
162
- export function clearSessionSync(sql: SqlTaggedTemplate): void {
163
- sql`DELETE FROM pi_session_message_submission`;
164
- sql`DELETE FROM pi_session_entries`;
165
- }
306
+ async readList<T>(
307
+ address: ValueList<T>,
308
+ options: ListReadOptions | undefined,
309
+ context: Context,
310
+ ): Promise<ListElement<T>[]> {
311
+ this.assertOpen(context);
312
+ const resolved = resolveListReadOptions(options);
313
+ const descending = resolved.order === "desc";
314
+ const comparison = descending ? "<" : ">";
315
+ return this.sql
316
+ .exec<{ seq: number; body: string }>(
317
+ `SELECT seq, body FROM pi_lists WHERE namespace = ? AND key = ? AND (? IS NULL OR seq ${comparison} ?) ORDER BY seq ${descending ? "DESC" : "ASC"} LIMIT ?`,
318
+ address.namespace,
319
+ address.key,
320
+ resolved.cursor?.seq ?? null,
321
+ resolved.cursor?.seq ?? null,
322
+ resolved.limit,
323
+ )
324
+ .toArray()
325
+ .map((row) => ({ seq: row.seq, value: JSON.parse(row.body) }));
326
+ }
166
327
 
167
- /**
168
- * 回合事实源:这个 Durable Object 自己拥有的会话记录流。
169
- *
170
- * ## 为什么不再 implements pi 的 SessionStorage
171
- *
172
- * 我们只用到 branch / entry / appendCompaction 三件事,却曾为此实现一个十方法接口,
173
- * 并把「一个回合到底长什么样」的定义权交给了 pi 的 **harness 层** —— 而 `SessionStorage`
174
- * 住在 `dist/harness/types.d.ts`,是 pi 给本地 CLI 用的那一层,不是 pi 内核的必需品
175
- * (`AgentOptions` 里根本没有 session 字段)。它也是 pi 发版最勤的一层:
176
- * 0.81.0 已经把 `getPathToRoot` 改名成 `getPathToRootOrCompaction` 并新增两个必填方法,
177
- * `[Unreleased]` 又要把整个 `SessionStorage` 换成 `SessionRepository` + caller-owned
178
- * `SessionStore`。**实现别人的接口 = 别人改语义时编译器不会告诉我们。**
179
- *
180
- * 现在语义由我们定、写在我们的测试里(见 storage.test.ts 的差分对拍);pi 只作为
181
- * 纯函数被调用(`prepareCompaction` / `compact` / `buildSessionContext`),
182
- * 那些改签名编译器一定会报。
183
- *
184
- * ## 会话模型(读代码前先看这三条,否则会误改)
185
- *
186
- * 1. **leaf 是派生的,不是存的**:seq 最大的那一行;若它是 `leaf` 标记条目,则跟随它的
187
- * `targetId`。所以**追加任何 entry 都会自动成为新 leaf**,不需要额外「移动」。
188
- * 2. **回退 leaf 靠追加标记**:`moveSessionLeafSync` append 一条 `leaf` 类型条目指向目标,
189
- * 表本身始终 append-only,从不改写历史行。
190
- * 3. **`leaf` 标记永远不会成为任何条目的 parent**:新条目的 parentId 取自
191
- * `getLeafIdSync()`,那已经是解析过标记之后的真实条目 id。所以按 parentId 上溯时
192
- * 不会走进标记里。
193
- */
194
- export class PiTranscriptStore {
195
- constructor(
196
- private readonly sql: SqlTaggedTemplate,
197
- metadata?: SessionMetadata,
198
- ) {
199
- this.sql`
200
- CREATE TABLE IF NOT EXISTS pi_session_metadata (
201
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
202
- id TEXT NOT NULL,
203
- created_at TEXT NOT NULL
328
+ private branchRows(query: StorageBranchScan, structure: boolean) {
329
+ // Stops follow the requested traversal direction; filters must not hide a stop entry.
330
+ const oldestFirst = query.order === "oldestFirst";
331
+ const rows = this.sql
332
+ .exec<{
333
+ id: string;
334
+ parentId: string | null;
335
+ seq: number;
336
+ timestamp: number;
337
+ type: Entry["type"];
338
+ customType: string | null;
339
+ body?: string;
340
+ }>(
341
+ `
342
+ WITH RECURSIVE path AS (
343
+ SELECT id, parent_id, seq, timestamp, type, custom_type${structure ? "" : ", body"} FROM pi_entries WHERE id = ?
344
+ UNION ALL
345
+ SELECT e.id, e.parent_id, e.seq, e.timestamp, e.type, e.custom_type${structure ? "" : ", e.body"}
346
+ FROM pi_entries e JOIN path p ON e.id = p.parent_id
347
+ ) SELECT id, parent_id AS parentId, seq, timestamp, type, custom_type AS customType${structure ? "" : ", body"}
348
+ FROM path ORDER BY seq ${oldestFirst ? "ASC" : "DESC"}
349
+ `,
350
+ query.start,
204
351
  )
205
- `;
206
- this.sql`
207
- CREATE TABLE IF NOT EXISTS pi_session_entries (
208
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
209
- id TEXT NOT NULL UNIQUE,
210
- type TEXT NOT NULL,
211
- parent_id TEXT,
212
- target_id TEXT,
213
- body TEXT NOT NULL
352
+ .toArray();
353
+ if (!rows.length) throw new Error(`Unknown branch start: ${query.start}`);
354
+ const root = oldestFirst ? rows[0]! : rows.at(-1)!;
355
+ if (root.parentId !== null)
356
+ throw new Error("Corrupt branch: missing parent");
357
+ const stop = rows.findIndex(
358
+ (row) => row.id === query.stopAtId || row.type === query.stopAtType,
359
+ );
360
+ return (stop < 0 ? rows : rows.slice(0, stop + 1))
361
+ .filter(
362
+ (row) =>
363
+ (query.type === undefined || query.type === row.type) &&
364
+ (query.customType === undefined ||
365
+ query.customType === row.customType) &&
366
+ (query.cursor === undefined ||
367
+ (oldestFirst
368
+ ? row.seq > query.cursor.seq
369
+ : row.seq < query.cursor.seq)),
214
370
  )
215
- `;
216
- this.sql`
217
- CREATE INDEX IF NOT EXISTS pi_session_entries_type
218
- ON pi_session_entries(type)
219
- `;
220
- this.sql`
221
- CREATE INDEX IF NOT EXISTS pi_session_entries_target
222
- ON pi_session_entries(type, target_id, seq)
223
- `;
224
- this.sql`
225
- CREATE TABLE IF NOT EXISTS pi_session_message_submission (
226
- message_id TEXT PRIMARY KEY,
227
- submission_id TEXT NOT NULL
371
+ .slice(
372
+ 0,
373
+ query.limit === undefined ? undefined : Math.max(0, query.limit),
374
+ );
375
+ }
376
+
377
+ async scanBranch(
378
+ query: StorageBranchScan,
379
+ context: Context,
380
+ ): Promise<Entry[]> {
381
+ return this.scanBranchSync(query, context);
382
+ }
383
+
384
+ scanBranchSync(query: StorageBranchScan, context?: Context): Entry[] {
385
+ this.assertOpen(context);
386
+ return this.branchRows(query, false).map((row) => JSON.parse(row.body!));
387
+ }
388
+
389
+ async scanBranchStructure(
390
+ query: StorageBranchScan,
391
+ context: Context,
392
+ ): Promise<EntryStructure[]> {
393
+ this.assertOpen(context);
394
+ return this.branchRows(query, true).map(
395
+ ({ customType, body: _body, ...row }) => ({
396
+ ...row,
397
+ ...(customType === null ? {} : { customType }),
398
+ }),
399
+ );
400
+ }
401
+
402
+ async scanEntries(query: EntryScan, context: Context): Promise<Entry[]> {
403
+ this.assertOpen(context);
404
+ return this.sql
405
+ .exec<{ body: string }>(
406
+ `
407
+ SELECT body FROM pi_entries WHERE (? IS NULL OR type = ?) AND (? IS NULL OR custom_type = ?)
408
+ AND (? IS NULL OR seq >= ?) AND (? IS NULL OR seq <= ?) ORDER BY seq ${query.order === "desc" ? "DESC" : "ASC"} LIMIT ?`,
409
+ query.type ?? null,
410
+ query.type ?? null,
411
+ query.customType ?? null,
412
+ query.customType ?? null,
413
+ query.fromSeq ?? null,
414
+ query.fromSeq ?? null,
415
+ query.toSeq ?? null,
416
+ query.toSeq ?? null,
417
+ query.limit === undefined ? -1 : Math.max(0, Math.trunc(query.limit)),
228
418
  )
229
- `;
230
- this.sql`
231
- CREATE INDEX IF NOT EXISTS pi_session_message_submission_id
232
- ON pi_session_message_submission(submission_id)
233
- `;
234
- const initial = metadata ?? {
235
- id: uuidv7(),
236
- createdAt: new Date().toISOString(),
237
- };
238
- this.sql`
239
- INSERT OR IGNORE INTO pi_session_metadata (
240
- singleton, id, created_at
241
- ) VALUES (
242
- 1, ${initial.id}, ${initial.createdAt}
419
+ .toArray()
420
+ .map((row) => JSON.parse(row.body));
421
+ }
422
+
423
+ async scanUsage(query: UsageScan, context: Context): Promise<UsageRow[]> {
424
+ this.assertOpen(context);
425
+ return this.sql
426
+ .exec<{ body: string }>(
427
+ `
428
+ SELECT body FROM pi_usage WHERE (? IS NULL OR seq >= ?) AND (? IS NULL OR seq <= ?)
429
+ ORDER BY seq ${query.order === "desc" ? "DESC" : "ASC"} LIMIT ?`,
430
+ query.fromSeq ?? null,
431
+ query.fromSeq ?? null,
432
+ query.toSeq ?? null,
433
+ query.toSeq ?? null,
434
+ query.limit === undefined ? -1 : Math.max(0, Math.trunc(query.limit)),
243
435
  )
244
- `;
436
+ .toArray()
437
+ .map((row) => JSON.parse(row.body));
245
438
  }
246
439
 
247
- /**
248
- * 从当前 leaf 上溯到 root 的完整路径。
249
- *
250
- * 用一条递归 CTE 取全路径。旧实现是 while 循环里每个祖先打一次 SELECT ——
251
- * canonicalMessages / storedMessages 各调一次,等于每次刷 UI 都是 O(消息数) 次 SQL。
252
- * JOIN 落在 `id` 上(UNIQUE,有索引),不需要为 parent_id 另建索引。
253
- */
254
- branch(): SessionTreeEntry[] {
255
- const leafId = getLeafIdSync(this.sql);
256
- if (leafId === null) return [];
440
+ private statsSync(): SessionStats {
441
+ return JSON.parse(
442
+ this.sql
443
+ .exec<{
444
+ stats: string;
445
+ }>("SELECT stats FROM pi_storage_state WHERE singleton = 1")
446
+ .one().stats,
447
+ );
448
+ }
257
449
 
258
- const rows = this.sql<EntryRow>`
259
- WITH RECURSIVE path(id, body, parent_id) AS (
260
- SELECT id, body, parent_id
261
- FROM pi_session_entries
262
- WHERE id = ${leafId}
263
- UNION ALL
264
- SELECT e.id, e.body, e.parent_id
265
- FROM pi_session_entries e
266
- JOIN path p ON e.id = p.parent_id
267
- )
268
- SELECT id, body FROM path
269
- `;
270
- if (rows.length === 0) {
271
- throw new SessionError("not_found", `Entry ${leafId} not found`);
272
- }
273
- // CTE 从 leaf 往上产出,这里翻成 root→leaf 与旧的 unshift 顺序一致。
274
- const path = rows.map(parseEntry).reverse();
275
- // 断链检测:旧实现在父条目缺失时抛 invalid_session,而 CTE 遇到断点只会静默停止
276
- // 递归。顶端条目的 parentId 必须为空,否则说明中间有一条祖先不见了。
277
- const root = path[0];
278
- if (root?.parentId) {
279
- throw new SessionError(
280
- "invalid_session",
281
- `Entry ${root.parentId} not found`,
282
- );
283
- }
284
- return path;
450
+ async getStats(context: Context): Promise<SessionStats> {
451
+ this.assertOpen(context);
452
+ return this.statsSync();
285
453
  }
286
454
 
287
- entry(id: string): SessionTreeEntry | undefined {
288
- return getEntrySync(this.sql, id);
455
+ /** Host commits use this single branch; off-branch entries remain available by ID. */
456
+ branch(): Entry[] {
457
+ const tip = this.getValueSync(branchTip("main"))?.value;
458
+ return tip ? this.scanBranchSync({ start: tip, order: "oldestFirst" }) : [];
289
459
  }
290
460
 
291
- /**
292
- * 追加一条压缩条目,整对象写入。
293
- *
294
- * 旧路径是把 assembly 侧已经造好的完整 CompactionEntry 拆成 5 个位置参数喂给
295
- * pi 的 `Session.appendCompaction` —— pi 0.81 把它加到 7 个参数(usage / retainedTail)
296
- * 之后,多出来的两个会被静默丢掉,编译器不报。整对象写入让这类失败模式在结构上
297
- * 不存在:调用方给什么就落什么。
298
- *
299
- * id / parentId / timestamp 由这里补齐,语义与 pi 的 `appendTypedEntry` 一致
300
- * (parentId 取当前 leaf;追加后本条自动成为新 leaf)。
301
- */
302
- appendCompaction(
303
- entry: Omit<CompactionEntry, "id" | "parentId" | "timestamp">,
304
- ): string {
305
- const id = this.createEntryId();
306
- const appended = appendEntrySync(this.sql, {
307
- ...entry,
308
- id,
309
- parentId: getLeafIdSync(this.sql),
310
- timestamp: new Date().toISOString(),
461
+ entry(id: string): Entry | undefined {
462
+ return this.getEntriesSync([id]).get(id);
463
+ }
464
+
465
+ appendMessage(input: {
466
+ id: string;
467
+ message: AgentMessage;
468
+ submissionId?: string;
469
+ }): boolean {
470
+ return this.storage.transactionSync(() => {
471
+ if (this.entry(input.id)) return false;
472
+ const usage =
473
+ input.message.role === "assistant" ||
474
+ input.message.role === "toolResult"
475
+ ? input.message.usage
476
+ : undefined;
477
+ this.commitSync([
478
+ insertEntry({
479
+ id: input.id,
480
+ parentId: this.getValueSync(branchTip("main"))?.value ?? null,
481
+ type: "message",
482
+ message: input.message,
483
+ }),
484
+ setValue(branchTip("main"), input.id),
485
+ ...(usage
486
+ ? [
487
+ insertUsage({
488
+ id: crypto.randomUUID(),
489
+ entryId: input.id,
490
+ usage,
491
+ adjustment: false,
492
+ }),
493
+ ]
494
+ : []),
495
+ ]);
496
+ if (input.submissionId)
497
+ this.sql.exec(
498
+ "INSERT INTO pi_session_message_submission VALUES (?, ?)",
499
+ input.id,
500
+ input.submissionId,
501
+ );
502
+ return true;
311
503
  });
312
- if (!appended) {
313
- throw new SessionError("storage", `Session entry ${id} already exists`);
314
- }
504
+ }
505
+
506
+ moveBranchTip(id: string | null): void {
507
+ if (id !== null && !this.entry(id))
508
+ throw new Error(`Unknown branch target: ${id}`);
509
+ this.commitSync([setValue(branchTip("main"), id)]);
510
+ }
511
+
512
+ recordCompaction(
513
+ entry: Omit<CompactionEntry, "id" | "parentId" | "seq" | "timestamp"> | undefined,
514
+ usage: Usage | undefined = entry?.usage,
515
+ ): string {
516
+ const id = crypto.randomUUID();
517
+ const writes: Write[] = [];
518
+ if (entry) writes.push(
519
+ insertEntry({
520
+ ...entry, id,
521
+ parentId: this.getValueSync(branchTip("main"))?.value ?? null,
522
+ }),
523
+ setValue(branchTip("main"), id),
524
+ );
525
+ if (usage) writes.push(insertUsage({
526
+ id: entry ? crypto.randomUUID() : id,
527
+ ...(entry ? { entryId: id } : {}),
528
+ usage,
529
+ adjustment: false,
530
+ }));
531
+ this.commitSync(writes);
315
532
  return id;
316
533
  }
317
534
 
318
- /**
319
- * 按类型取条目。生产路径无消费者,但压缩相关测试靠它断言「压缩条目真的落库了」,
320
- * 是那条行为契约的观测口。
321
- */
322
- findEntries<TType extends SessionTreeEntry["type"]>(
323
- type: TType,
324
- ): Array<Extract<SessionTreeEntry, { type: TType }>> {
325
- return this.sql<EntryRow>`
326
- SELECT id, body
327
- FROM pi_session_entries
328
- WHERE type = ${type}
329
- ORDER BY seq ASC
330
- `.map(parseEntry) as Array<Extract<SessionTreeEntry, { type: TType }>>;
535
+ entries(): Entry[] {
536
+ return this.sql
537
+ .exec<{ body: string }>("SELECT body FROM pi_entries ORDER BY seq")
538
+ .toArray()
539
+ .map((row) => JSON.parse(row.body));
331
540
  }
332
541
 
333
- /** 全量条目(含 leaf 标记),按写入顺序。仅供测试观测整棵树用。 */
334
- entries(): SessionTreeEntry[] {
335
- return this.sql<EntryRow>`
336
- SELECT id, body
337
- FROM pi_session_entries
338
- ORDER BY seq ASC
339
- `.map(parseEntry);
542
+ clear(): void {
543
+ this.storage.transactionSync(() => {
544
+ this.sql.exec(
545
+ "DELETE FROM pi_session_message_submission; DELETE FROM pi_entries; DELETE FROM pi_values; DELETE FROM pi_lists; DELETE FROM pi_usage;",
546
+ );
547
+ const usage = {
548
+ input: 0,
549
+ output: 0,
550
+ cacheRead: 0,
551
+ cacheWrite: 0,
552
+ totalTokens: 0,
553
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
554
+ };
555
+ this.sql.exec(
556
+ "UPDATE pi_storage_state SET stats = ? WHERE singleton = 1",
557
+ JSON.stringify({ messageCount: 0, usage }),
558
+ );
559
+ });
340
560
  }
341
561
 
342
- /**
343
- * 短 id,与 pi 的 `createEntryId` 同规则(uuidv7 尾 8 位 + 碰撞重试)。
344
- * 必须保持一致:压缩条目的 firstKeptEntryId 引用的就是这种 id。
345
- */
346
- private createEntryId(): string {
347
- for (let attempt = 0; attempt < 100; attempt += 1) {
348
- const id = uuidv7().slice(-8);
349
- if (!getEntrySync(this.sql, id)) return id;
350
- }
351
- return uuidv7();
562
+ async close(_context?: Context): Promise<void> {
563
+ this.closed = true;
352
564
  }
353
565
  }