@morlay/session-rdb 0.0.21-alpha.2 → 0.0.21-alpha.4

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.
package/src/sqlite.ts CHANGED
@@ -11,12 +11,21 @@ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistenc
11
11
  import {
12
12
  type Backend,
13
13
  type BackendTx,
14
+ type EventCountBucket,
14
15
  type EventInsert,
15
16
  type EventRow,
16
17
  type SessionRow,
17
18
  } from "./backend.ts";
18
19
  import { sessionConflictRow, sessionInsertRow, titleOfEventData, usageRowOf } from "./log.ts";
19
- import type { UsageAggregate, UsageTotals } from "./usage.ts";
20
+ import {
21
+ COUNTED_EVENT_TYPES,
22
+ addActivityCount,
23
+ addTokenTotals,
24
+ addTotals,
25
+ emptyTotals,
26
+ localDayKey,
27
+ } from "./usage.ts";
28
+ import type { UsageActivityTotals, UsageAggregate, UsageTokenTotals } from "./usage.ts";
20
29
  import {
21
30
  DEFAULT_BUSY_TIMEOUT_MS,
22
31
  SCHEMA_VERSION,
@@ -39,12 +48,11 @@ import type { StorageRepository } from "./storage-takeover/types.ts";
39
48
  type SqliteDb = NodeSQLiteDatabase & { $client: DatabaseSync };
40
49
 
41
50
  /** 用量聚合的原始行(列名是 SQL 别名)。 */
42
- interface RawUsageRow {
51
+ interface RawBucketRow {
43
52
  day: string;
44
53
  provider: string | null;
45
54
  model: string | null;
46
55
  subagent: number;
47
- events: number;
48
56
  input_tokens: number | null;
49
57
  output_tokens: number | null;
50
58
  cache_read_tokens: number | null;
@@ -52,6 +60,55 @@ interface RawUsageRow {
52
60
  total_tokens: number | null;
53
61
  }
54
62
 
63
+ /** 活动计数行(派生表按事件类型存,读的时候折成四项)。 */
64
+ interface RawCountRow {
65
+ f_type: string;
66
+ n: number | null;
67
+ }
68
+
69
+ /** 「是否子代理」两组的 token 合计行。 */
70
+ interface RawScopeRow extends RawBucketRow {}
71
+
72
+ /** 按会话的行:token 一条查询、活动一条查询,按 session_id 合并。 */
73
+ interface RawSessionTokenRow extends RawBucketRow {
74
+ session_id: string;
75
+ title: string | null;
76
+ archived: number;
77
+ }
78
+
79
+ interface RawSessionCountRow extends RawCountRow {
80
+ session_id: string;
81
+ }
82
+
83
+ /** 活动计数的派生表:可销毁重建(不在 drizzle schema / 迁移里,建表幂等)。 */
84
+ function ensureEventCountsTable(db: DatabaseSync): void {
85
+ db.exec(`CREATE TABLE IF NOT EXISTS t_event_counts (
86
+ f_id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ f_session_id TEXT NOT NULL,
88
+ f_day TEXT NOT NULL,
89
+ f_type TEXT NOT NULL,
90
+ f_count INTEGER NOT NULL DEFAULT 0
91
+ );
92
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_event_counts_session_day_type
93
+ ON t_event_counts(f_session_id, f_day, f_type);
94
+ CREATE INDEX IF NOT EXISTS idx_event_counts_day ON t_event_counts(f_day);`);
95
+ }
96
+
97
+ /** 一次性回填活动计数(表为空时从事件表重算;幂等)。 */
98
+ function backfillEventCounts(db: DatabaseSync): void {
99
+ const pending = db.prepare("SELECT count(*) AS n FROM t_event_counts").get() as { n: number };
100
+ if (pending.n > 0) return;
101
+ db.exec(`INSERT OR IGNORE INTO t_event_counts (f_session_id, f_day, f_type, f_count)
102
+ SELECT b.f_session_id,
103
+ date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
104
+ e.f_type,
105
+ count(*)
106
+ FROM t_session_events b
107
+ JOIN t_events e ON e.f_event_id = b.f_event_id
108
+ WHERE e.f_type IN (${COUNTED_EVENT_TYPE_SQL})
109
+ GROUP BY 1, 2, 3`);
110
+ }
111
+
55
112
  /**
56
113
  * 一次性回填:旧库的用量从未写过 `t_event_usage`,建表后按事件行补一次(幂等:表非空即跳过)。
57
114
  */
@@ -83,9 +140,14 @@ function backfillEventUsage(db: DatabaseSync): void {
83
140
  }
84
141
 
85
142
  /** SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。 */
86
- function totalsOf(row: RawUsageRow): UsageTotals {
143
+ function tokenTotalsOf(row: {
144
+ input_tokens: number | null;
145
+ output_tokens: number | null;
146
+ cache_read_tokens: number | null;
147
+ reasoning_tokens: number | null;
148
+ total_tokens: number | null;
149
+ }): UsageTokenTotals {
87
150
  return {
88
- events: row.events,
89
151
  inputTokens: row.input_tokens ?? 0,
90
152
  outputTokens: row.output_tokens ?? 0,
91
153
  cacheReadTokens: row.cache_read_tokens ?? 0,
@@ -94,6 +156,25 @@ function totalsOf(row: RawUsageRow): UsageTotals {
94
156
  };
95
157
  }
96
158
 
159
+ /** 一行的整套指标:token 用量 + 活动计数(轮次 / 步骤 / 用户输入 / 工具调用)。 */
160
+ function emptyActivity(): UsageActivityTotals {
161
+ return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
162
+ }
163
+
164
+ /** 有 token 用量的会话:活动计数与列表都限定在这一批会话里,口径一致。 */
165
+ function hasUsageSession(column: string): string {
166
+ return `EXISTS (SELECT 1 FROM t_session_events ub
167
+ JOIN t_event_usage uu ON uu.f_event_id = ub.f_event_id
168
+ WHERE ub.f_session_id = ${column})`;
169
+ }
170
+
171
+ const SUBAGENT_OF_EVENT = `EXISTS (SELECT 1 FROM t_session_events sb
172
+ JOIN t_sessions ss ON ss.f_session_id = sb.f_session_id
173
+ WHERE sb.f_event_id = %EVENT% AND ss.f_origin = 'subagent')`;
174
+
175
+ /** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
176
+ const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
177
+
97
178
  const sqliteMigrationsDir = fileURLToPath(new URL("../drizzle/sqlite/", import.meta.url));
98
179
 
99
180
  const sqliteTxQueues = new Map<string, Promise<void>>();
@@ -138,6 +219,8 @@ export async function openDatabase(
138
219
  await baselineV2(db);
139
220
  }
140
221
  migrate(dbx, { migrationsFolder: sqliteMigrationsDir });
222
+ ensureEventCountsTable(db);
223
+ backfillEventCounts(db);
141
224
  backfillEventUsage(db);
142
225
 
143
226
  dbx
@@ -358,8 +441,12 @@ export class SqliteBackend implements Backend {
358
441
  return scoped.orderBy(tSessionEvents.fSequence).all() as unknown as EventRow[];
359
442
  }
360
443
 
444
+ // 只取类型:rewind 的边界 / 窗口探测不该把整个事件 JSON(f_data)拖回来。
361
445
  async getEventTypeAt(id: SessionId, sequence: number): Promise<string | undefined> {
362
- const row = this.eventRows()
446
+ const row = this.db
447
+ .select({ fType: tEvents.fType })
448
+ .from(tSessionEvents)
449
+ .innerJoin(tEvents, eq(tSessionEvents.fEventId, tEvents.fEventId))
363
450
  .where(and(eq(tSessionEvents.fSessionId, id), eq(tSessionEvents.fSequence, sequence)))
364
451
  .get() as { fType?: string } | undefined;
365
452
  return row?.fType;
@@ -370,7 +457,10 @@ export class SqliteBackend implements Backend {
370
457
  beforeSequence: number,
371
458
  limit: number,
372
459
  ): Promise<Array<Pick<EventRow, "fSequence" | "fType">>> {
373
- return this.eventRows()
460
+ return this.db
461
+ .select({ fSequence: tSessionEvents.fSequence, fType: tEvents.fType })
462
+ .from(tSessionEvents)
463
+ .innerJoin(tEvents, eq(tSessionEvents.fEventId, tEvents.fEventId))
374
464
  .where(and(eq(tSessionEvents.fSessionId, id), lt(tSessionEvents.fSequence, beforeSequence)))
375
465
  .orderBy(desc(tSessionEvents.fSequence))
376
466
  .limit(limit)
@@ -597,36 +687,65 @@ export class SqliteBackend implements Backend {
597
687
  return Number(info.changes);
598
688
  }
599
689
 
600
- /** 用量聚合:只读 `t_event_usage`;可选只算 `sinceMs` 之后的事件行。 */
690
+ /**
691
+ * 用量聚合:token 用量沿 `t_event_usage` 的用量行,活动计数(轮次 / 步骤 / 用户输入 / 工具调用)
692
+ * 沿 `t_events` 的事件类型数——两者限定在**有 token 用量的会话**里,时间范围各自按自己的
693
+ * `f_created_at`(同一时刻写入,口径一致)。
694
+ */
601
695
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
602
- const sinceClause = sinceMs === undefined ? "" : " AND u.f_created_at >= ?";
603
- const sinceParams = sinceMs === undefined ? [] : [sinceMs];
696
+ const usageSince = sinceMs === undefined ? "" : " AND u.f_created_at >= ?";
697
+ const params = sinceMs === undefined ? [] : [sinceMs];
698
+ // 活动计数走派生表的「本地日」列(与桶的 localtime 口径一致)。
699
+ const daySince = sinceMs === undefined ? "" : " AND c.f_day >= ?";
700
+ const dayParams = sinceMs === undefined ? [] : [localDayKey(sinceMs)];
701
+ const subagentOfUsage = SUBAGENT_OF_EVENT.replace("%EVENT%", "u.f_event_id");
702
+
604
703
  const buckets = this.db.$client
605
704
  .prepare(
606
705
  `SELECT date(u.f_created_at / 1000, 'unixepoch', 'localtime') AS day,
607
706
  u.f_provider AS provider,
608
707
  u.f_model AS model,
609
- EXISTS (SELECT 1 FROM t_session_events sb
610
- JOIN t_sessions ss ON ss.f_session_id = sb.f_session_id
611
- WHERE sb.f_event_id = u.f_event_id AND ss.f_origin = 'subagent') AS subagent,
612
- count(*) AS events,
708
+ ${subagentOfUsage} AS subagent,
613
709
  sum(u.f_input_tokens) AS input_tokens,
614
710
  sum(u.f_output_tokens) AS output_tokens,
615
711
  sum(u.f_cache_read_tokens) AS cache_read_tokens,
616
712
  sum(u.f_reasoning_tokens) AS reasoning_tokens,
617
713
  sum(u.f_total_tokens) AS total_tokens
618
714
  FROM t_event_usage u
619
- WHERE EXISTS (SELECT 1 FROM t_session_events rb WHERE rb.f_event_id = u.f_event_id)${sinceClause}
715
+ WHERE EXISTS (SELECT 1 FROM t_session_events rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
620
716
  GROUP BY day, provider, model, subagent`,
621
717
  )
622
- .all(...sinceParams) as unknown as RawUsageRow[];
623
- const sessions = this.db.$client
718
+ .all(...params) as unknown as RawBucketRow[];
719
+ const tokenScope = this.db.$client
720
+ .prepare(
721
+ `SELECT ${subagentOfUsage} AS subagent,
722
+ sum(u.f_input_tokens) AS input_tokens,
723
+ sum(u.f_output_tokens) AS output_tokens,
724
+ sum(u.f_cache_read_tokens) AS cache_read_tokens,
725
+ sum(u.f_reasoning_tokens) AS reasoning_tokens,
726
+ sum(u.f_total_tokens) AS total_tokens
727
+ FROM t_event_usage u
728
+ WHERE EXISTS (SELECT 1 FROM t_session_events rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
729
+ GROUP BY subagent`,
730
+ )
731
+ .all(...params) as unknown as RawScopeRow[];
732
+ const activityScope = this.db.$client
733
+ .prepare(
734
+ `SELECT (s.f_origin = 'subagent') AS subagent,
735
+ c.f_type AS f_type,
736
+ sum(c.f_count) AS n
737
+ FROM t_event_counts c
738
+ JOIN t_sessions s ON s.f_session_id = c.f_session_id
739
+ WHERE ${hasUsageSession("c.f_session_id")}${daySince}
740
+ GROUP BY subagent, c.f_type`,
741
+ )
742
+ .all(...dayParams) as unknown as Array<RawCountRow & { subagent: number }>;
743
+ const tokenSessions = this.db.$client
624
744
  .prepare(
625
745
  `SELECT b.f_session_id AS session_id,
626
746
  s.f_title AS title,
627
747
  (s.f_origin = 'subagent') AS subagent,
628
748
  (s.f_archived_at IS NOT NULL) AS archived,
629
- count(*) AS events,
630
749
  sum(u.f_input_tokens) AS input_tokens,
631
750
  sum(u.f_output_tokens) AS output_tokens,
632
751
  sum(u.f_cache_read_tokens) AS cache_read_tokens,
@@ -635,30 +754,90 @@ export class SqliteBackend implements Backend {
635
754
  FROM t_session_events b
636
755
  JOIN t_event_usage u ON u.f_event_id = b.f_event_id
637
756
  JOIN t_sessions s ON s.f_session_id = b.f_session_id
638
- WHERE 1 = 1${sinceClause}
757
+ WHERE 1 = 1${usageSince}
639
758
  GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at`,
640
759
  )
641
- .all(...sinceParams) as unknown as Array<
642
- RawUsageRow & { session_id: string; title: string | null; archived: number }
643
- >;
760
+ .all(...params) as unknown as RawSessionTokenRow[];
761
+ const activitySessions = this.db.$client
762
+ .prepare(
763
+ `SELECT c.f_session_id AS session_id,
764
+ c.f_type AS f_type,
765
+ sum(c.f_count) AS n
766
+ FROM t_event_counts c
767
+ WHERE ${hasUsageSession("c.f_session_id")}${daySince}
768
+ GROUP BY c.f_session_id, c.f_type`,
769
+ )
770
+ .all(...dayParams) as unknown as RawSessionCountRow[];
771
+ const human = emptyTotals();
772
+ const subagent = emptyTotals();
773
+ for (const row of tokenScope) {
774
+ addTokenTotals(row.subagent === 1 ? subagent : human, tokenTotalsOf(row));
775
+ }
776
+ for (const row of activityScope) {
777
+ addActivityCount(row.subagent === 1 ? subagent : human, row.f_type, row.n ?? 0);
778
+ }
779
+ const totals = addTotals(addTotals(emptyTotals(), human), subagent);
780
+
781
+ const activityBySession = new Map<string, UsageActivityTotals>();
782
+ for (const row of activitySessions) {
783
+ const target = activityBySession.get(row.session_id) ?? emptyActivity();
784
+ addActivityCount(target, row.f_type, row.n ?? 0);
785
+ activityBySession.set(row.session_id, target);
786
+ }
644
787
  return {
788
+ totals,
789
+ subagent,
790
+ human,
645
791
  buckets: buckets.map((row) => ({
646
792
  day: row.day,
647
793
  provider: row.provider,
648
794
  model: row.model,
649
795
  subagent: row.subagent === 1,
650
- ...totalsOf(row),
796
+ ...tokenTotalsOf(row),
651
797
  })),
652
- sessions: sessions.map((row) => ({
798
+ sessions: tokenSessions.map((row) => ({
653
799
  sessionId: row.session_id,
654
800
  title: row.title,
655
801
  subagent: row.subagent === 1,
656
802
  archived: row.archived === 1,
657
- ...totalsOf(row),
803
+ ...tokenTotalsOf(row),
804
+ ...(activityBySession.get(row.session_id) ?? emptyActivity()),
658
805
  })),
659
806
  };
660
807
  }
661
808
 
809
+ /** 活动计数旁路累加:派生表 `t_event_counts`,不在写事务里(失败可丢,表可销毁重建)。 */
810
+ async incrementEventCounts(id: SessionId, buckets: readonly EventCountBucket[]): Promise<void> {
811
+ if (buckets.length === 0) return;
812
+ const statement = this.db.$client.prepare(
813
+ `INSERT INTO t_event_counts (f_session_id, f_day, f_type, f_count) VALUES (?, ?, ?, ?)
814
+ ON CONFLICT(f_session_id, f_day, f_type) DO UPDATE SET f_count = f_count + excluded.f_count`,
815
+ );
816
+ for (const bucket of buckets) statement.run(id, bucket.day, bucket.type, bucket.count);
817
+ }
818
+
819
+ /** 按会话重算活动计数(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
820
+ async rebuildEventCounts(id: SessionId): Promise<void> {
821
+ this.db.$client.prepare("DELETE FROM t_event_counts WHERE f_session_id = ?").run(id);
822
+ this.db.$client
823
+ .prepare(
824
+ `INSERT INTO t_event_counts (f_session_id, f_day, f_type, f_count)
825
+ SELECT b.f_session_id,
826
+ date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
827
+ e.f_type,
828
+ count(*)
829
+ FROM t_session_events b
830
+ JOIN t_events e ON e.f_event_id = b.f_event_id
831
+ WHERE b.f_session_id = ? AND e.f_type IN (${COUNTED_EVENT_TYPE_SQL})
832
+ GROUP BY 1, 2, 3`,
833
+ )
834
+ .run(id);
835
+ }
836
+
837
+ async deleteEventCounts(id: SessionId): Promise<void> {
838
+ this.db.$client.prepare("DELETE FROM t_event_counts WHERE f_session_id = ?").run(id);
839
+ }
840
+
662
841
  async vacuum(): Promise<void> {
663
842
  await enqueueSqliteTx(this.dbPath, async () => {
664
843
  this.db.$client.exec("VACUUM");
package/src/usage.ts CHANGED
@@ -48,9 +48,8 @@ function parseUsageRange(value: unknown): UsageRangeKey {
48
48
  : "all";
49
49
  }
50
50
 
51
- /** 一段用量合计(token 字段直接取事件里模型报的 usage)。 */
52
- export interface UsageTotals {
53
- events: number;
51
+ /** 一段 token 用量(字段直接取事件里模型报的 usage)。 */
52
+ export interface UsageTokenTotals {
54
53
  inputTokens: number;
55
54
  outputTokens: number;
56
55
  cacheReadTokens: number;
@@ -58,8 +57,25 @@ export interface UsageTotals {
58
57
  totalTokens: number;
59
58
  }
60
59
 
61
- /** 一天 × 一个模型 × 是否子代理 的用量桶:总览、按天、按模型都由它折叠。 */
62
- export interface UsageBucket extends UsageTotals {
60
+ /**
61
+ * 活动计数:与 token 用量同一时间范围、同一去重口径(只算被会话引用的事件行),
62
+ * 按事件类型数出来——「轮次 / 步骤 / 用户输入 / 工具调用」比「事件行数」有信息量。
63
+ */
64
+ export interface UsageActivityTotals {
65
+ turns: number;
66
+ steps: number;
67
+ userInputs: number;
68
+ toolCalls: number;
69
+ }
70
+
71
+ /** 总量与按会话行用的一整套指标(token + 活动)。 */
72
+ export interface UsageTotals extends UsageTokenTotals, UsageActivityTotals {}
73
+
74
+ /**
75
+ * 一天 × 一个模型 × 是否子代理 的用量桶:总览、按天、按模型都由它折叠。
76
+ * 只有 token 用量——活动计数没有模型归属(一个轮次可能跨模型),挂在总量与按会话行上。
77
+ */
78
+ export interface UsageBucket extends UsageTokenTotals {
63
79
  day: string;
64
80
  provider: string | null;
65
81
  model: string | null;
@@ -74,25 +90,63 @@ export interface UsageSessionRow extends UsageTotals {
74
90
  archived: boolean;
75
91
  }
76
92
 
77
- /** 后端的原始聚合结果。 */
93
+ /** 后端的原始聚合结果(总量 / 其中子代理 / 人类、按天×模型的桶、按会话的行)。 */
78
94
  export interface UsageAggregate {
79
- buckets: UsageBucket[];
80
- sessions: UsageSessionRow[];
81
- }
82
-
83
- /** 一次统计请求的完整回报。 */
84
- export interface SessionUsageReport {
85
95
  totals: UsageTotals;
86
96
  subagent: UsageTotals;
87
- /** 只被人类会话引用的部分(`totals − subagent`)。 */
88
97
  human: UsageTotals;
89
98
  buckets: UsageBucket[];
90
99
  sessions: UsageSessionRow[];
91
100
  }
92
101
 
93
- function emptyTotals(): UsageTotals {
102
+ /** 一次统计请求的完整回报。 */
103
+ export type SessionUsageReport = UsageAggregate;
104
+
105
+ /** 计入活动计数的事件类型(轮次 / 步骤 / 用户输入 / 工具调用)。 */
106
+ export const COUNTED_EVENT_TYPES = [
107
+ "turn/start",
108
+ "step/start",
109
+ "user/message",
110
+ "tool/call",
111
+ ] as const;
112
+
113
+ /** 按事件类型把计数累加到活动指标上(表里按类型存,读的时候折成四项)。 */
114
+ export function addActivityCount(
115
+ target: UsageActivityTotals,
116
+ type: string,
117
+ count: number,
118
+ ): UsageActivityTotals {
119
+ switch (type) {
120
+ case "turn/start":
121
+ target.turns += count;
122
+ return target;
123
+ case "step/start":
124
+ target.steps += count;
125
+ return target;
126
+ case "user/message":
127
+ target.userInputs += count;
128
+ return target;
129
+ case "tool/call":
130
+ target.toolCalls += count;
131
+ return target;
132
+ default:
133
+ return target;
134
+ }
135
+ }
136
+
137
+ /** 毫秒时间戳 → host 本地日(`YYYY-MM-DD`,与 SQL 的 localtime 口径一致)。 */
138
+ export function localDayKey(ms: number): string {
139
+ const date = new Date(ms);
140
+ const pad = (value: number): string => String(value).padStart(2, "0");
141
+ return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
142
+ }
143
+
144
+ export function emptyTotals(): UsageTotals {
94
145
  return {
95
- events: 0,
146
+ turns: 0,
147
+ steps: 0,
148
+ userInputs: 0,
149
+ toolCalls: 0,
96
150
  inputTokens: 0,
97
151
  outputTokens: 0,
98
152
  cacheReadTokens: 0,
@@ -101,18 +155,31 @@ function emptyTotals(): UsageTotals {
101
155
  };
102
156
  }
103
157
 
104
- /** 折叠一组行(桶或会话行)的用量。 */
105
- export function sumUsage(rows: readonly UsageTotals[]): UsageTotals {
106
- const totals = emptyTotals();
107
- for (const row of rows) {
108
- totals.events += row.events;
109
- totals.inputTokens += row.inputTokens;
110
- totals.outputTokens += row.outputTokens;
111
- totals.cacheReadTokens += row.cacheReadTokens;
112
- totals.reasoningTokens += row.reasoningTokens;
113
- totals.totalTokens += row.totalTokens;
114
- }
115
- return totals;
158
+ export function addTokenTotals(target: UsageTokenTotals, row: UsageTokenTotals): UsageTokenTotals {
159
+ target.inputTokens += row.inputTokens;
160
+ target.outputTokens += row.outputTokens;
161
+ target.cacheReadTokens += row.cacheReadTokens;
162
+ target.reasoningTokens += row.reasoningTokens;
163
+ target.totalTokens += row.totalTokens;
164
+ return target;
165
+ }
166
+
167
+ export function addActivityTotals(
168
+ target: UsageActivityTotals,
169
+ row: UsageActivityTotals,
170
+ ): UsageActivityTotals {
171
+ target.turns += row.turns;
172
+ target.steps += row.steps;
173
+ target.userInputs += row.userInputs;
174
+ target.toolCalls += row.toolCalls;
175
+ return target;
176
+ }
177
+
178
+ /** 把一行的整套指标累加进目标(后端合并「是否子代理」两组时用)。 */
179
+ export function addTotals(target: UsageTotals, row: UsageTotals): UsageTotals {
180
+ addActivityTotals(target, row);
181
+ addTokenTotals(target, row);
182
+ return target;
116
183
  }
117
184
 
118
185
  /**
@@ -167,14 +234,11 @@ export function registerSessionUsage(ctx: Context, persistence: SessionPersisten
167
234
  }
168
235
  }
169
236
  try {
170
- const aggregate = await persistence.usageReport(
237
+ const report = await persistence.usageReport(
171
238
  resolveUsageSince(parseUsageRange(envelope.range), Date.now()),
172
239
  );
173
- const totals = sumUsage(aggregate.buckets);
174
- const subagent = sumUsage(aggregate.buckets.filter((bucket) => bucket.subagent));
175
- const human = sumUsage(aggregate.buckets.filter((bucket) => !bucket.subagent));
176
240
  res.writeHead(200, { "content-type": "application/json" });
177
- res.end(JSON.stringify({ totals, subagent, human, ...aggregate }));
241
+ res.end(JSON.stringify(report));
178
242
  } catch (error: unknown) {
179
243
  res.writeHead(500, { "content-type": "application/json" });
180
244
  res.end(