@morlay/session-rdb 0.0.21-alpha.7 → 0.0.21-alpha.9

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.
Files changed (45) hide show
  1. package/README.md +31 -12
  2. package/dist/artifact.d.mts +2 -101
  3. package/dist/artifact.mjs +2 -2
  4. package/dist/{branch-BjUR6wfe.mjs → branch-D4nVzQQM.mjs} +3 -3
  5. package/dist/deletion.d.mts +1 -1
  6. package/dist/deletion.mjs +1 -1
  7. package/dist/{dist-vIVO6bA-.mjs → dist-DHer1cql.mjs} +14 -7
  8. package/dist/export.d.mts +8 -0
  9. package/dist/export.mjs +76 -0
  10. package/dist/gc.d.mts +8 -0
  11. package/dist/gc.mjs +51 -0
  12. package/dist/import.d.mts +1 -1
  13. package/dist/import.mjs +1 -1
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +3 -3
  16. package/dist/{log-qCNkDjh5.mjs → log-BWVFYAh5.mjs} +39 -12
  17. package/dist/{backend-E0bmTXpz.d.mts → log-CUjKQbOR.d.mts} +222 -39
  18. package/dist/{sqlite-zcXVyMxr.mjs → sqlite-SCiSnwaz.mjs} +321 -259
  19. package/dist/{src-B9X8BKyh.mjs → src-BZ1tffd5.mjs} +342 -310
  20. package/dist/storage.d.mts +20 -7
  21. package/dist/storage.mjs +2 -2
  22. package/dist/testing.d.mts +1 -1
  23. package/dist/testing.mjs +48 -29
  24. package/dist/usage.d.mts +2 -0
  25. package/dist/usage.mjs +152 -0
  26. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +48 -0
  27. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +48 -0
  28. package/package.json +25 -30
  29. package/src/backend.ts +41 -17
  30. package/src/branch.ts +4 -4
  31. package/src/drizzle/postgres-v3.ts +2 -0
  32. package/src/drizzle/sqlite-v3.ts +2 -0
  33. package/src/entities/v3/event-usage.ts +8 -1
  34. package/src/entities/v3/index.ts +8 -0
  35. package/src/entities/v3/session-counts.ts +23 -0
  36. package/src/entities/v3/session-usage.ts +29 -0
  37. package/src/index.ts +230 -88
  38. package/src/log.ts +58 -17
  39. package/src/postgres.ts +248 -149
  40. package/src/rows.ts +2 -1
  41. package/src/schema.ts +4 -0
  42. package/src/sqlite.ts +263 -124
  43. package/src/storage-takeover/repository.ts +1 -4
  44. package/src/usage.ts +10 -6
  45. package/cordis.patch.yml +0 -13
@@ -1,7 +1,10 @@
1
- import { A as WriteGuard, O as postgresTableDefs, a as addActivityCount, c as emptyTotals, d as DEFAULT_BUSY_TIMEOUT_MS, f as EVENT_ENCODING, h as eventDimensions, i as COUNTED_EVENT_TYPES, k as toPostgresSchema, l as localDayKey, o as addTokenTotals, r as createStorageRepository, s as addTotals, t as SqliteBackend, u as registerSessionUsage } from "./sqlite-zcXVyMxr.mjs";
2
- import { _ as sessionInsertRow, b as toJsonlArtifact, g as sessionConflictRow, h as scanRows, m as rowToMeta, r as hasLegacyShape, u as repairReadView, x as usageRowOf, y as titleOfEventData } from "./log-qCNkDjh5.mjs";
3
- import { c as isLegacyVersion, o as adoptLegacyRows, s as convertLegacyRows, t as SessionBranchRdb } from "./branch-BjUR6wfe.mjs";
1
+ import { C as postgresTableDefs, T as WriteGuard, a as EVENT_ENCODING, c as eventDimensions, i as DEFAULT_BUSY_TIMEOUT_MS, r as createStorageRepository, t as SqliteBackend, w as toPostgresSchema } from "./sqlite-SCiSnwaz.mjs";
2
+ import { COUNTED_EVENT_TYPES, addActivityCount, addActivityTotals, addTokenTotals, addTotals, emptyTotals, localDayKey, registerSessionUsage } from "./usage.mjs";
3
+ import { S as usageRowOf, _ as sessionConflictRow, b as titleOfEventData, g as sealOwnEvents, h as scanRows, m as rowToMeta, r as needsShapeAdoption, u as repairReadView, v as sessionInsertRow, x as toJsonlArtifact } from "./log-BWVFYAh5.mjs";
4
+ import { c as isLegacyVersion, o as adoptLegacyRows, s as convertLegacyRows, t as SessionBranchRdb } from "./branch-D4nVzQQM.mjs";
4
5
  import { registerSessionImport } from "./import.mjs";
6
+ import { registerSessionExport } from "./export.mjs";
7
+ import { registerSessionGc } from "./gc.mjs";
5
8
  import z from "@deepseek-ai/schemastery";
6
9
  import { randomUUID } from "node:crypto";
7
10
  import { Pool } from "pg";
@@ -14,7 +17,6 @@ import { readdir } from "node:fs/promises";
14
17
  import { fileURLToPath } from "node:url";
15
18
  import { migrate } from "drizzle-orm/node-postgres/migrator";
16
19
  import { balanceRewindPrefix } from "@morlay/session-branch";
17
- import { strToU8, zip } from "fflate";
18
20
  import { SessionQueryEngine, SessionQueryError } from "@deepseek-ai/dsh-session-query";
19
21
  import { StorageError, storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
20
22
  import { Service } from "@deepseek-ai/cordis";
@@ -54,6 +56,15 @@ function emptyActivity() {
54
56
  toolCalls: 0
55
57
  };
56
58
  }
59
+ /** `t_session_counts` 的四项计数折成活动计数(pg 的 sum 回落成字符串)。 */
60
+ function activityTotalsOf(row) {
61
+ return {
62
+ turns: numeric(row["turns"]),
63
+ steps: numeric(row["steps"]),
64
+ userInputs: numeric(row["user_inputs"]),
65
+ toolCalls: numeric(row["tool_calls"])
66
+ };
67
+ }
57
68
  /** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
58
69
  const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
59
70
  var PostgresBackend = class PostgresBackend {
@@ -107,9 +118,7 @@ var PostgresBackend = class PostgresBackend {
107
118
  if (await this.readMeta(this.db, "schema_version") === "2") await this.baselineV2();
108
119
  }
109
120
  await migrate(this.db, { migrationsFolder: postgresMigrationsDir });
110
- await this.ensureEventCountsTable();
111
- await this.backfillEventCounts();
112
- await this.backfillEventUsage();
121
+ await this.backfillUsageTables();
113
122
  const storeId = await this.db.transaction(async (tx) => {
114
123
  await tx.insert(this.tables["t_persistence_state"]).values({
115
124
  fSingleton: 1,
@@ -170,7 +179,7 @@ var PostgresBackend = class PostgresBackend {
170
179
  getHead: (id) => this.getHead(tx, id),
171
180
  getSeedLength: (id) => this.getSeedLength(tx, id),
172
181
  updateSeedLength: (id, seedLength) => this.updateSeedLength(tx, id, seedLength),
173
- insertEvents: (events) => this.insertEvents(tx, events),
182
+ insertEvents: (events, usageRows) => this.insertEvents(tx, events, usageRows),
174
183
  insertBridges: (rows) => this.insertBridges(tx, rows),
175
184
  updateHead: (id, headEventId, headSequence) => this.updateHead(tx, id, headEventId, headSequence),
176
185
  bumpRevision: (id, lastEventAt) => this.bumpRevision(tx, id, lastEventAt),
@@ -207,14 +216,10 @@ var PostgresBackend = class PostgresBackend {
207
216
  await exec.update(this.tables["t_sessions"]).set({ fSeedLength: seedLength }).where(eq(this.tables["t_sessions"].fSessionId, id)).execute();
208
217
  }
209
218
  static INSERT_BATCH_ROWS = 1e3;
210
- async insertEvents(exec, events) {
219
+ async insertEvents(exec, events, usageRows) {
211
220
  if (events.length === 0) return;
212
221
  for (let i = 0; i < events.length; i += PostgresBackend.INSERT_BATCH_ROWS) await exec.insert(this.tables["t_events"]).values(events.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((event) => ({ ...event }))).execute();
213
- const usage = events.flatMap((event) => {
214
- const row = usageRowOf(event);
215
- return row === void 0 ? [] : [row];
216
- });
217
- if (usage.length > 0) await exec.insert(this.tables["t_event_usage"]).values(usage).onConflictDoNothing().execute();
222
+ if (usageRows.length > 0) for (let i = 0; i < usageRows.length; i += PostgresBackend.INSERT_BATCH_ROWS) await exec.insert(this.tables["t_event_usage"]).values(usageRows.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((row) => ({ ...row }))).onConflictDoNothing().execute();
218
223
  }
219
224
  async insertBridges(exec, rows) {
220
225
  if (rows.length === 0) return;
@@ -284,21 +289,41 @@ var PostgresBackend = class PostgresBackend {
284
289
  async vacuum() {
285
290
  await this.db.execute(sql`VACUUM ANALYZE`);
286
291
  }
287
- /** 一次性回填:旧库没写过 `t_event_usage`,建表后按事件行补一次(表非空即跳过)。 */
292
+ /** 派生统计表的回填:任一表为空即按事件表全量重算(幂等,迁移删表重建后自愈)。 */
293
+ async backfillUsageTables() {
294
+ await this.backfillEventUsage();
295
+ await this.backfillSessionUsage();
296
+ await this.backfillSessionCounts();
297
+ }
298
+ async tableIsEmpty(table) {
299
+ const existing = await this.db.execute(sql.raw(`SELECT count(*) AS n FROM ${table}`));
300
+ return Number(existing.rows[0]?.["n"] ?? 0) === 0;
301
+ }
302
+ /**
303
+ * 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。
304
+ * 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
305
+ */
288
306
  async backfillEventUsage() {
307
+ if (!await this.tableIsEmpty(this.qualifiedTable("t_event_usage"))) return;
289
308
  const tEventUsage = this.tables["t_event_usage"];
290
309
  const tEvents = this.tables["t_events"];
291
- const existing = await this.db.execute(sql`SELECT count(*) AS n FROM ${tEventUsage}`);
292
- if (Number(existing.rows[0]?.["n"] ?? 0) > 0) return;
310
+ const tSessionEvents = this.tables["t_session_events"];
311
+ const tSessions = this.tables["t_sessions"];
293
312
  await this.db.execute(sql`
294
313
  INSERT INTO ${tEventUsage} (
295
- f_event_id, f_created_at, f_provider, f_model,
314
+ f_event_id, f_created_at, f_day, f_provider, f_model, f_referenced, f_subagent,
296
315
  f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
297
316
  SELECT e.f_event_id, e.f_created_at,
317
+ to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
298
318
  coalesce(e.f_data::json #>> '{data,message,source,provider}',
299
319
  e.f_data::json #>> '{message,source,provider}'),
300
320
  coalesce(e.f_data::json #>> '{data,message,source,model}',
301
321
  e.f_data::json #>> '{message,source,model}'),
322
+ (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
323
+ WHERE rb.f_event_id = e.f_event_id))::int,
324
+ (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
325
+ JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
326
+ WHERE sb.f_event_id = e.f_event_id AND ss.f_origin = 'subagent'))::int,
302
327
  coalesce((e.f_data::json #>> '{data,usage,inputTokens}')::integer,
303
328
  (e.f_data::json #>> '{usage,inputTokens}')::integer, 0),
304
329
  coalesce((e.f_data::json #>> '{data,usage,outputTokens}')::integer,
@@ -314,6 +339,47 @@ var PostgresBackend = class PostgresBackend {
314
339
  AND (e.f_data::json #> '{data,usage}' IS NOT NULL
315
340
  OR e.f_data::json #> '{usage}' IS NOT NULL)
316
341
  ON CONFLICT DO NOTHING
342
+ `);
343
+ }
344
+ /** 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。 */
345
+ async backfillSessionUsage() {
346
+ if (!await this.tableIsEmpty(this.qualifiedTable("t_session_usage"))) return;
347
+ const tSessionUsage = this.tables["t_session_usage"];
348
+ const tSessionEvents = this.tables["t_session_events"];
349
+ const tEventUsage = this.tables["t_event_usage"];
350
+ await this.db.execute(sql`
351
+ INSERT INTO ${tSessionUsage} (
352
+ f_session_id, f_day, f_provider, f_model,
353
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
354
+ SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
355
+ sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
356
+ sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
357
+ FROM ${tSessionEvents} b
358
+ JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
359
+ GROUP BY 1, 2, 3, 4
360
+ ON CONFLICT DO NOTHING
361
+ `);
362
+ }
363
+ /** 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。 */
364
+ async backfillSessionCounts() {
365
+ if (!await this.tableIsEmpty(this.qualifiedTable("t_session_counts"))) return;
366
+ const tSessionCounts = this.tables["t_session_counts"];
367
+ const tSessionEvents = this.tables["t_session_events"];
368
+ const tEvents = this.tables["t_events"];
369
+ await this.db.execute(sql`
370
+ INSERT INTO ${tSessionCounts} (
371
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
372
+ SELECT b.f_session_id,
373
+ to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
374
+ sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
375
+ sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
376
+ sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
377
+ sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
378
+ FROM ${tSessionEvents} b
379
+ JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
380
+ WHERE e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
381
+ GROUP BY 1, 2
382
+ ON CONFLICT DO NOTHING
317
383
  `);
318
384
  }
319
385
  /** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
@@ -366,85 +432,78 @@ var PostgresBackend = class PostgresBackend {
366
432
  total: numeric(counted.rows[0]?.["n"] ?? 0)
367
433
  };
368
434
  }
435
+ /**
436
+ * 用量聚合:与 SQLite 侧同形——只读三张派生统计表(用量行给去重口径的总量与「天 × 模型」桶,
437
+ * 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
438
+ */
369
439
  async usageReport(sinceMs) {
370
- const usageSince = sinceMs === void 0 ? sql`` : sql` AND u.f_created_at >= ${sinceMs}`;
371
- const daySince = sinceMs === void 0 ? sql`` : sql` AND c.f_day >= ${localDayKey(sinceMs)}`;
440
+ const usageSince = sinceMs === void 0 ? sql`` : sql` AND f_created_at >= ${sinceMs}`;
441
+ const dayFilter = (column) => sinceMs === void 0 ? sql`` : sql` AND ${sql.raw(column)} >= ${localDayKey(sinceMs)}`;
372
442
  const tEventUsage = this.tables["t_event_usage"];
373
- const tSessionEvents = this.tables["t_session_events"];
443
+ const tSessionUsage = this.tables["t_session_usage"];
444
+ const tSessionCounts = this.tables["t_session_counts"];
374
445
  const tSessions = this.tables["t_sessions"];
375
- const eventCountsTable = this.qualifiedTable("t_event_counts");
376
- const subagentOfUsage = sql`EXISTS (SELECT 1 FROM ${tSessionEvents} sb
377
- JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
378
- WHERE sb.f_event_id = u.f_event_id AND ss.f_origin = 'subagent')`;
379
- const subagentOfCounts = sql`EXISTS (SELECT 1 FROM ${tSessionEvents} sb
380
- JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
381
- WHERE sb.f_session_id = c.f_session_id AND ss.f_origin = 'subagent')`;
382
- const hasUsageSession = (column) => sql`EXISTS (SELECT 1 FROM ${tSessionEvents} ub
383
- JOIN ${tEventUsage} uu ON uu.f_event_id = ub.f_event_id
384
- WHERE ub.f_session_id = ${sql.raw(column)})`;
385
- const tokenColumns = sql.raw(`sum(u.f_input_tokens) AS input_tokens,
386
- sum(u.f_output_tokens) AS output_tokens,
387
- sum(u.f_cache_read_tokens) AS cache_read_tokens,
388
- sum(u.f_reasoning_tokens) AS reasoning_tokens,
389
- sum(u.f_total_tokens) AS total_tokens`);
446
+ const tokenColumns = (alias) => sql.raw(`sum(${alias}.f_input_tokens) AS input_tokens,
447
+ sum(${alias}.f_output_tokens) AS output_tokens,
448
+ sum(${alias}.f_cache_read_tokens) AS cache_read_tokens,
449
+ sum(${alias}.f_reasoning_tokens) AS reasoning_tokens,
450
+ sum(${alias}.f_total_tokens) AS total_tokens`);
390
451
  const buckets = await this.db.execute(sql`
391
- SELECT to_char(to_timestamp(u.f_created_at / 1000.0), 'YYYY-MM-DD') AS day,
392
- u.f_provider AS provider,
393
- u.f_model AS model,
394
- ${subagentOfUsage} AS subagent,
395
- ${tokenColumns}
396
- FROM ${tEventUsage} u
397
- WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
452
+ SELECT a.f_day AS day,
453
+ a.f_provider AS provider,
454
+ a.f_model AS model,
455
+ (a.f_subagent = 1) AS subagent,
456
+ ${tokenColumns("a")}
457
+ FROM ${tEventUsage} a
458
+ WHERE a.f_referenced = 1${usageSince}
398
459
  GROUP BY 1, 2, 3, 4
399
460
  `);
400
461
  const tokenScope = await this.db.execute(sql`
401
- SELECT ${subagentOfUsage} AS subagent,
402
- ${tokenColumns}
403
- FROM ${tEventUsage} u
404
- WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
462
+ SELECT (a.f_subagent = 1) AS subagent,
463
+ ${tokenColumns("a")}
464
+ FROM ${tEventUsage} a
465
+ WHERE a.f_referenced = 1${usageSince}
405
466
  GROUP BY 1
406
467
  `);
407
468
  const activityScope = await this.db.execute(sql`
408
- SELECT ${subagentOfCounts} AS subagent,
409
- c.f_type AS f_type,
410
- sum(c.f_count) AS n
411
- FROM ${sql.raw(eventCountsTable)} c
469
+ SELECT (s.f_origin = 'subagent') AS subagent,
470
+ sum(c.f_turns) AS turns,
471
+ sum(c.f_steps) AS steps,
472
+ sum(c.f_user_inputs) AS user_inputs,
473
+ sum(c.f_tool_calls) AS tool_calls
474
+ FROM ${tSessionCounts} c
412
475
  JOIN ${tSessions} s ON s.f_session_id = c.f_session_id
413
- WHERE ${hasUsageSession("c.f_session_id")}${daySince}
414
- GROUP BY 1, 2
476
+ WHERE EXISTS (SELECT 1 FROM ${tSessionUsage} ub WHERE ub.f_session_id = c.f_session_id)${dayFilter("c.f_day")}
477
+ GROUP BY 1
415
478
  `);
416
479
  const tokenSessions = await this.db.execute(sql`
417
- SELECT b.f_session_id AS session_id,
480
+ SELECT u.f_session_id AS session_id,
418
481
  s.f_title AS title,
419
482
  (s.f_origin = 'subagent') AS subagent,
420
483
  (s.f_archived_at IS NOT NULL) AS archived,
421
- ${tokenColumns}
422
- FROM ${tSessionEvents} b
423
- JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
424
- JOIN ${tSessions} s ON s.f_session_id = b.f_session_id
425
- WHERE 1 = 1${usageSince}
426
- GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at
484
+ ${tokenColumns("u")}
485
+ FROM ${tSessionUsage} u
486
+ JOIN ${tSessions} s ON s.f_session_id = u.f_session_id
487
+ WHERE 1 = 1${dayFilter("u.f_day")}
488
+ GROUP BY u.f_session_id, s.f_title, s.f_origin, s.f_archived_at
427
489
  `);
428
490
  const activitySessions = await this.db.execute(sql`
429
491
  SELECT c.f_session_id AS session_id,
430
- c.f_type AS f_type,
431
- sum(c.f_count) AS n
432
- FROM ${sql.raw(eventCountsTable)} c
433
- WHERE ${hasUsageSession("c.f_session_id")}${daySince}
434
- GROUP BY c.f_session_id, c.f_type
492
+ sum(c.f_turns) AS turns,
493
+ sum(c.f_steps) AS steps,
494
+ sum(c.f_user_inputs) AS user_inputs,
495
+ sum(c.f_tool_calls) AS tool_calls
496
+ FROM ${tSessionCounts} c
497
+ WHERE 1 = 1${dayFilter("c.f_day")}
498
+ GROUP BY c.f_session_id
435
499
  `);
436
500
  const human = emptyTotals();
437
501
  const subagent = emptyTotals();
438
502
  for (const row of tokenScope.rows) addTokenTotals(row["subagent"] === true ? subagent : human, tokenTotalsOf(row));
439
- for (const row of activityScope.rows) addActivityCount(row["subagent"] === true ? subagent : human, String(row["f_type"]), numeric(row["n"]));
503
+ for (const row of activityScope.rows) addActivityTotals(row["subagent"] === true ? subagent : human, activityTotalsOf(row));
440
504
  const totals = addTotals(addTotals(emptyTotals(), human), subagent);
441
505
  const activityBySession = /* @__PURE__ */ new Map();
442
- for (const row of activitySessions.rows) {
443
- const key = String(row["session_id"]);
444
- const target = activityBySession.get(key) ?? emptyActivity();
445
- addActivityCount(target, String(row["f_type"]), numeric(row["n"]));
446
- activityBySession.set(key, target);
447
- }
506
+ for (const row of activitySessions.rows) activityBySession.set(String(row["session_id"]), activityTotalsOf(row));
448
507
  return {
449
508
  totals,
450
509
  subagent,
@@ -466,76 +525,100 @@ var PostgresBackend = class PostgresBackend {
466
525
  }))
467
526
  };
468
527
  }
469
- /** 表名限定到配置的 schema(派生表不进 drizzle schema / 迁移,建表幂等)。 */
528
+ /** 表名限定到配置的 schema。 */
470
529
  qualifiedTable(name) {
471
530
  const schema = this.options.schema ?? "public";
472
531
  return schema === "public" ? name : `"${schema}".${name}`;
473
532
  }
474
- async ensureEventCountsTable() {
475
- const table = this.qualifiedTable("t_event_counts");
476
- await this.db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${table} (
477
- f_id serial PRIMARY KEY,
478
- f_session_id text NOT NULL,
479
- f_day text NOT NULL,
480
- f_type text NOT NULL,
481
- f_count integer NOT NULL DEFAULT 0
482
- )`));
483
- await this.db.execute(sql.raw(`CREATE UNIQUE INDEX IF NOT EXISTS uq_event_counts_session_day_type ON ${table}(f_session_id, f_day, f_type)`));
484
- await this.db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS idx_event_counts_day ON ${table}(f_day)`));
485
- }
486
- /** 一次性回填活动计数(表为空时从事件表重算;幂等)。 */
487
- async backfillEventCounts() {
488
- const table = this.qualifiedTable("t_event_counts");
489
- const existing = await this.db.execute(sql.raw(`SELECT count(*) AS n FROM ${table}`));
490
- if (Number(existing.rows[0]?.["n"] ?? 0) > 0) return;
491
- const tSessionEvents = this.tables["t_session_events"];
492
- const tEvents = this.tables["t_events"];
493
- await this.db.execute(sql`
494
- INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
495
- SELECT ${tSessionEvents}.f_session_id,
496
- to_char(to_timestamp(${tEvents}.f_created_at / 1000.0), 'YYYY-MM-DD'),
497
- ${tEvents}.f_type,
498
- count(*)
499
- FROM ${tSessionEvents}
500
- JOIN ${tEvents} ON ${tEvents}.f_event_id = ${tSessionEvents}.f_event_id
501
- WHERE ${tEvents}.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
502
- GROUP BY 1, 2, 3
503
- ON CONFLICT DO NOTHING
504
- `);
533
+ /** token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。 */
534
+ async incrementSessionUsage(id, buckets) {
535
+ if (buckets.length === 0) return;
536
+ const tSessionUsage = this.tables["t_session_usage"];
537
+ for (const bucket of buckets) await this.db.execute(sql`
538
+ INSERT INTO ${tSessionUsage} (
539
+ f_session_id, f_day, f_provider, f_model,
540
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
541
+ VALUES (${id}, ${bucket.day}, ${bucket.provider ?? ""}, ${bucket.model ?? ""},
542
+ ${bucket.inputTokens}, ${bucket.outputTokens}, ${bucket.cacheReadTokens},
543
+ ${bucket.reasoningTokens}, ${bucket.totalTokens})
544
+ ON CONFLICT (f_session_id, f_day, f_provider, f_model) DO UPDATE SET
545
+ f_input_tokens = ${tSessionUsage}.f_input_tokens + EXCLUDED.f_input_tokens,
546
+ f_output_tokens = ${tSessionUsage}.f_output_tokens + EXCLUDED.f_output_tokens,
547
+ f_cache_read_tokens = ${tSessionUsage}.f_cache_read_tokens + EXCLUDED.f_cache_read_tokens,
548
+ f_reasoning_tokens = ${tSessionUsage}.f_reasoning_tokens + EXCLUDED.f_reasoning_tokens,
549
+ f_total_tokens = ${tSessionUsage}.f_total_tokens + EXCLUDED.f_total_tokens
550
+ `);
505
551
  }
506
- /** 活动计数旁路累加:派生表,不在写事务里(失败可丢,表可销毁重建)。 */
507
- async incrementEventCounts(id, buckets) {
552
+ /** 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。 */
553
+ async incrementSessionCounts(id, buckets) {
508
554
  if (buckets.length === 0) return;
509
- const table = this.qualifiedTable("t_event_counts");
555
+ const tSessionCounts = this.tables["t_session_counts"];
510
556
  for (const bucket of buckets) await this.db.execute(sql`
511
- INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
512
- VALUES (${id}, ${bucket.day}, ${bucket.type}, ${bucket.count})
513
- ON CONFLICT (f_session_id, f_day, f_type)
514
- DO UPDATE SET f_count = ${sql.raw(`${table}.f_count`)} + EXCLUDED.f_count
557
+ INSERT INTO ${tSessionCounts} (
558
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
559
+ VALUES (${id}, ${bucket.day}, ${bucket.turns}, ${bucket.steps},
560
+ ${bucket.userInputs}, ${bucket.toolCalls})
561
+ ON CONFLICT (f_session_id, f_day) DO UPDATE SET
562
+ f_turns = ${tSessionCounts}.f_turns + EXCLUDED.f_turns,
563
+ f_steps = ${tSessionCounts}.f_steps + EXCLUDED.f_steps,
564
+ f_user_inputs = ${tSessionCounts}.f_user_inputs + EXCLUDED.f_user_inputs,
565
+ f_tool_calls = ${tSessionCounts}.f_tool_calls + EXCLUDED.f_tool_calls
515
566
  `);
516
567
  }
517
- /** 按会话重算活动计数(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
518
- async rebuildEventCounts(id) {
519
- const table = this.qualifiedTable("t_event_counts");
568
+ /** 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
569
+ async rebuildSessionStats(id) {
570
+ const tSessionUsage = this.tables["t_session_usage"];
571
+ const tSessionCounts = this.tables["t_session_counts"];
520
572
  const tSessionEvents = this.tables["t_session_events"];
573
+ const tEventUsage = this.tables["t_event_usage"];
521
574
  const tEvents = this.tables["t_events"];
522
- await this.db.execute(sql`DELETE FROM ${sql.raw(table)} WHERE f_session_id = ${id}`);
575
+ await this.db.execute(sql`DELETE FROM ${tSessionUsage} WHERE f_session_id = ${id}`);
576
+ await this.db.execute(sql`
577
+ INSERT INTO ${tSessionUsage} (
578
+ f_session_id, f_day, f_provider, f_model,
579
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
580
+ SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
581
+ sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
582
+ sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
583
+ FROM ${tSessionEvents} b
584
+ JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
585
+ WHERE b.f_session_id = ${id}
586
+ GROUP BY 1, 2, 3, 4
587
+ `);
588
+ await this.db.execute(sql`DELETE FROM ${tSessionCounts} WHERE f_session_id = ${id}`);
523
589
  await this.db.execute(sql`
524
- INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
525
- SELECT ${tSessionEvents}.f_session_id,
526
- to_char(to_timestamp(${tEvents}.f_created_at / 1000.0), 'YYYY-MM-DD'),
527
- ${tEvents}.f_type,
528
- count(*)
529
- FROM ${tSessionEvents}
530
- JOIN ${tEvents} ON ${tEvents}.f_event_id = ${tSessionEvents}.f_event_id
531
- WHERE ${tSessionEvents}.f_session_id = ${id}
532
- AND ${tEvents}.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
533
- GROUP BY 1, 2, 3
590
+ INSERT INTO ${tSessionCounts} (
591
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
592
+ SELECT b.f_session_id,
593
+ to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
594
+ sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
595
+ sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
596
+ sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
597
+ sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
598
+ FROM ${tSessionEvents} b
599
+ JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
600
+ WHERE b.f_session_id = ${id} AND e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
601
+ GROUP BY 1, 2
534
602
  `);
535
603
  }
536
- async deleteEventCounts(id) {
537
- const table = this.qualifiedTable("t_event_counts");
538
- await this.db.execute(sql`DELETE FROM ${sql.raw(table)} WHERE f_session_id = ${id}`);
604
+ /** 全量重算用量行的引用标记(rewind / fork / 会话删除之后)。 */
605
+ async refreshEventUsageFlags() {
606
+ const tEventUsage = this.tables["t_event_usage"];
607
+ const tSessionEvents = this.tables["t_session_events"];
608
+ const tSessions = this.tables["t_sessions"];
609
+ await this.db.execute(sql`
610
+ UPDATE ${tEventUsage} SET
611
+ f_referenced = (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
612
+ WHERE rb.f_event_id = ${tEventUsage}.f_event_id))::int,
613
+ f_subagent = (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
614
+ JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
615
+ WHERE sb.f_event_id = ${tEventUsage}.f_event_id
616
+ AND ss.f_origin = 'subagent'))::int
617
+ `);
618
+ }
619
+ async deleteSessionStats(id) {
620
+ await this.db.execute(sql`DELETE FROM ${this.tables["t_session_usage"]} WHERE f_session_id = ${id}`);
621
+ await this.db.execute(sql`DELETE FROM ${this.tables["t_session_counts"]} WHERE f_session_id = ${id}`);
539
622
  }
540
623
  async getPrevBridge(exec, id, sequence) {
541
624
  return (await exec.select({
@@ -622,130 +705,6 @@ function registerSessionDeletion(ctx, persistence) {
622
705
  });
623
706
  }
624
707
  //#endregion
625
- //#region src/export.ts
626
- const SESSION_EXPORT_PATH = "/api/session.export";
627
- /** 文件名只保留安全字符:会话 id 来自请求体,会写进 Content-Disposition。 */
628
- function safeFilename(sessionId) {
629
- return sessionId.replace(/[^A-Za-z0-9._-]/gu, "_");
630
- }
631
- /** fflate 只给回调式异步 API(同步变体被 node/no-sync 禁止)。 */
632
- function zipBytes(files) {
633
- return new Promise((resolve, reject) => {
634
- zip(files, (error, data) => {
635
- if (error === null) resolve(data);
636
- else reject(error);
637
- });
638
- });
639
- }
640
- /** 导出通道:直接把会话日志打成 zip 响应体,与导入通道读同一份 artifact。 */
641
- function registerSessionExport(ctx, persistence) {
642
- ctx.inject(["webServer", "connection"], (webCtx) => {
643
- const webServer = webCtx.webServer;
644
- const connection = webCtx.get("connection");
645
- return webCtx.effect(() => webServer.register({
646
- kind: "exact",
647
- path: SESSION_EXPORT_PATH,
648
- handler: async (req, res) => {
649
- const rejection = connection.requestRejection(req);
650
- if (rejection !== void 0) {
651
- res.writeHead(rejection);
652
- res.end(rejection === 401 ? "unauthorized" : "forbidden");
653
- return;
654
- }
655
- if (req.method !== "POST") {
656
- res.writeHead(405, { "content-type": "application/json" });
657
- res.end(JSON.stringify({ error: "method not allowed" }));
658
- return;
659
- }
660
- const chunks = [];
661
- for await (const chunk of req) chunks.push(chunk);
662
- let envelope;
663
- try {
664
- envelope = JSON.parse(Buffer.concat(chunks).toString("utf8"));
665
- } catch {
666
- res.writeHead(400, { "content-type": "application/json" });
667
- res.end(JSON.stringify({ error: "request body is not JSON" }));
668
- return;
669
- }
670
- if (typeof envelope.sessionId !== "string" || envelope.sessionId === "") {
671
- res.writeHead(400, { "content-type": "application/json" });
672
- res.end(JSON.stringify({ error: "missing sessionId field" }));
673
- return;
674
- }
675
- const sessionId = envelope.sessionId;
676
- try {
677
- const raw = await persistence.readRaw(sessionId);
678
- if (raw === void 0) {
679
- res.writeHead(404, { "content-type": "application/json" });
680
- res.end(JSON.stringify({ error: `session "${sessionId}" not found` }));
681
- return;
682
- }
683
- const zipFile = await zipBytes({ [raw.filename]: strToU8(raw.content) });
684
- res.writeHead(200, {
685
- "content-type": "application/zip",
686
- "content-length": String(zipFile.byteLength),
687
- "content-disposition": `attachment; filename="${safeFilename(sessionId)}.zip"`
688
- });
689
- res.end(Buffer.from(zipFile));
690
- } catch (error) {
691
- res.writeHead(500, { "content-type": "application/json" });
692
- res.end(JSON.stringify({ error: error instanceof Error ? error.message : "session export failed" }));
693
- }
694
- }
695
- }), `session-rdb: ${SESSION_EXPORT_PATH} route`);
696
- });
697
- }
698
- //#endregion
699
- //#region src/gc.ts
700
- const SESSION_GC_PATH = "/api/session.gc";
701
- /** 让所有运行中的 agent 先退场:GC 要重写事件表,运行中的写路径必须停下来。 */
702
- async function stopRunningAgents(ctx) {
703
- const running = ctx.get("agents")?.list() ?? [];
704
- for (const agent of running) agent.cancel?.({ kind: "user" }, { keepInbox: true });
705
- await Promise.all(running.map((agent) => agent.whenIdle()));
706
- return running.length;
707
- }
708
- /** GC 通道:停 agent → 回收孤儿事件行 → VACUUM,一次请求完成。 */
709
- function registerSessionGc(ctx, persistence) {
710
- ctx.inject(["webServer", "connection"], (webCtx) => {
711
- const webServer = webCtx.webServer;
712
- const connection = webCtx.get("connection");
713
- return webCtx.effect(() => webServer.register({
714
- kind: "exact",
715
- path: SESSION_GC_PATH,
716
- handler: async (req, res) => {
717
- const rejection = connection.requestRejection(req);
718
- if (rejection !== void 0) {
719
- res.writeHead(rejection);
720
- res.end(rejection === 401 ? "unauthorized" : "forbidden");
721
- return;
722
- }
723
- if (req.method !== "POST") {
724
- res.writeHead(405, { "content-type": "application/json" });
725
- res.end(JSON.stringify({ error: "method not allowed" }));
726
- return;
727
- }
728
- try {
729
- const stoppedAgents = await stopRunningAgents(webCtx);
730
- const orphanSessions = await persistence.collectOrphanSessions();
731
- const orphanEvents = await persistence.collectOrphans();
732
- await persistence.vacuum();
733
- res.writeHead(200, { "content-type": "application/json" });
734
- res.end(JSON.stringify({
735
- orphanSessions,
736
- orphanEvents,
737
- stoppedAgents,
738
- vacuumed: true
739
- }));
740
- } catch (error) {
741
- res.writeHead(500, { "content-type": "application/json" });
742
- res.end(JSON.stringify({ error: error instanceof Error ? error.message : "gc failed" }));
743
- }
744
- }
745
- }), `session-rdb: ${SESSION_GC_PATH} route`);
746
- });
747
- }
748
- //#endregion
749
708
  //#region src/rows.ts
750
709
  /**
751
710
  * 管理面的会话列表:**我们自己的**一条路由(完整语料,含归档),与官方 `session/list` 分开。
@@ -1467,38 +1426,62 @@ var RdbSessionHandle = class RdbSessionHandle {
1467
1426
  }
1468
1427
  static LIVE_WRITE_BATCH_MAX_DELAY_MS = 200;
1469
1428
  };
1429
+ /**
1430
+ * 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
1431
+ * (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
1432
+ */
1433
+ const localized = (text) => text;
1434
+ /** 投影 checkpoint 的写回节流:两个分支共用。 */
1435
+ const projectionCacheSchema = z.object({
1436
+ writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS).description(localized({
1437
+ zh: "每累计这么多事件写回一次投影 checkpoint。",
1438
+ en: "Write the projection checkpoint back after this many events."
1439
+ })),
1440
+ writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS).description(localized({
1441
+ zh: "距离上次写回超过这个间隔(毫秒)时也写一次。",
1442
+ en: "Also write back when this many ms passed since the last one."
1443
+ }))
1444
+ }).default({
1445
+ writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
1446
+ writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
1447
+ });
1470
1448
  var SessionPersistenceRdb = class extends SessionPersistence {
1471
- config;
1472
1449
  static inject = ["sessions"];
1473
1450
  static Config = z.union([z.object({
1474
1451
  type: z.const("sqlite"),
1475
- path: z.string().required(),
1452
+ path: z.string().required().description(localized({
1453
+ zh: "SQLite 数据库文件路径;`:memory:` 是内存库(进程结束即丢)。",
1454
+ en: "SQLite database file path; `:memory:` keeps it in memory and loses it with the process."
1455
+ })),
1476
1456
  journalMode: z.union([
1477
1457
  "wal",
1478
1458
  "delete",
1479
1459
  "truncate",
1480
1460
  "persist"
1481
- ]).default("wal"),
1482
- busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS),
1483
- projectionCache: z.object({
1484
- writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
1485
- writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
1486
- }).default({
1487
- writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
1488
- writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
1489
- })
1461
+ ]).default("wal").description(localized({
1462
+ zh: "SQLite 日志模式;默认 wal(并发读最好)。",
1463
+ en: "SQLite journal mode; `wal` by default (best concurrent reads)."
1464
+ })),
1465
+ busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS).description(localized({
1466
+ zh: "写锁冲突时的忙等超时(毫秒)。",
1467
+ en: "Busy timeout in ms while a write lock is contended."
1468
+ })),
1469
+ projectionCache: projectionCacheSchema
1490
1470
  }), z.object({
1491
1471
  type: z.const("postgres"),
1492
- connectionString: z.string().required(),
1493
- schema: z.string().default("public"),
1494
- projectionCache: z.object({
1495
- writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
1496
- writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
1497
- }).default({
1498
- writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
1499
- writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
1500
- })
1501
- })]);
1472
+ connectionString: z.string().required().description(localized({
1473
+ zh: "PostgreSQL 连接串(含库名与凭证)。",
1474
+ en: "PostgreSQL connection string (database and credentials)."
1475
+ })),
1476
+ schema: z.string().default("public").description(localized({
1477
+ zh: "库内的 schema 名;默认 public。",
1478
+ en: "Schema name inside the database; `public` by default."
1479
+ })),
1480
+ projectionCache: projectionCacheSchema
1481
+ })]).description(localized({
1482
+ zh: "会话持久化后端:改这里的连接参数会在这一行重挂时生效(Loader 会重装被改的行)。",
1483
+ en: "Session persistence backend: edits apply when this row is remounted (the Loader re-installs changed rows)."
1484
+ })).volatile();
1502
1485
  name = "session-rdb";
1503
1486
  tracker = new RdbBackendTracker(this.name);
1504
1487
  backend;
@@ -1508,10 +1491,12 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1508
1491
  reuseEventIds = /* @__PURE__ */ new Map();
1509
1492
  liveBuffers = /* @__PURE__ */ new Map();
1510
1493
  liveReady = /* @__PURE__ */ new Map();
1494
+ /** 本次装配用的配置快照(整段 Config 是 volatile,重挂会给新的)。 */
1495
+ config;
1511
1496
  constructor(ctx, config, injectedBackend) {
1512
1497
  super(ctx);
1513
- this.config = config;
1514
- this.backend = injectedBackend ?? createBackend(config);
1498
+ this.config = config.get();
1499
+ this.backend = injectedBackend ?? createBackend(this.config);
1515
1500
  this.ready = this.init();
1516
1501
  this.installLiveRouting(ctx);
1517
1502
  new SessionBranchRdb(this.ctx);
@@ -1657,7 +1642,7 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1657
1642
  this.liveFailures.delete(id);
1658
1643
  this.liveDropWarned.delete(id);
1659
1644
  this.reuseEventIds.delete(id);
1660
- await this.deleteEventCounts(id);
1645
+ await this.dropSessionStats(id);
1661
1646
  }
1662
1647
  /** GC 通道:回收已无桥接行引用的事件行(孤儿),返回删除行数。 */
1663
1648
  async collectOrphans() {
@@ -1676,7 +1661,7 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1676
1661
  this.liveFailures.delete(id);
1677
1662
  this.liveDropWarned.delete(id);
1678
1663
  this.reuseEventIds.delete(id);
1679
- await this.deleteEventCounts(id);
1664
+ await this.dropSessionStats(id);
1680
1665
  }
1681
1666
  return deleted;
1682
1667
  }
@@ -1727,9 +1712,11 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1727
1712
  async appendBatch(meta, inheritedEventCount, events, tornTruncateTo) {
1728
1713
  await this.ready;
1729
1714
  if (events.length === 0) return false;
1715
+ sealOwnEvents(events);
1730
1716
  validateStoredEvents(meta, [...events]);
1731
1717
  const reuse = this.reuseEventIds.get(meta.id);
1732
1718
  let confirmedHead = -1;
1719
+ let usageRows = [];
1733
1720
  await this.backend.transaction(async (tx) => {
1734
1721
  if (tornTruncateTo !== void 0) {
1735
1722
  await tx.deleteBridgeTail(meta.id, tornTruncateTo);
@@ -1743,18 +1730,19 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1743
1730
  }, randomUUID());
1744
1731
  const head = await tx.getHead(meta.id);
1745
1732
  this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
1746
- const { headEventId, headSequence } = await appendEventTail(tx, meta, events, {
1733
+ const appended = await appendEventTail(tx, meta, events, {
1747
1734
  parentId: head.fHeadEventId,
1748
1735
  nextSeq: head.fHeadSequence + 1
1749
1736
  }, reuse);
1750
- await tx.updateHead(meta.id, headEventId, headSequence);
1737
+ usageRows = appended.usageRows;
1738
+ await tx.updateHead(meta.id, appended.headEventId, appended.headSequence);
1751
1739
  await tx.bumpRevision(meta.id, maxEventTime(events));
1752
- confirmedHead = headSequence;
1740
+ confirmedHead = appended.headSequence;
1753
1741
  });
1754
1742
  if (reuse !== void 0) this.reuseEventIds.delete(meta.id);
1755
1743
  this.writeGuard.confirmHead(meta.id, confirmedHead);
1756
1744
  this.tracker.materialized(meta.id);
1757
- await this.recordEventCounts(meta.id, events);
1745
+ await this.recordSessionStats(meta.id, events, usageRows);
1758
1746
  return true;
1759
1747
  }
1760
1748
  async readLog(id, options = {}, signal) {
@@ -1792,7 +1780,7 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1792
1780
  };
1793
1781
  }
1794
1782
  const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
1795
- if (hasLegacyShape(preserved)) {
1783
+ if (needsShapeAdoption(preserved)) {
1796
1784
  const adopted = adoptLegacyRows(row, eventRows);
1797
1785
  return {
1798
1786
  meta: adopted.meta,
@@ -1817,32 +1805,43 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1817
1805
  };
1818
1806
  }
1819
1807
  /**
1820
- * 活动计数**旁路累加**:不在写事务里、失败只 warn——`t_event_counts` 是可销毁重建的派生表,
1821
- * 丢几次累加不影响可用性(需要时用 `rebuildEventCounts` 重算)。
1808
+ * 统计衍生表的**旁路累加**:不在写事务里、失败只 warn——两张会话汇总表都是可销毁重建的
1809
+ * 派生表,丢几次累加不影响可用性(需要时用 `rebuildSessionStats` 重算)。
1810
+ * token 走写路径已折好的用量行,活动计数走本批事件类型。
1822
1811
  */
1823
- async recordEventCounts(id, events) {
1824
- const buckets = eventCountBuckets(events);
1825
- if (buckets.length === 0) return;
1826
- try {
1827
- await this.backend.incrementEventCounts(id, buckets);
1812
+ async recordSessionStats(id, events, usageRows) {
1813
+ const usageBuckets = sessionUsageBuckets(usageRows);
1814
+ if (usageBuckets.length > 0) try {
1815
+ await this.backend.incrementSessionUsage(id, usageBuckets);
1816
+ } catch (error) {
1817
+ this.ctx.logger.warn(`session-rdb: token usage for "${id}" not updated (${describeError(error)})`);
1818
+ }
1819
+ const countBuckets = sessionCountBuckets(events);
1820
+ if (countBuckets.length > 0) try {
1821
+ await this.backend.incrementSessionCounts(id, countBuckets);
1828
1822
  } catch (error) {
1829
1823
  this.ctx.logger.warn(`session-rdb: activity counts for "${id}" not updated (${describeError(error)})`);
1830
1824
  }
1831
1825
  }
1832
- /** 重算一个会话的活动计数(rewind / fork 之后;best-effort,同 `recordEventCounts`)。 */
1833
- async rebuildEventCounts(id) {
1826
+ /**
1827
+ * 重算一个会话的统计(rewind / fork 之后;best-effort,同 `recordSessionStats`):
1828
+ * 两张汇总表按会话重算,用量行的引用标记全量重算(引用可能跨会话消失)。
1829
+ */
1830
+ async rebuildSessionStats(id) {
1834
1831
  try {
1835
- await this.backend.rebuildEventCounts(id);
1832
+ await this.backend.rebuildSessionStats(id);
1833
+ await this.backend.refreshEventUsageFlags();
1836
1834
  } catch (error) {
1837
- this.ctx.logger.warn(`session-rdb: activity counts for "${id}" not rebuilt (${describeError(error)})`);
1835
+ this.ctx.logger.warn(`session-rdb: session stats for "${id}" not rebuilt (${describeError(error)})`);
1838
1836
  }
1839
1837
  }
1840
- /** 会话删除时清掉它的活动计数行(best-effort)。 */
1841
- async deleteEventCounts(id) {
1838
+ /** 会话删除时清掉它的汇总行,并重算用量行的引用标记(被删会话引用的行要立刻退出统计)。 */
1839
+ async dropSessionStats(id) {
1842
1840
  try {
1843
- await this.backend.deleteEventCounts(id);
1841
+ await this.backend.deleteSessionStats(id);
1842
+ await this.backend.refreshEventUsageFlags();
1844
1843
  } catch (error) {
1845
- this.ctx.logger.warn(`session-rdb: activity counts for "${id}" not deleted (${describeError(error)})`);
1844
+ this.ctx.logger.warn(`session-rdb: session stats for "${id}" not deleted (${describeError(error)})`);
1846
1845
  }
1847
1846
  }
1848
1847
  async rewriteMigratedLog(id, log) {
@@ -1855,13 +1854,14 @@ var SessionPersistenceRdb = class extends SessionPersistence {
1855
1854
  meta: log.meta,
1856
1855
  inheritedEventCount: SessionLogOffset(log.inheritedEventCount)
1857
1856
  }, randomUUID());
1858
- const { headEventId, headSequence } = await appendEventTail(tx, log.meta, log.events, {
1857
+ const appended = await appendEventTail(tx, log.meta, log.events, {
1859
1858
  parentId: "",
1860
1859
  nextSeq: 0
1861
1860
  });
1862
- await tx.updateHead(id, headEventId, headSequence);
1861
+ await tx.updateHead(id, appended.headEventId, appended.headSequence);
1863
1862
  await tx.bumpRevision(id, maxEventTime(log.events));
1864
1863
  });
1864
+ await this.rebuildSessionStats(id);
1865
1865
  }
1866
1866
  async listSnapshots(signal) {
1867
1867
  signal?.throwIfAborted();
@@ -2091,19 +2091,46 @@ function seedCoversPrefix(seed, prefix) {
2091
2091
  return seedEvent !== void 0 && JSON.stringify(seedEvent) === JSON.stringify(event);
2092
2092
  });
2093
2093
  }
2094
- /** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合。 */
2095
- function eventCountBuckets(events) {
2094
+ /** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合成四项。 */
2095
+ function sessionCountBuckets(events) {
2096
2096
  const counted = /* @__PURE__ */ new Map();
2097
2097
  for (const event of events) {
2098
2098
  if (!COUNTED_EVENT_TYPES.includes(event.type)) continue;
2099
2099
  const day = localDayKey(event.time);
2100
- const key = `${day}#${event.type}`;
2101
- const bucket = counted.get(key) ?? {
2100
+ const bucket = counted.get(day) ?? {
2102
2101
  day,
2103
- type: event.type,
2104
- count: 0
2102
+ turns: 0,
2103
+ steps: 0,
2104
+ userInputs: 0,
2105
+ toolCalls: 0
2105
2106
  };
2106
- bucket.count += 1;
2107
+ addActivityCount(bucket, event.type, 1);
2108
+ counted.set(day, bucket);
2109
+ }
2110
+ return [...counted.values()];
2111
+ }
2112
+ /** 一批用量行折成 token 桶:按「本地日 × 模型」聚合(模型未知落空串,与表口径一致)。 */
2113
+ function sessionUsageBuckets(usageRows) {
2114
+ const counted = /* @__PURE__ */ new Map();
2115
+ for (const row of usageRows) {
2116
+ const provider = row.fProvider ?? "";
2117
+ const model = row.fModel ?? "";
2118
+ const key = `${row.fDay}#${provider}#${model}`;
2119
+ const bucket = counted.get(key) ?? {
2120
+ day: row.fDay,
2121
+ provider,
2122
+ model,
2123
+ inputTokens: 0,
2124
+ outputTokens: 0,
2125
+ cacheReadTokens: 0,
2126
+ reasoningTokens: 0,
2127
+ totalTokens: 0
2128
+ };
2129
+ bucket.inputTokens += row.fInputTokens;
2130
+ bucket.outputTokens += row.fOutputTokens;
2131
+ bucket.cacheReadTokens += row.fCacheReadTokens;
2132
+ bucket.reasoningTokens += row.fReasoningTokens;
2133
+ bucket.totalTokens += row.fTotalTokens;
2107
2134
  counted.set(key, bucket);
2108
2135
  }
2109
2136
  return [...counted.values()];
@@ -2135,6 +2162,7 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
2135
2162
  let parentId = anchor.parentId;
2136
2163
  let nextSeq = anchor.nextSeq;
2137
2164
  const eventRows = [];
2165
+ const usageRows = [];
2138
2166
  const bridgeRows = [];
2139
2167
  for (const event of events) {
2140
2168
  const reusedId = reuse?.get(event.seq);
@@ -2142,7 +2170,7 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
2142
2170
  if (reusedId === void 0) {
2143
2171
  const { kind, role, name, actionId } = eventDimensions(event);
2144
2172
  const { data, surfaceOp: _surfaceOp, sourceEventSeqs: _sourceEventSeqs, ...envelope } = event;
2145
- eventRows.push({
2173
+ const row = {
2146
2174
  fEventId: eventId,
2147
2175
  fParentId: parentId,
2148
2176
  fType: event.type,
@@ -2156,7 +2184,10 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
2156
2184
  data
2157
2185
  }),
2158
2186
  fCreatedAt: event.time
2159
- });
2187
+ };
2188
+ eventRows.push(row);
2189
+ const usageRow = usageRowOf(row, meta.origin === "subagent");
2190
+ if (usageRow !== void 0) usageRows.push(usageRow);
2160
2191
  }
2161
2192
  const surfaceOp = event.surfaceOp === void 0 ? null : JSON.stringify(event.surfaceOp);
2162
2193
  bridgeRows.push({
@@ -2168,12 +2199,13 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
2168
2199
  parentId = eventId;
2169
2200
  nextSeq++;
2170
2201
  }
2171
- if (eventRows.length > 0) await tx.insertEvents(eventRows);
2202
+ if (eventRows.length > 0) await tx.insertEvents(eventRows, usageRows);
2172
2203
  await tx.insertBridges(bridgeRows);
2173
2204
  if (events.some((event) => event.type === "session/title")) await tx.refreshTitle(meta.id);
2174
2205
  return {
2175
2206
  headEventId: parentId,
2176
- headSequence: nextSeq - 1
2207
+ headSequence: nextSeq - 1,
2208
+ usageRows
2177
2209
  };
2178
2210
  }
2179
2211
  //#endregion