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

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 (37) hide show
  1. package/README.md +17 -10
  2. package/dist/artifact.d.mts +2 -101
  3. package/dist/artifact.mjs +2 -2
  4. package/dist/{branch-BjUR6wfe.mjs → branch-7eOh_tbO.mjs} +3 -3
  5. package/dist/deletion.d.mts +1 -1
  6. package/dist/deletion.mjs +1 -1
  7. package/dist/import.d.mts +1 -1
  8. package/dist/import.mjs +1 -1
  9. package/dist/index.d.mts +1 -1
  10. package/dist/index.mjs +3 -3
  11. package/dist/{backend-E0bmTXpz.d.mts → log-BbleqD1M.d.mts} +171 -24
  12. package/dist/{log-qCNkDjh5.mjs → log-DTcvsMz7.mjs} +189 -12
  13. package/dist/{sqlite-zcXVyMxr.mjs → sqlite-ChM_sHAG.mjs} +320 -259
  14. package/dist/{src-B9X8BKyh.mjs → src-BWqePRA7.mjs} +290 -162
  15. package/dist/storage.d.mts +20 -7
  16. package/dist/storage.mjs +2 -2
  17. package/dist/testing.d.mts +1 -1
  18. package/dist/testing.mjs +1 -1
  19. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +48 -0
  20. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +48 -0
  21. package/package.json +20 -20
  22. package/src/backend.ts +41 -17
  23. package/src/branch.ts +4 -4
  24. package/src/drizzle/postgres-v3.ts +2 -0
  25. package/src/drizzle/sqlite-v3.ts +2 -0
  26. package/src/entities/v3/event-usage.ts +8 -1
  27. package/src/entities/v3/index.ts +8 -0
  28. package/src/entities/v3/session-counts.ts +23 -0
  29. package/src/entities/v3/session-usage.ts +29 -0
  30. package/src/index.ts +109 -43
  31. package/src/log.ts +58 -17
  32. package/src/postgres.ts +248 -149
  33. package/src/rows.ts +2 -1
  34. package/src/schema.ts +4 -0
  35. package/src/sqlite.ts +263 -124
  36. package/src/storage-takeover/repository.ts +1 -4
  37. package/src/usage.ts +10 -6
package/src/index.ts CHANGED
@@ -41,11 +41,13 @@ import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
41
41
  import {
42
42
  type Backend,
43
43
  type BackendTx,
44
- type EventCountBucket,
45
44
  type EventInsert,
45
+ type SessionCountBucket,
46
+ type SessionUsageBucket,
46
47
  } from "./backend.ts";
47
48
  import { WriteGuard } from "./write-guard.ts";
48
- import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
49
+ import { repairReadView, rowToMeta, scanRows, toJsonlArtifact, usageRowOf } from "./log.ts";
50
+ import type { EventUsageRow } from "./log.ts";
49
51
  import {
50
52
  DEFAULT_BUSY_TIMEOUT_MS,
51
53
  eventDimensions,
@@ -61,11 +63,16 @@ import { registerSessionDeletion } from "./deletion.ts";
61
63
  import { registerSessionExport } from "./export.ts";
62
64
  import { registerSessionGc } from "./gc.ts";
63
65
  import { registerSessionRows } from "./rows.ts";
64
- import { COUNTED_EVENT_TYPES, localDayKey, registerSessionUsage } from "./usage.ts";
66
+ import {
67
+ COUNTED_EVENT_TYPES,
68
+ addActivityCount,
69
+ localDayKey,
70
+ registerSessionUsage,
71
+ } from "./usage.ts";
65
72
  import type { UsageAggregate } from "./usage.ts";
66
73
  import { SessionQueryRdb } from "./session-query.ts";
67
74
  import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
68
- import { hasLegacyShape } from "./log.ts";
75
+ import { needsShapeAdoption, sealOwnEvents } from "./log.ts";
69
76
  import { installStorageTakeover } from "./storage-takeover/index.ts";
70
77
 
71
78
  /** 一批事件里的最大时间:写路径据此推进会话行的「最后活动时间」。 */
@@ -754,7 +761,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
754
761
  this.liveFailures.delete(id);
755
762
  this.liveDropWarned.delete(id);
756
763
  this.reuseEventIds.delete(id);
757
- await this.deleteEventCounts(id);
764
+ await this.dropSessionStats(id);
758
765
  }
759
766
 
760
767
  /** GC 通道:回收已无桥接行引用的事件行(孤儿),返回删除行数。 */
@@ -778,7 +785,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
778
785
  this.liveFailures.delete(id);
779
786
  this.liveDropWarned.delete(id);
780
787
  this.reuseEventIds.delete(id);
781
- await this.deleteEventCounts(id);
788
+ await this.dropSessionStats(id);
782
789
  }
783
790
  return deleted;
784
791
  }
@@ -854,10 +861,14 @@ export class SessionPersistenceRdb extends SessionPersistence {
854
861
  await this.ready;
855
862
  if (events.length === 0) return false;
856
863
 
864
+ // 本仓库自造的事件类型落库前补上 `ignorable` 信封:不补的话上游那道校验连写入都拒。
865
+ sealOwnEvents(events);
857
866
  validateStoredEvents(meta, [...events]);
858
867
 
859
868
  const reuse = this.reuseEventIds.get(meta.id);
860
869
  let confirmedHead = -1;
870
+ // 本批用量行由 `appendEventTail` 折好;事务成功后旁路据此累加 token 汇总表。
871
+ let usageRows: EventUsageRow[] = [];
861
872
  await this.backend.transaction(async (tx) => {
862
873
  if (tornTruncateTo !== undefined) {
863
874
  await tx.deleteBridgeTail(meta.id, tornTruncateTo);
@@ -872,16 +883,17 @@ export class SessionPersistenceRdb extends SessionPersistence {
872
883
  const head = await tx.getHead(meta.id);
873
884
 
874
885
  this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
875
- const { headEventId, headSequence } = await appendEventTail(
886
+ const appended = await appendEventTail(
876
887
  tx,
877
888
  meta,
878
889
  events,
879
890
  { parentId: head.fHeadEventId, nextSeq: head.fHeadSequence + 1 },
880
891
  reuse,
881
892
  );
882
- await tx.updateHead(meta.id, headEventId, headSequence);
893
+ usageRows = appended.usageRows;
894
+ await tx.updateHead(meta.id, appended.headEventId, appended.headSequence);
883
895
  await tx.bumpRevision(meta.id, maxEventTime(events));
884
- confirmedHead = headSequence;
896
+ confirmedHead = appended.headSequence;
885
897
  });
886
898
  // 事务**成功之后**才丢掉复用映射:失败(并发写者校验、唯一键冲突等)时这份映射还没被消费,
887
899
  // 重试同一个 append 还要靠它复用父会话的事件行——提前删掉会让前缀事件行被重新插入一遍。
@@ -889,7 +901,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
889
901
 
890
902
  this.writeGuard.confirmHead(meta.id, confirmedHead);
891
903
  this.tracker.materialized(meta.id);
892
- await this.recordEventCounts(meta.id, events);
904
+ await this.recordSessionStats(meta.id, events, usageRows);
893
905
  return true;
894
906
  }
895
907
 
@@ -959,7 +971,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
959
971
  const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
960
972
  // 版本号不完全可信:写路径曾把回退视图的结果以当前版本号落库,于是库里存在「v4 标记 + 旧代形状」
961
973
  // 的行——在扫干净的边界之内再按内容判一次(撕裂尾部与坏行已经被 `scanRows` 丢掉了)。
962
- if (hasLegacyShape(preserved)) {
974
+ if (needsShapeAdoption(preserved)) {
963
975
  const adopted = adoptLegacyRows(row, eventRows);
964
976
  return {
965
977
  meta: adopted.meta,
@@ -985,39 +997,60 @@ export class SessionPersistenceRdb extends SessionPersistence {
985
997
  }
986
998
 
987
999
  /**
988
- * 活动计数**旁路累加**:不在写事务里、失败只 warn——`t_event_counts` 是可销毁重建的派生表,
989
- * 丢几次累加不影响可用性(需要时用 `rebuildEventCounts` 重算)。
1000
+ * 统计衍生表的**旁路累加**:不在写事务里、失败只 warn——两张会话汇总表都是可销毁重建的
1001
+ * 派生表,丢几次累加不影响可用性(需要时用 `rebuildSessionStats` 重算)。
1002
+ * token 走写路径已折好的用量行,活动计数走本批事件类型。
990
1003
  */
991
- private async recordEventCounts(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
992
- const buckets = eventCountBuckets(events);
993
- if (buckets.length === 0) return;
994
- try {
995
- await this.backend.incrementEventCounts(id, buckets);
996
- } catch (error: unknown) {
997
- this.ctx.logger.warn(
998
- `session-rdb: activity counts for "${id}" not updated (${describeError(error)})`,
999
- );
1004
+ private async recordSessionStats(
1005
+ id: SessionId,
1006
+ events: readonly SessionEvent[],
1007
+ usageRows: readonly EventUsageRow[],
1008
+ ): Promise<void> {
1009
+ const usageBuckets = sessionUsageBuckets(usageRows);
1010
+ if (usageBuckets.length > 0) {
1011
+ try {
1012
+ await this.backend.incrementSessionUsage(id, usageBuckets);
1013
+ } catch (error: unknown) {
1014
+ this.ctx.logger.warn(
1015
+ `session-rdb: token usage for "${id}" not updated (${describeError(error)})`,
1016
+ );
1017
+ }
1018
+ }
1019
+ const countBuckets = sessionCountBuckets(events);
1020
+ if (countBuckets.length > 0) {
1021
+ try {
1022
+ await this.backend.incrementSessionCounts(id, countBuckets);
1023
+ } catch (error: unknown) {
1024
+ this.ctx.logger.warn(
1025
+ `session-rdb: activity counts for "${id}" not updated (${describeError(error)})`,
1026
+ );
1027
+ }
1000
1028
  }
1001
1029
  }
1002
1030
 
1003
- /** 重算一个会话的活动计数(rewind / fork 之后;best-effort,同 `recordEventCounts`)。 */
1004
- async rebuildEventCounts(id: SessionId): Promise<void> {
1031
+ /**
1032
+ * 重算一个会话的统计(rewind / fork 之后;best-effort,同 `recordSessionStats`):
1033
+ * 两张汇总表按会话重算,用量行的引用标记全量重算(引用可能跨会话消失)。
1034
+ */
1035
+ async rebuildSessionStats(id: SessionId): Promise<void> {
1005
1036
  try {
1006
- await this.backend.rebuildEventCounts(id);
1037
+ await this.backend.rebuildSessionStats(id);
1038
+ await this.backend.refreshEventUsageFlags();
1007
1039
  } catch (error: unknown) {
1008
1040
  this.ctx.logger.warn(
1009
- `session-rdb: activity counts for "${id}" not rebuilt (${describeError(error)})`,
1041
+ `session-rdb: session stats for "${id}" not rebuilt (${describeError(error)})`,
1010
1042
  );
1011
1043
  }
1012
1044
  }
1013
1045
 
1014
- /** 会话删除时清掉它的活动计数行(best-effort)。 */
1015
- private async deleteEventCounts(id: SessionId): Promise<void> {
1046
+ /** 会话删除时清掉它的汇总行,并重算用量行的引用标记(被删会话引用的行要立刻退出统计)。 */
1047
+ private async dropSessionStats(id: SessionId): Promise<void> {
1016
1048
  try {
1017
- await this.backend.deleteEventCounts(id);
1049
+ await this.backend.deleteSessionStats(id);
1050
+ await this.backend.refreshEventUsageFlags();
1018
1051
  } catch (error: unknown) {
1019
1052
  this.ctx.logger.warn(
1020
- `session-rdb: activity counts for "${id}" not deleted (${describeError(error)})`,
1053
+ `session-rdb: session stats for "${id}" not deleted (${describeError(error)})`,
1021
1054
  );
1022
1055
  }
1023
1056
  }
@@ -1039,13 +1072,15 @@ export class SessionPersistenceRdb extends SessionPersistence {
1039
1072
  { meta: log.meta, inheritedEventCount: SessionLogOffset(log.inheritedEventCount) },
1040
1073
  randomUUID(),
1041
1074
  );
1042
- const { headEventId, headSequence } = await appendEventTail(tx, log.meta, log.events, {
1075
+ const appended = await appendEventTail(tx, log.meta, log.events, {
1043
1076
  parentId: "",
1044
1077
  nextSeq: 0,
1045
1078
  });
1046
- await tx.updateHead(id, headEventId, headSequence);
1079
+ await tx.updateHead(id, appended.headEventId, appended.headSequence);
1047
1080
  await tx.bumpRevision(id, maxEventTime(log.events));
1048
1081
  });
1082
+ // 重写换了事件行:被删掉的那批成了孤儿(引用标记要落回 0),本会话汇总按新事件重算。
1083
+ await this.rebuildSessionStats(id);
1049
1084
  }
1050
1085
 
1051
1086
  async listSnapshots(
@@ -1374,15 +1409,41 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
1374
1409
  );
1375
1410
  }
1376
1411
 
1377
- /** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合。 */
1378
- function eventCountBuckets(events: readonly SessionEvent[]): EventCountBucket[] {
1379
- const counted = new Map<string, EventCountBucket>();
1412
+ /** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合成四项。 */
1413
+ function sessionCountBuckets(events: readonly SessionEvent[]): SessionCountBucket[] {
1414
+ const counted = new Map<string, SessionCountBucket>();
1380
1415
  for (const event of events) {
1381
1416
  if (!(COUNTED_EVENT_TYPES as readonly string[]).includes(event.type)) continue;
1382
1417
  const day = localDayKey(event.time);
1383
- const key = `${day}#${event.type}`;
1384
- const bucket = counted.get(key) ?? { day, type: event.type, count: 0 };
1385
- bucket.count += 1;
1418
+ const bucket = counted.get(day) ?? { day, turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
1419
+ addActivityCount(bucket, event.type, 1);
1420
+ counted.set(day, bucket);
1421
+ }
1422
+ return [...counted.values()];
1423
+ }
1424
+
1425
+ /** 一批用量行折成 token 桶:按「本地日 × 模型」聚合(模型未知落空串,与表口径一致)。 */
1426
+ function sessionUsageBuckets(usageRows: readonly EventUsageRow[]): SessionUsageBucket[] {
1427
+ const counted = new Map<string, SessionUsageBucket>();
1428
+ for (const row of usageRows) {
1429
+ const provider = row.fProvider ?? "";
1430
+ const model = row.fModel ?? "";
1431
+ const key = `${row.fDay}#${provider}#${model}`;
1432
+ const bucket = counted.get(key) ?? {
1433
+ day: row.fDay,
1434
+ provider,
1435
+ model,
1436
+ inputTokens: 0,
1437
+ outputTokens: 0,
1438
+ cacheReadTokens: 0,
1439
+ reasoningTokens: 0,
1440
+ totalTokens: 0,
1441
+ };
1442
+ bucket.inputTokens += row.fInputTokens;
1443
+ bucket.outputTokens += row.fOutputTokens;
1444
+ bucket.cacheReadTokens += row.fCacheReadTokens;
1445
+ bucket.reasoningTokens += row.fReasoningTokens;
1446
+ bucket.totalTokens += row.fTotalTokens;
1386
1447
  counted.set(key, bucket);
1387
1448
  }
1388
1449
  return [...counted.values()];
@@ -1424,11 +1485,12 @@ async function appendEventTail(
1424
1485
  events: readonly SessionEvent[],
1425
1486
  anchor: { parentId: string; nextSeq: number },
1426
1487
  reuse?: ReadonlyMap<number, string>,
1427
- ): Promise<{ headEventId: string; headSequence: number }> {
1488
+ ): Promise<{ headEventId: string; headSequence: number; usageRows: EventUsageRow[] }> {
1428
1489
  let parentId = anchor.parentId;
1429
1490
  let nextSeq = anchor.nextSeq;
1430
1491
 
1431
1492
  const eventRows: EventInsert[] = [];
1493
+ const usageRows: EventUsageRow[] = [];
1432
1494
  const bridgeRows: Array<{
1433
1495
  fSessionId: SessionId;
1434
1496
  fEventId: string;
@@ -1447,7 +1509,7 @@ async function appendEventTail(
1447
1509
  sourceEventSeqs?: unknown;
1448
1510
  };
1449
1511
  const { data, surfaceOp: _surfaceOp, sourceEventSeqs: _sourceEventSeqs, ...envelope } = raw;
1450
- eventRows.push({
1512
+ const row: EventInsert = {
1451
1513
  fEventId: eventId,
1452
1514
  fParentId: parentId,
1453
1515
  fType: event.type,
@@ -1458,7 +1520,11 @@ async function appendEventTail(
1458
1520
  fEncoding: EVENT_ENCODING,
1459
1521
  fData: JSON.stringify({ ...envelope, data }),
1460
1522
  fCreatedAt: event.time,
1461
- });
1523
+ };
1524
+ eventRows.push(row);
1525
+ // 用量行在这里一次折好(读侧维度一起物化),后端只负责插入,事务后旁路再据此累加汇总表。
1526
+ const usageRow = usageRowOf(row, meta.origin === "subagent");
1527
+ if (usageRow !== undefined) usageRows.push(usageRow);
1462
1528
  }
1463
1529
  const surfaceOp =
1464
1530
  (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined
@@ -1473,13 +1539,13 @@ async function appendEventTail(
1473
1539
  parentId = eventId;
1474
1540
  nextSeq++;
1475
1541
  }
1476
- if (eventRows.length > 0) await tx.insertEvents(eventRows);
1542
+ if (eventRows.length > 0) await tx.insertEvents(eventRows, usageRows);
1477
1543
  await tx.insertBridges(bridgeRows);
1478
1544
 
1479
1545
  if (events.some((event) => (event.type as string) === "session/title")) {
1480
1546
  await tx.refreshTitle(meta.id);
1481
1547
  }
1482
- return { headEventId: parentId, headSequence: nextSeq - 1 };
1548
+ return { headEventId: parentId, headSequence: nextSeq - 1, usageRows };
1483
1549
  }
1484
1550
 
1485
1551
  export default SessionPersistenceRdb;
package/src/log.ts CHANGED
@@ -4,6 +4,7 @@ import type { SessionFormatEvent, SessionFormatHeader } from "@deepseek-ai/dsh-s
4
4
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
5
5
  import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
6
6
  import type { EventRow, SessionRow } from "./backend.ts";
7
+ import { localDayKey } from "./usage.ts";
7
8
 
8
9
  function normalizeSurfaceOp(value: unknown): SurfaceOp {
9
10
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -469,15 +470,15 @@ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
469
470
  * (v4 起 system 消息只认这个 kind);
470
471
  * - `tool/result` 的消息:v3 是 user 角色、结果块包在 `content[0]`;v4 是 `role: 'tool'` + 顶层
471
472
  * `toolCallId` + `content` 就是结果块;
472
- * - 本仓库自己写过、上游已删除的事件类型(见 `LEGACY_OWN_EVENT_TYPES`)标 `ignorable: true`——v4 的
473
- * 校验对未知类型只接受可忽略信封。
473
+ * - **本仓库自造、上游不认识的事件类型**(见 `OWN_EVENT_TYPES`:历史遗留的与我们当前在写的)标
474
+ * `ignorable: true`——v4 的校验对未知类型只接受可忽略信封。
474
475
  *
475
476
  * 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
476
477
  */
477
478
  export function normalizeToCurrentShape(events: SessionEvent[]): void {
478
479
  for (const event of events) {
479
480
  const type = (event as { type: string }).type;
480
- if (LEGACY_OWN_EVENT_TYPES.has(type)) {
481
+ if (OWN_EVENT_TYPES.has(type)) {
481
482
  (event as { ignorable?: true }).ignorable = true;
482
483
  continue;
483
484
  }
@@ -520,17 +521,45 @@ function liftToolResultMessage(message: unknown): void {
520
521
  }
521
522
 
522
523
  /**
523
- * 本仓库写过、后来连同机制一起删除的事件类型:读的时候标 `ignorable: true` 让校验跳过它们。
524
- * 新增历史类型时登记到这里——**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件)。
524
+ * 落库前给**本仓库自造的事件类型**补上 `ignorable: true` 信封。
525
+ *
526
+ * 上游的持久化校验(`validateStoredEvents`)只放行「已知类型」或「带可忽略信封的未知类型」,我们的类型
527
+ * 按构造就在已知表之外——不补的话连**写入**都会被拒(`appendBatch` 里那道校验)。上游 `Session.append`
528
+ * 没有承载这个信封的口子(第三个参数只服务 surface 事件),所以这一笔由持久化层补:canonical log 里的
529
+ * 形状不变,落库的那一份带上信封。
530
+ * @param events - 即将落库的批次(`materializeAppendBatch` 的 JSON 快照副本,可写)。
531
+ */
532
+ export function sealOwnEvents(events: readonly SessionEvent[]): void {
533
+ for (const event of events) {
534
+ if (OWN_EVENT_TYPES.has((event as { type: string }).type)) {
535
+ (event as { ignorable?: true }).ignorable = true;
536
+ }
537
+ }
538
+ }
539
+
540
+ /**
541
+ * 本仓库自造、上游不认识的事件类型:读的时候标 `ignorable: true`,v4 的持久化校验才肯跳过它们
542
+ * (`validateStoredEvents` 只放行「已知类型」或「带可忽略信封的未知类型」)。
543
+ *
544
+ * 两类都在这张表里:
545
+ * - **历史遗留**:写过、后来连同机制一起删除的(`session-branch/version`);
546
+ * - **当前在写**:本仓库的插件自己声明的事件类型(`session-mode/selected`)——上游的
547
+ * `KNOWN_SESSION_EVENT_TYPES` 由上游仓库的声明生成,外部插件的事件按构造就在它之外。
548
+ *
549
+ * 新增自造事件类型时**必须**登记到这里,否则读路径 fail loud(真回归:`session-mode/selected` 漏登记,
550
+ * 会话打不开)。**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件——那种必须拒读)。
525
551
  */
526
- export const LEGACY_OWN_EVENT_TYPES: ReadonlySet<string> = new Set(["session-branch/version"]);
552
+ export const OWN_EVENT_TYPES: ReadonlySet<string> = new Set([
553
+ "session-branch/version",
554
+ "session-mode/selected",
555
+ ]);
527
556
 
528
- /** 行里是否还带着旧代消息形状(`session-branch/version` 这类自造事件也算)——写路径曾把回退视图的结果
529
- * 以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
530
- export function hasLegacyShape(events: readonly SessionEvent[]): boolean {
557
+ /** 这批事件是否需要按当前形状归一(旧代消息形状,或尚未标 `ignorable` 的自造事件类型)——写路径曾把回退
558
+ * 视图的结果以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
559
+ export function needsShapeAdoption(events: readonly SessionEvent[]): boolean {
531
560
  for (const event of events) {
532
561
  const type = (event as { type: string }).type;
533
- if (LEGACY_OWN_EVENT_TYPES.has(type)) return true;
562
+ if (OWN_EVENT_TYPES.has(type)) return true;
534
563
  if (type === "system/message") {
535
564
  const message = (event.data as unknown as Record<string, unknown>)["message"] as
536
565
  | Record<string, unknown>
@@ -716,12 +745,15 @@ export function titleOfEventData(data: string): string | undefined {
716
745
  }
717
746
  }
718
747
 
719
- /** 用量行的列(`t_event_usage`)。 */
748
+ /** 用量行的列(`t_event_usage`):读侧维度(本地日与两个归属标记)在写入时一起物化。 */
720
749
  export interface EventUsageRow {
721
750
  fEventId: string;
722
751
  fCreatedAt: number;
752
+ fDay: string;
723
753
  fProvider: string | null;
724
754
  fModel: string | null;
755
+ fReferenced: number;
756
+ fSubagent: number;
725
757
  fInputTokens: number;
726
758
  fOutputTokens: number;
727
759
  fCacheReadTokens: number;
@@ -740,15 +772,21 @@ function textField(value: unknown): string | null {
740
772
  /**
741
773
  * 一条事件行的用量(只有带 `usage` 的 `assistant/message` 才有):写路径据此记录
742
774
  * `t_event_usage`,统计因此不必逐行解析 JSON。两种 `f_data` 结构(带信封 / 老格式)都认。
775
+ * 读侧维度一起物化:本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务,
776
+ * 提交即被引用),`f_subagent` 取写入它的会话是否子代理。
743
777
  * @param event - 待写入的事件行。
778
+ * @param subagent - 写入它的会话是否 `origin === 'subagent'`。
744
779
  * @returns 用量行,或该事件没有用量时的 undefined。
745
780
  */
746
- export function usageRowOf(event: {
747
- fEventId: string;
748
- fCreatedAt: number;
749
- fType: string;
750
- fData: string;
751
- }): EventUsageRow | undefined {
781
+ export function usageRowOf(
782
+ event: {
783
+ fEventId: string;
784
+ fCreatedAt: number;
785
+ fType: string;
786
+ fData: string;
787
+ },
788
+ subagent: boolean,
789
+ ): EventUsageRow | undefined {
752
790
  if (event.fType !== "assistant/message") return undefined;
753
791
  let parsed: Record<string, unknown>;
754
792
  try {
@@ -763,8 +801,11 @@ export function usageRowOf(event: {
763
801
  const row: EventUsageRow = {
764
802
  fEventId: event.fEventId,
765
803
  fCreatedAt: event.fCreatedAt,
804
+ fDay: localDayKey(event.fCreatedAt),
766
805
  fProvider: textField(source?.["provider"]),
767
806
  fModel: textField(source?.["model"]),
807
+ fReferenced: 1,
808
+ fSubagent: subagent ? 1 : 0,
768
809
  fInputTokens: numericField((usage as Record<string, unknown>)["inputTokens"]),
769
810
  fOutputTokens: numericField((usage as Record<string, unknown>)["outputTokens"]),
770
811
  fCacheReadTokens: numericField((usage as Record<string, unknown>)["cacheReadTokens"]),