@krtclcdy/tersio 2.25.5 → 2.25.6

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 (73) hide show
  1. package/README.md +17 -36
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/cli/backups.js +7 -14
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +28 -12
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +66 -35
  8. package/dist/cli/doctor-fix.js +204 -19
  9. package/dist/cli/doctor.js +226 -39
  10. package/dist/cli/hosts.js +25 -5
  11. package/dist/cli/install.js +144 -48
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +87 -7
  14. package/dist/cli/profile.js +13 -8
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +3 -3
  18. package/dist/cli/update.js +6 -7
  19. package/dist/cli/usage.js +6 -9
  20. package/dist/extensions/caveman/index.js +4 -2
  21. package/dist/extensions/caveman/rule-megacave.md +5 -3
  22. package/dist/extensions/caveman/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +55 -45
  24. package/dist/extensions/combo/index.js +62 -21
  25. package/dist/extensions/commands/index.js +1 -1
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/rtk/filter.js +0 -1
  29. package/dist/extensions/rtk/session.js +4 -4
  30. package/dist/extensions/server.js +90 -6
  31. package/dist/extensions/shared/agent-stats.js +214 -278
  32. package/dist/extensions/shared/brand-marks.js +33 -34
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +11 -6
  35. package/dist/extensions/shared/pricing.js +8 -16
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +53 -41
  42. package/dist/extensions/shared/usage-store.js +111 -52
  43. package/dist/extensions/shared/utils.js +5 -5
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/updater/index.js +3 -6
  46. package/dist/tersio.js +8 -3
  47. package/extensions/caveman/index.ts +4 -2
  48. package/extensions/caveman/rule-megacave.md +5 -3
  49. package/extensions/caveman/rule-ultra.md +4 -4
  50. package/extensions/caveman/rule.md +55 -45
  51. package/extensions/combo/index.ts +62 -19
  52. package/extensions/commands/index.ts +1 -1
  53. package/extensions/pstack/index.ts +128 -0
  54. package/extensions/pstack/rule.md +65 -0
  55. package/extensions/rtk/filter.ts +0 -1
  56. package/extensions/rtk/session.ts +4 -4
  57. package/extensions/server.ts +72 -7
  58. package/extensions/shared/agent-stats.ts +226 -278
  59. package/extensions/shared/brand-marks.ts +34 -34
  60. package/extensions/shared/carbon.ts +1 -1
  61. package/extensions/shared/plugin-settings.ts +12 -6
  62. package/extensions/shared/pricing.ts +10 -26
  63. package/extensions/shared/rtk-gain.ts +4 -9
  64. package/extensions/shared/session-state.ts +18 -13
  65. package/extensions/shared/sqlite.ts +168 -0
  66. package/extensions/shared/status.ts +9 -8
  67. package/extensions/shared/types.ts +2 -1
  68. package/extensions/shared/usage-ledger.ts +60 -47
  69. package/extensions/shared/usage-store.ts +120 -52
  70. package/extensions/shared/utils.ts +5 -5
  71. package/extensions/timing/index.ts +45 -0
  72. package/extensions/updater/index.ts +3 -8
  73. package/package.json +17 -8
@@ -1,12 +1,9 @@
1
- // extensions/shared/agent-stats.ts: read-only aggregate over the agent statistics databases.
2
- // Today that is omp's stats.db and agent.db, so the naming stays host-neutral. Every metric
3
- // matches @oh-my-pi/omp-stats: same SQL facts, same rules. Missing sqlite3 or a
4
- // missing table degrades to zeros, never to a thrown error.
5
- import { execFileSync } from 'node:child_process';
1
+ // Read-only aggregate over the usage store; missing SQLite, table, or metric reads as zeros.
6
2
  import fs from 'node:fs';
7
3
  import os from 'node:os';
8
4
  import path from 'node:path';
9
- import { SQLITE_READ_BUFFER } from './usage-ledger.ts';
5
+ import { sqliteRead } from './sqlite.ts';
6
+ import { usageDbPath } from './usage-store.ts';
10
7
 
11
8
  export type RangeKey = '1h' | '24h' | '7d' | '30d' | '90d' | 'all';
12
9
 
@@ -42,7 +39,7 @@ const VIEW_PARTS: Record<AgentView, readonly string[]> = {
42
39
  costs: ['overall', 'byModel', 'series', 'modelSeries'],
43
40
  // The Carbon page needs the same two facts as Costs: per-model output tokens and the mix.
44
41
  carbon: ['overall', 'byModel'],
45
- requests: ['recent'],
42
+ requests: ['overall', 'recent'],
46
43
  errors: ['errors', 'overall', 'byModel', 'recent'],
47
44
  traces: ['traces'],
48
45
  tools: ['overall', 'tools'],
@@ -79,14 +76,91 @@ const TOOL_MODEL_LIMIT = 300;
79
76
  const SERIES_PROVIDERS = 8;
80
77
  const SERIES_MODELS = 12;
81
78
 
79
+ /** The store every metric but subscription quota reads, kept in step by `tersio usage`. */
82
80
  export function statsDbPath(): string {
83
- return process.env.TERSIO_AGENT_STATS_DB ?? path.join(os.homedir(), '.omp', 'stats.db');
81
+ return process.env.TERSIO_AGENT_STATS_DB ?? usageDbPath();
84
82
  }
85
83
 
84
+ /** Quota snapshots. Only omp records these, so this stays omp's own database. */
86
85
  export function agentDbPath(): string {
87
86
  return process.env.TERSIO_AGENT_SESSIONS_DB ?? path.join(os.homedir(), '.omp', 'agent', 'agent.db');
88
87
  }
89
88
 
89
+ /** The row shape every query groups by: a message joined to its file, since path and facts live apart. */
90
+ const SOURCE = 'messages m JOIN files f ON f.id = m.file_id';
91
+
92
+ /** The transcript path with any host-side row suffix removed, so one file groups as one session. */
93
+ const FILE_KEY = `substr(f.path, 1, instr(f.path || '#', '#') - 1)`;
94
+
95
+ /** Model-id spellings whose dashboard mark lives under another name; the first segment names the gateway. */
96
+ const PROVIDER_ALIASES: Readonly<Record<string, string>> = {
97
+ 'opencode-zen': 'opencode',
98
+ 'opencode-go': 'opencode',
99
+ 'gmi-cloud': 'gmicloud',
100
+ 'charm-hyper': 'charmhyper',
101
+ 'kimi-code': 'kimi',
102
+ 'openai-codex': 'openai',
103
+ 'github-copilot': 'githubcopilot',
104
+ 'ollama-cloud': 'ollama',
105
+ 'amd-radeon-cloud-cn': 'amd',
106
+ 'google-antigravity': 'google',
107
+ devin: 'cognition',
108
+ };
109
+
110
+ /** SQL: the gateway a model id names, from its first segment. Empty when the id carries none, not a guess. */
111
+ function providerExpr(model: string): string {
112
+ const seg = `substr(${model}, 1, instr(${model} || '/', '/') - 1)`;
113
+ const named = `ltrim(${seg}, '~')`;
114
+ const cases = Object.entries(PROVIDER_ALIASES)
115
+ .map(([from, to]) => `WHEN '${from}' THEN '${to}'`)
116
+ .join(' ');
117
+ return `CASE WHEN instr(${model}, '/') = 0 THEN '' ELSE CASE ${named} ${cases} ELSE ${named} END END`;
118
+ }
119
+
120
+ /** SQL: the project a transcript belongs to, named by its session folder. A flat session tree uses its data dir. */
121
+ function projectExpr(file: string): string {
122
+ const marker = `instr(${file}, '/sessions/')`;
123
+ const seg = `substr(${file}, ${marker} + 10, instr(substr(${file}, ${marker} + 10) || '/', '/') - 1)`;
124
+ const base = `replace(${file}, rtrim(${file}, replace(${file}, '/', '')), '')`;
125
+ const dir = `substr(${file}, 1, length(${file}) - length(${base}) - 1)`;
126
+ const dirBase = `replace(${dir}, rtrim(${dir}, replace(${dir}, '/', '')), '')`;
127
+ return `CASE WHEN ${marker} > 0 THEN ${seg} ELSE ${dirBase} END`;
128
+ }
129
+
130
+ /** Every column a query reads, mapped to the store's schema in one place. */
131
+ const COL = {
132
+ session: FILE_KEY,
133
+ entry: `COALESCE(m.id, '')`,
134
+ folder: projectExpr(FILE_KEY),
135
+ model: 'm.model',
136
+ provider: providerExpr('m.model'),
137
+ /** No route name is stored, and the Requests page reads one. */
138
+ api: "''",
139
+ timestamp: 'm.t',
140
+ // Bucketing form of the timestamp. REAL division does not floor, so the cast is needed.
141
+ bucket: 'CAST(COALESCE(m.t, 0) AS INTEGER)',
142
+ duration: 'm.d',
143
+ /** First-token latency. A host that records none leaves it NULL, which reads null, never zero. */
144
+ ttft: 'm.tf',
145
+ stopReason: `CASE m.st WHEN 'error' THEN 'error' WHEN 'aborted' THEN 'aborted' ELSE 'stop' END`,
146
+ errorMessage: 'm.note',
147
+ input: 'm.i',
148
+ output: 'm.o',
149
+ cacheRead: 'm.cr',
150
+ cacheWrite: 'm.cw',
151
+ total: '(m.i + m.o + m.cr + m.cw)',
152
+ /** The cost the host reported. A model the host priced nothing for reads $0.00 by design. */
153
+ costTotal: 'COALESCE(m.usd, 0)',
154
+ agentType: `COALESCE(m.a, 'main')`,
155
+ host: `COALESCE(m.h, '')`,
156
+ /** The store holds no per-component split, so the remainder rule below finds nothing to move. */
157
+ costInput: '0',
158
+ costCacheRead: '0',
159
+ costCacheWrite: '0',
160
+ /** No route is a subscription route here, so nothing is ever flagged unpriced. */
161
+ unpriced: '0',
162
+ } as const;
163
+
90
164
  export interface AgentTokenMix {
91
165
  input: number;
92
166
  output: number;
@@ -140,7 +214,6 @@ export interface AgentRow extends AgentTokenMix {
140
214
  key: string;
141
215
  /** The provider this row belongs to, empty when the group is not per provider. */
142
216
  provider: string;
143
- /** The row's estimate split by billing component. */
144
217
  costMix: AgentCostMix;
145
218
  requests: number;
146
219
  failed: number;
@@ -268,7 +341,7 @@ export interface AgentWindowPoint {
268
341
  exhausted: boolean;
269
342
  }
270
343
 
271
- /** The Requests page's summary row, computed over the loaded window. */
344
+ /** The Requests page summary row: whole-range counts and totals; `shipped` counts rows that rode along. */
272
345
  export interface AgentRequestStats {
273
346
  requests: number;
274
347
  failed: number;
@@ -276,6 +349,8 @@ export interface AgentRequestStats {
276
349
  tokens: number;
277
350
  costUsd: number;
278
351
  unpriced: number;
352
+ /** Rows that rode along with this payload, at most `RECENT_LIMIT`. */
353
+ shipped: number;
279
354
  medianDurationMs: number;
280
355
  p95DurationMs: number;
281
356
  medianTtftMs: number;
@@ -453,89 +528,29 @@ export function emptyAgentStats(range: RangeKey = '24h'): AgentStats {
453
528
  tools: [], toolsByModel: [], toolSeries: [],
454
529
  usageSeries: [], windowInsights: [], providerHourly: [],
455
530
  requestStats: {
456
- requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0,
531
+ requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0, shipped: 0,
457
532
  medianDurationMs: 0, p95DurationMs: 0, medianTtftMs: 0, oldest: null, newest: null,
458
533
  },
459
534
  errors: [],
460
535
  };
461
536
  }
462
537
 
463
- // ---------------------------------------------------------------- sqlite access
464
-
465
- /**
466
- * The omp databases run in WAL mode. Ordered fallbacks, each verified against the live pair:
467
- * `-readonly` needs the `-shm` side file, `immutable=1` silently ignores a WAL, and a copy of
468
- * db + wal + shm replays the log to the same row count the running server reports.
469
- */
538
+ /** Rows from a read-only script, tab-separated: whichever engine this host has answers it. */
470
539
  export function sqliteScript(db: string, script: string): string {
471
- if (!fs.existsSync(db)) throw new Error(`missing database: ${db}`);
472
- const opts = {
473
- encoding: 'utf8' as const,
474
- timeout: 30_000,
475
- stdio: ['pipe', 'pipe', 'ignore'] as ['pipe', 'pipe', 'ignore'],
476
- maxBuffer: SQLITE_READ_BUFFER,
477
- input: script,
478
- };
479
- try {
480
- return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
481
- } catch { /* the side files may be gone; try the next rung */ }
482
- if (!fs.existsSync(`${db}-wal`)) {
483
- try {
484
- return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
485
- } catch { /* fall through to the copy */ }
486
- }
487
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-omp-'));
488
- const target = path.join(dir, path.basename(db));
489
- try {
490
- fs.copyFileSync(db, target);
491
- for (const suffix of ['-wal', '-shm']) {
492
- if (fs.existsSync(`${db}${suffix}`)) fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
493
- }
494
- return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
495
- } finally {
496
- fs.rmSync(dir, { recursive: true, force: true });
497
- }
540
+ return sqliteRead(db, script);
498
541
  }
499
542
 
500
- const BLOCK = '@@blk@@';
501
-
502
- function blocks(out: string): Map<string, string[][]> {
543
+ /** One call per query: an unavailable table leaves its key absent, so a partial schema still reads. */
544
+ function readBlocks(db: string, queries: Array<[name: string, sql: string]>): Map<string, string[][]> {
503
545
  const map = new Map<string, string[][]>();
504
- let current: string[][] | null = null;
505
- for (const line of out.split('\n')) {
506
- if (!line) continue;
507
- if (line.startsWith(BLOCK)) {
508
- current = [];
509
- map.set(line.slice(BLOCK.length), current);
510
- continue;
511
- }
512
- if (current) current.push(line.split('\t'));
546
+ for (const [name, sql] of queries) {
547
+ try {
548
+ map.set(name, sqliteScript(db, sql).split('\n').filter((line) => line.trim()).map((line) => line.split('\t')));
549
+ } catch { /* an unavailable table leaves its key absent */ }
513
550
  }
514
551
  return map;
515
552
  }
516
553
 
517
- /**
518
- * One sqlite3 call for a group of queries. A failing statement aborts the script, so the
519
- * partial output is kept and only the missing blocks re-run one at a time.
520
- */
521
- function readBlocks(db: string, queries: Array<[name: string, sql: string]>): Map<string, string[][]> {
522
- const script = queries.map(([name, sql]) => `SELECT '${BLOCK}${name}';\n${sql};\n`).join('');
523
- try {
524
- return blocks(sqliteScript(db, script));
525
- } catch (error) {
526
- // A failing statement aborts the script, so its stdout holds every block that ran.
527
- if (!error || typeof error !== 'object' || !('stdout' in error) || typeof error.stdout !== 'string') throw error;
528
- const merged = blocks(error.stdout);
529
- for (const [name, sql] of queries) {
530
- if (merged.has(name)) continue;
531
- try {
532
- for (const [k, rows] of blocks(sqliteScript(db, `SELECT '${BLOCK}${name}';\n${sql};\n`))) merged.set(k, rows);
533
- } catch { /* an unavailable table leaves its key absent */ }
534
- }
535
- return merged;
536
- }
537
- }
538
-
539
554
  function tableColumns(db: string, table: string): Set<string> {
540
555
  try {
541
556
  const out = sqliteScript(db, `SELECT name FROM pragma_table_info('${table}');\n`);
@@ -561,63 +576,45 @@ function txt(column: string): string {
561
576
  return `replace(replace(replace(COALESCE(${column}, ''), char(10), ' '), char(13), ' '), char(9), ' ')`;
562
577
  }
563
578
 
564
- // ------------------------------------------------------------------- sql builders
565
-
566
- /** The unpriced rule, copied from the reference: a subscription route with no price is not "free". */
567
- function unpricedSql(p = ''): string {
568
- return `CASE WHEN ${p}total_tokens > 0 AND ${p}cost_total = 0
569
- AND (${p}provider = 'xai-oauth' OR ${p}cost_unpriced = 1) THEN 1 ELSE 0 END`;
579
+ /** The unpriced rule, kept for callers that group by it: no route here is a subscription route. */
580
+ function unpricedSql(): string {
581
+ return '0';
570
582
  }
571
583
 
572
584
  /** The additive facts every messages group reads. */
573
- function messageFacts(p = ''): string {
585
+ function messageFacts(): string {
574
586
  return [
575
587
  'COUNT(*)',
576
- `SUM(CASE WHEN ${p}stop_reason = 'error' THEN 1 ELSE 0 END)`,
577
- `SUM(${p}input_tokens)`,
578
- `SUM(${p}output_tokens)`,
579
- `SUM(${p}cache_read_tokens)`,
580
- `SUM(${p}cache_write_tokens)`,
581
- `SUM(${p}total_tokens)`,
582
- `TOTAL(${p}cost_total)`,
583
- `TOTAL(${p}cost_input)`,
584
- `TOTAL(${p}cost_cache_read)`,
585
- `TOTAL(${p}cost_cache_write)`,
586
- `SUM(${unpricedSql(p)})`,
587
- `TOTAL(${p}duration)`,
588
- `COUNT(${p}duration)`,
589
- `TOTAL(${p}ttft)`,
590
- `COUNT(${p}ttft)`,
591
- `TOTAL(CASE WHEN ${p}duration > 0 THEN ${p}output_tokens * 1000.0 / ${p}duration END)`,
592
- `COUNT(CASE WHEN ${p}duration > 0 THEN 1 END)`,
593
- `MIN(${p}timestamp)`,
594
- `MAX(${p}timestamp)`,
595
- `MAX(${p}duration)`,
588
+ `SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)`,
589
+ `SUM(${COL.input})`,
590
+ `SUM(${COL.output})`,
591
+ `SUM(${COL.cacheRead})`,
592
+ `SUM(${COL.cacheWrite})`,
593
+ `SUM(${COL.total})`,
594
+ `TOTAL(${COL.costTotal})`,
595
+ `TOTAL(${COL.costInput})`,
596
+ `TOTAL(${COL.costCacheRead})`,
597
+ `TOTAL(${COL.costCacheWrite})`,
598
+ `SUM(${COL.unpriced})`,
599
+ `TOTAL(${COL.duration})`,
600
+ `COUNT(${COL.duration})`,
601
+ `TOTAL(${COL.ttft})`,
602
+ `COUNT(${COL.ttft})`,
603
+ `TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END)`,
604
+ `COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END)`,
605
+ `MIN(${COL.timestamp})`,
606
+ `MAX(${COL.timestamp})`,
607
+ `MAX(${COL.duration})`,
596
608
  ].join(', ');
597
609
  }
598
610
 
599
- /** The zero and null literals an older omp schema needs in place of a named column. */
600
- const SHIMS: ReadonlyArray<readonly [string, string]> = [
601
- ['stop_reason', "''"],
602
- ['cost_no_cache_input', '0'],
603
- ['cost_input', '0'],
604
- ['cost_cache_read', '0'],
605
- ['cost_cache_write', '0'],
606
- ['cost_unpriced', '0'],
607
- ['provider', "''"],
608
- ['folder', "''"],
609
- ['agent_type', "''"],
610
- ];
611
-
612
- /** Applies the shims: a column the table does not hold reads as its literal. */
613
- function withShims(sql: string, cols: Set<string>): string {
614
- let out = sql;
615
- for (const [column, literal] of SHIMS) {
616
- if (cols.has(column)) continue;
617
- out = out.split(column).join(literal);
618
- }
619
- return out;
620
- }
611
+ /** Column order of a recent or error row, matching `requestColumns` below. */
612
+ const REQUEST_COLUMNS = [
613
+ COL.timestamp, COL.provider, COL.model, COL.folder, COL.api,
614
+ COL.input, COL.output, COL.cacheRead, COL.cacheWrite, COL.total,
615
+ COL.costTotal, COL.unpriced, COL.duration, COL.ttft, COL.stopReason, COL.errorMessage,
616
+ COL.agentType, COL.session, COL.entry,
617
+ ] as const;
621
618
 
622
619
  interface Facts {
623
620
  requests: number; failed: number;
@@ -702,8 +699,6 @@ function round4(v: number | null): number | null {
702
699
  return v === null ? null : Math.round(v * 1e4) / 1e4;
703
700
  }
704
701
 
705
- // ------------------------------------------------------------------- the aggregate
706
-
707
702
  function bucketSize(range: RangeKey, firstTs: number | null, lastTs: number | null): number {
708
703
  const base = BUCKET_MS[range];
709
704
  if (firstTs === null || lastTs === null) return base;
@@ -712,42 +707,32 @@ function bucketSize(range: RangeKey, firstTs: number | null, lastTs: number | nu
712
707
  return Math.max(base, Math.ceil(span / MAX_BUCKETS / base) * base);
713
708
  }
714
709
 
715
- // ------------------------------------------------------------------- the aggregate
716
-
717
- function readStatsDb(db: string, cutoff: number, bucketMs: number, cols: Set<string>, parts: Set<string>): Partial<AgentStats> {
718
- const where = rangeWhere(cutoff);
719
- const errCol = cols.has('error_message') ? txt('error_message') : "''";
720
- const apiCol = cols.has('api') ? txt('api') : "''";
721
- const errorWhere = `WHERE stop_reason = 'error'${cutoff > 0 ? ` AND timestamp >= ${Math.floor(cutoff)}` : ''}`;
722
-
723
- const requestColumns = `timestamp, ${txt('provider')}, ${txt('model')}, ${txt('folder')}, ${apiCol},
724
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
725
- cost_total, ${unpricedSql()}, duration, ttft, ${txt('stop_reason')}, ${errCol},
726
- ${txt('agent_type')}, ${txt('session_file')}, ${txt('entry_id')}`;
710
+ function readStatsDb(db: string, cutoff: number, bucketMs: number, parts: Set<string>, offset = 0): Partial<AgentStats> {
711
+ const where = rangeWhere(cutoff, COL.timestamp);
712
+ const errorWhere = `WHERE ${COL.stopReason} = 'error'${cutoff > 0 ? ` AND ${COL.timestamp} >= ${Math.floor(cutoff)}` : ''}`;
713
+ const from = SOURCE;
727
714
 
728
715
  const out = readBlocks(db, [
729
- ['overall', `SELECT ${messageFacts()} FROM messages ${where}`],
730
- ['byModel', `SELECT ${txt('model')}, ${txt('provider')}, ${messageFacts()} FROM messages ${where} GROUP BY model, provider ORDER BY 3 DESC`],
731
- ['byProvider', `SELECT ${txt('provider')}, COUNT(DISTINCT model), ${messageFacts()} FROM messages ${where} GROUP BY provider ORDER BY 3 DESC`],
732
- ['byAgentType', `SELECT ${txt('agent_type')}, ${messageFacts()} FROM messages ${where} GROUP BY 1 ORDER BY 2 DESC`],
733
- ['byProject', `SELECT ${txt('folder')}, ${messageFacts()} FROM messages ${where} GROUP BY 1 ORDER BY 2 DESC`],
734
- ['series', `SELECT (timestamp / ${bucketMs}) * ${bucketMs}, COUNT(*),
735
- SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END),
736
- SUM(total_tokens), TOTAL(cost_total) FROM messages ${where} GROUP BY 1 ORDER BY 1`],
737
- ['hourOfDay', `SELECT CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER),
738
- COUNT(*), SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END), SUM(total_tokens), TOTAL(cost_total)
739
- FROM messages ${where} GROUP BY 1 ORDER BY 1`],
740
- ['recent', `SELECT ${requestColumns} FROM messages ${where} ORDER BY timestamp DESC LIMIT ${RECENT_LIMIT}`],
741
- ['errors', `SELECT ${requestColumns} FROM messages ${errorWhere} ORDER BY timestamp DESC LIMIT ${ERROR_LIMIT}`],
742
- ['traces', `SELECT ${txt('session_file')}, MAX(${txt('folder')}),
743
- COUNT(*), SUM(total_tokens), TOTAL(cost_total), SUM(${unpricedSql()}),
744
- MIN(timestamp), MAX(timestamp + COALESCE(duration, 0))
745
- FROM messages ${where} GROUP BY session_file ORDER BY 8 DESC LIMIT ${TRACE_LIMIT}`],
746
- ].filter(([name]) => parts.has(name)).map(([name, sql]) => [name, withShims(sql, cols)] as [string, string]));
747
-
748
- // The trace rows are read per file, then folded per root. The model list and the tool counts are
749
- // read across every transcript and keyed by root, so a child that fell outside the window still
750
- // folds into the session it belongs to.
716
+ ['overall', `SELECT ${messageFacts()} FROM ${from} ${where}`],
717
+ ['byModel', `SELECT ${COL.model}, ${COL.provider}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 3 DESC`],
718
+ ['byProvider', `SELECT ${COL.provider}, COUNT(DISTINCT ${COL.model}), ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 3 DESC`],
719
+ ['byAgentType', `SELECT ${COL.agentType}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
720
+ ['byProject', `SELECT ${COL.folder}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
721
+ ['series', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, COUNT(*),
722
+ SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END),
723
+ SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
724
+ ['hourOfDay', `SELECT CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
725
+ COUNT(*), SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END), SUM(${COL.total}), TOTAL(${COL.costTotal})
726
+ FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
727
+ ['recent', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${where} ORDER BY ${COL.timestamp} DESC LIMIT ${RECENT_LIMIT} OFFSET ${Math.max(0, Math.floor(offset))}`],
728
+ ['errors', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${errorWhere} ORDER BY ${COL.timestamp} DESC LIMIT ${ERROR_LIMIT}`],
729
+ ['traces', `SELECT ${COL.session}, MAX(${COL.folder}),
730
+ COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}), SUM(${COL.unpriced}),
731
+ MIN(${COL.timestamp}), MAX(${COL.timestamp} + COALESCE(${COL.duration}, 0))
732
+ FROM ${from} ${where} GROUP BY 1 ORDER BY 8 DESC LIMIT ${TRACE_LIMIT}`],
733
+ ].filter(([name]) => parts.has(name)).map(([name, sql]) => [name, sql] as [string, string]));
734
+
735
+ // Models and tools are keyed by root, so a child outside the window still folds into its session.
751
736
  const traceModels = readTraceModels(db);
752
737
  const traceTools = readTraceToolCounts(db);
753
738
  const traces = foldTraces((out.get('traces') ?? []).map((r) => {
@@ -816,13 +801,7 @@ function readStatsDb(db: string, cutoff: number, bucketMs: number, cols: Set<str
816
801
  };
817
802
  }
818
803
 
819
- /**
820
- * Fold every child transcript into its root session, the way the reference lists them.
821
- *
822
- * A subagent writes its own file under its root's folder, so a row grouped by file alone would
823
- * show one row per subagent. Each root holds its children's requests, tokens, cost, models, and
824
- * time, counts the children folded in, and keeps the row even when its own file is gone.
825
- */
804
+ /** Fold every child transcript into its root session, or a row per file would show one row per subagent. */
826
805
  function foldTraces(rows: AgentTraceRow[]): AgentTraceRow[] {
827
806
  const byRoot = new Map<string, AgentTraceRow>();
828
807
  for (const row of rows) {
@@ -904,9 +883,9 @@ function groupErrors(rows: AgentRequestRow[]): AgentErrorGroup[] {
904
883
  }
905
884
 
906
885
  function readProviderSeries(db: string, cutoff: number, bucketMs: number): Array<{ provider: string; points: AgentBucket[] }> {
907
- const out = readBlocks(db, [['providerSeries', `SELECT ${txt('provider')}, (timestamp / ${bucketMs}) * ${bucketMs},
908
- COUNT(*), SUM(total_tokens), TOTAL(cost_total)
909
- FROM messages ${rangeWhere(cutoff)} GROUP BY 1, 2 ORDER BY 1, 2`]]);
886
+ const out = readBlocks(db, [['providerSeries', `SELECT ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
887
+ COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal})
888
+ FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2 ORDER BY 1, 2`]]);
910
889
  const perProvider = new Map<string, { cost: number; points: AgentBucket[] }>();
911
890
  for (const r of out.get('providerSeries') ?? []) {
912
891
  const provider = r[0] ?? '';
@@ -922,48 +901,44 @@ function readProviderSeries(db: string, cutoff: number, bucketMs: number): Array
922
901
  .map(([provider, held]) => ({ provider, points: held.points }));
923
902
  }
924
903
 
904
+ /** Tool calls from each message's names array: a name is one whole call, so per-call sizes read as zero. */
925
905
  function readToolSeries(db: string, cutoff: number, bucketMs: number): Partial<AgentStats> {
926
- const cols = tableColumns(db, 'tool_calls');
927
- if (!cols.has('tool_name')) return {};
928
- const join = 'LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id';
929
- const where = `WHERE t.timestamp >= ${Math.floor(cutoff)}`;
930
- const facts = (bucket: string, extra: string): string => `
931
- ${bucket},
932
- ${txt('t.tool_name')}, ${txt('t.model')}, ${txt('t.provider')},
906
+ const where = `WHERE ${COL.timestamp} >= ${Math.floor(cutoff)}`;
907
+ const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
908
+ const from = `${SOURCE}, json_each(${names}) te`;
909
+ const facts = `
910
+ ${COL.timestamp},
911
+ te.value, ${COL.model}, ${COL.provider},
933
912
  COUNT(*),
934
- SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END),
935
- SUM(t.args_chars),
936
- SUM(COALESCE(t.result_chars, 0)),
937
- TOTAL(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn),
938
- TOTAL(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn),
939
- TOTAL(COALESCE(m.cost_total, 0) / t.calls_in_turn),
940
- TOTAL(CASE WHEN COALESCE(m.total_tokens, 0) > 0 AND COALESCE(m.cost_total, 0) = 0
941
- AND (m.provider = 'xai-oauth' OR m.cost_unpriced = 1) THEN 1 ELSE 0 END * 1.0 / t.calls_in_turn),
942
- MAX(t.timestamp)${extra}`;
913
+ TOTAL(${COL.total}),
914
+ TOTAL(${COL.output}),
915
+ TOTAL(${COL.costTotal}),
916
+ MAX(${COL.timestamp})`;
943
917
  const out = readBlocks(db, [
944
- ['byTool', `SELECT ${facts("'-'", '')} FROM tool_calls t ${join} ${where} GROUP BY t.tool_name ORDER BY 5 DESC`],
945
- ['byToolModel', `SELECT ${facts("'-'", ', t.model, t.provider')} FROM tool_calls t ${join} ${where}
946
- GROUP BY t.tool_name, t.model, t.provider ORDER BY 5 DESC LIMIT ${TOOL_MODEL_LIMIT}`],
947
- ['toolSeries', `SELECT (t.timestamp / ${bucketMs}) * ${bucketMs}, ${txt('t.tool_name')}, COUNT(*),
948
- SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) FROM tool_calls t ${where} GROUP BY 1, 2 ORDER BY 1`],
918
+ ['byTool', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value ORDER BY 5 DESC`],
919
+ ['byToolModel', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value, ${COL.model}, ${COL.provider}
920
+ ORDER BY 5 DESC LIMIT ${TOOL_MODEL_LIMIT}`],
921
+ ['toolSeries', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, te.value, COUNT(*),
922
+ SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)
923
+ FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 1`],
949
924
  ]);
950
- const parseTool = (r: string[], at: number): AgentToolRow => ({
925
+ const parseTool = (r: string[]): AgentToolRow => ({
951
926
  tool: r[1] ?? '',
952
927
  calls: num(r[4]),
953
- errors: num(r[5]),
954
- argsChars: num(r[6]),
955
- resultChars: num(r[7]),
956
- totalTokensShare: num(r[8]),
957
- outputTokensShare: num(r[9]),
958
- costShare: num(r[10]),
959
- unpricedShare: num(r[11]),
960
- lastUsed: numOrNull(r[12]),
961
- model: r[at] ?? '',
962
- provider: r[at + 1] ?? '',
928
+ errors: 0,
929
+ argsChars: 0,
930
+ resultChars: 0,
931
+ totalTokensShare: num(r[5]),
932
+ outputTokensShare: num(r[6]),
933
+ costShare: num(r[7]),
934
+ unpricedShare: 0,
935
+ lastUsed: numOrNull(r[8]),
936
+ model: r[2] ?? '',
937
+ provider: r[3] ?? '',
963
938
  });
964
939
  return {
965
- tools: (out.get('byTool') ?? []).map((r) => parseTool(r, 13)),
966
- toolsByModel: (out.get('byToolModel') ?? []).map((r) => parseTool(r, 13)),
940
+ tools: (out.get('byTool') ?? []).map(parseTool),
941
+ toolsByModel: (out.get('byToolModel') ?? []).map(parseTool),
967
942
  toolSeries: (out.get('toolSeries') ?? []).map((r) => ({
968
943
  ts: num(r[0]), tool: r[1] ?? '', calls: num(r[2]), errors: num(r[3]),
969
944
  })),
@@ -974,7 +949,9 @@ function readToolSeries(db: string, cutoff: number, bucketMs: number): Partial<A
974
949
  function readTraceToolCounts(db: string): Map<string, number> {
975
950
  const counts = new Map<string, number>();
976
951
  try {
977
- const out = sqliteScript(db, 'SELECT session_file, COUNT(*) FROM tool_calls GROUP BY 1;\n');
952
+ const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
953
+ const out = sqliteScript(db,
954
+ `SELECT ${COL.session}, COUNT(*) FROM ${SOURCE}, json_each(${names}) te GROUP BY 1;\n`);
978
955
  for (const line of out.split('\n')) {
979
956
  if (!line.trim()) continue;
980
957
  const [file, count] = line.split('\t');
@@ -990,7 +967,7 @@ function readTraceToolCounts(db: string): Map<string, number> {
990
967
  function readTraceModels(db: string): Map<string, string[]> {
991
968
  const models = new Map<string, Set<string>>();
992
969
  try {
993
- const out = sqliteScript(db, 'SELECT session_file, model FROM messages GROUP BY session_file, model;\n');
970
+ const out = sqliteScript(db, `SELECT ${COL.session}, ${COL.model} FROM ${SOURCE} GROUP BY 1, 2;\n`);
994
971
  for (const line of out.split('\n')) {
995
972
  if (!line.trim()) continue;
996
973
  const [file, model] = line.split('\t');
@@ -1003,12 +980,7 @@ function readTraceModels(db: string): Map<string, string[]> {
1003
980
  return new Map([...models.entries()].map(([file, set]) => [file, [...set].toSorted()]));
1004
981
  }
1005
982
 
1006
- /**
1007
- * Each root session's own title and working directory, read from the head of its transcript.
1008
- *
1009
- * The `session` line carries the working directory and the `title` line sits above it, so one head
1010
- * read answers both. A rotated or unreadable file leaves the row's folder and file name to speak.
1011
- */
983
+ /** A session's title and cwd: the `session` line carries the directory and the `title` line sits above it. */
1012
984
  function applySessionMetadata(traces: AgentTraceRow[]): void {
1013
985
  for (const trace of traces) {
1014
986
  if (!trace.sessionFile.endsWith('.jsonl')) continue;
@@ -1038,11 +1010,11 @@ function applySessionMetadata(traces: AgentTraceRow[]): void {
1038
1010
 
1039
1011
  /** Daily tokens and cost for the busiest models: the Models table sparkline. */
1040
1012
  function readModelSeries(db: string, cutoff: number, bucketMs: number): Array<{ model: string; points: AgentBucket[] }> {
1041
- const scope = rangeWhere(cutoff);
1042
- const top = `model IN (SELECT model FROM messages ${scope} GROUP BY model ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
1013
+ const scope = rangeWhere(cutoff, COL.timestamp);
1014
+ const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
1043
1015
  const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
1044
- const out = readBlocks(db, [['modelSeries', `SELECT ${txt('model')}, (timestamp / ${bucketMs}) * ${bucketMs},
1045
- COUNT(*), SUM(total_tokens), TOTAL(cost_total) FROM messages ${clause} GROUP BY 1, 2 ORDER BY 1, 2`]]);
1016
+ const out = readBlocks(db, [['modelSeries', `SELECT ${COL.model}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
1017
+ COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${SOURCE} ${clause} GROUP BY 1, 2 ORDER BY 1, 2`]]);
1046
1018
  const perModel = new Map<string, AgentBucket[]>();
1047
1019
  for (const r of out.get('modelSeries') ?? []) {
1048
1020
  const model = r[0] ?? '';
@@ -1053,22 +1025,18 @@ function readModelSeries(db: string, cutoff: number, bucketMs: number): Array<{
1053
1025
  return [...perModel.entries()].map(([model, points]) => ({ model, points }));
1054
1026
  }
1055
1027
 
1056
- /**
1057
- * Throughput and first-token latency per bucket for the busiest models, the chart behind a
1058
- * model's expanded row. Both averages are null in a bucket where no request carried the timing,
1059
- * because a non-null zero would draw a flat line through a real gap.
1060
- */
1028
+ /** Per-bucket throughput and ttft for the busiest models; a null average means no request carried timing. */
1061
1029
  function readModelPerformance(db: string, cutoff: number, bucketMs: number): Array<{ model: string; provider: string; points: AgentModelPerformancePoint[] }> {
1062
- const scope = rangeWhere(cutoff);
1063
- const top = `model IN (SELECT model FROM messages ${scope} GROUP BY model ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
1030
+ const scope = rangeWhere(cutoff, COL.timestamp);
1031
+ const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
1064
1032
  const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
1065
- const out = readBlocks(db, [['modelPerformance', `SELECT ${txt('model')}, ${txt('provider')}, (timestamp / ${bucketMs}) * ${bucketMs},
1033
+ const out = readBlocks(db, [['modelPerformance', `SELECT ${COL.model}, ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
1066
1034
  COUNT(*),
1067
- TOTAL(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration END),
1068
- COUNT(CASE WHEN duration > 0 THEN 1 END),
1069
- TOTAL(ttft),
1070
- COUNT(ttft)
1071
- FROM messages ${clause} GROUP BY 1, 2, 3 ORDER BY 1, 2, 3`]]);
1035
+ TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END),
1036
+ COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END),
1037
+ TOTAL(${COL.ttft}),
1038
+ COUNT(${COL.ttft})
1039
+ FROM ${SOURCE} ${clause} GROUP BY 1, 2, 3 ORDER BY 1, 2, 3`]]);
1072
1040
  const groups = new Map<string, { model: string; provider: string; points: AgentModelPerformancePoint[] }>();
1073
1041
  for (const r of out.get('modelPerformance') ?? []) {
1074
1042
  const key = `${r[0] ?? ''}\u0000${r[1] ?? ''}`;
@@ -1086,8 +1054,6 @@ function readModelPerformance(db: string, cutoff: number, bucketMs: number): Arr
1086
1054
  return [...groups.values()];
1087
1055
  }
1088
1056
 
1089
- // ------------------------------------------------------------ subscription windows
1090
-
1091
1057
  /** Reference constants for the quota half, copied from the omp implementation. */
1092
1058
  const WINDOW_RESET_DROP = 0.05;
1093
1059
  const WINDOW_MIN_EXTRAPOLATION = 0.1;
@@ -1128,10 +1094,7 @@ function downsampleWindow(points: AgentWindowPoint[]): AgentWindowPoint[] {
1128
1094
  return out;
1129
1095
  }
1130
1096
 
1131
- /**
1132
- * Peak of the sum across accounts at any sampled instant, forward filling each account's last
1133
- * known fraction. A peak of 1.7 means demand simultaneously held 1.7 windows of quota.
1134
- */
1097
+ /** Peak summed quota across accounts, forward-filling last known fractions (1.7 = 1.7 windows held). */
1135
1098
  function peakConcurrent(samples: WindowSample[]): number {
1136
1099
  const events = samples.filter((sample) => sample.usedFraction !== null).toSorted((a, b) => a.ts - b.ts);
1137
1100
  const current = new Map<string, number>();
@@ -1177,10 +1140,7 @@ function readWindowSamples(cutoff: number): WindowSample[] {
1177
1140
  });
1178
1141
  }
1179
1142
 
1180
- /**
1181
- * Utilization series and per-window insights. A window is one `(provider, limitId)` pair: distinct
1182
- * limits can share a duration label, and merging them interleaves unrelated fractions per account.
1183
- */
1143
+ /** Window utilization; a window is one `(provider, limitId)` pair, since limits can share a duration. */
1184
1144
  function computeWindows(
1185
1145
  samples: WindowSample[],
1186
1146
  tokensByProvider: Map<string, number>,
@@ -1268,17 +1228,17 @@ function computeWindows(
1268
1228
 
1269
1229
  /** Tokens per provider over the range: the currency a window insight extrapolates in. */
1270
1230
  function readProviderTokens(db: string, cutoff: number): Map<string, number> {
1271
- const out = readBlocks(db, [['providerTokens', `SELECT ${txt('provider')}, SUM(total_tokens)
1272
- FROM messages ${rangeWhere(cutoff)} GROUP BY 1`]]);
1231
+ const out = readBlocks(db, [['providerTokens', `SELECT ${COL.provider}, SUM(${COL.total})
1232
+ FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1`]]);
1273
1233
  return new Map((out.get('providerTokens') ?? []).map((r) => [r[0] ?? '', num(r[1])]));
1274
1234
  }
1275
1235
 
1276
1236
  /** Peak burn hours: one row per provider per local hour, the shape the reference charts. */
1277
1237
  function readProviderHourly(db: string, cutoff: number): AgentProviderHour[] {
1278
- const out = readBlocks(db, [['providerHourly', `SELECT ${txt('provider')},
1279
- CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER),
1280
- SUM(total_tokens), SUM(output_tokens), COUNT(*)
1281
- FROM messages ${rangeWhere(cutoff)} GROUP BY 1, 2`]]);
1238
+ const out = readBlocks(db, [['providerHourly', `SELECT ${COL.provider},
1239
+ CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
1240
+ SUM(${COL.total}), SUM(${COL.output}), COUNT(*)
1241
+ FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2`]]);
1282
1242
  return (out.get('providerHourly') ?? []).map((r) => ({
1283
1243
  provider: r[0] ?? '',
1284
1244
  hour: num(r[1]),
@@ -1296,24 +1256,25 @@ function median(values: Array<number | null | undefined>): number {
1296
1256
  return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
1297
1257
  }
1298
1258
 
1299
- /** The Requests page summary, computed over the rows the payload carries. */
1300
- function computeRequestStats(rows: AgentRequestRow[]): AgentRequestStats {
1259
+ /** Requests summary; percentiles stay over the capped slice, so a p95 of 200 of 2,400 describes those 200. */
1260
+ function computeRequestStats(rows: AgentRequestRow[], range?: AgentOverall): AgentRequestStats {
1301
1261
  const durations = rows.map((row) => row.durationMs);
1302
1262
  const sorted = durations.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
1303
1263
  const pick = (q: number): number => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
1304
1264
  const times = rows.map((row) => row.ts);
1305
1265
  return {
1306
- requests: rows.length,
1307
- failed: rows.filter((row) => row.stopReason === 'error').length,
1266
+ requests: range ? range.requests : rows.length,
1267
+ failed: range ? range.failed : rows.filter((row) => row.stopReason === 'error').length,
1308
1268
  aborted: rows.filter((row) => row.stopReason === 'aborted').length,
1309
- tokens: rows.reduce((sum, row) => sum + row.totalTokens, 0),
1310
- costUsd: rows.reduce((sum, row) => sum + row.costUsd, 0),
1311
- unpriced: rows.filter((row) => row.unpriced).length,
1269
+ tokens: range ? range.total : rows.reduce((sum, row) => sum + row.totalTokens, 0),
1270
+ costUsd: range ? range.costUsd : rows.reduce((sum, row) => sum + row.costUsd, 0),
1271
+ unpriced: range ? range.unpricedRequests : rows.filter((row) => row.unpriced).length,
1272
+ shipped: rows.length,
1312
1273
  medianDurationMs: median(durations),
1313
1274
  p95DurationMs: pick(0.95),
1314
1275
  medianTtftMs: median(rows.map((row) => row.ttftMs)),
1315
- oldest: times.length ? Math.min(...times) : null,
1316
- newest: times.length ? Math.max(...times) : null,
1276
+ oldest: range ? range.firstTs : times.length ? Math.min(...times) : null,
1277
+ newest: range ? range.lastTs : times.length ? Math.max(...times) : null,
1317
1278
  };
1318
1279
  }
1319
1280
 
@@ -1341,15 +1302,7 @@ function stopState(stopReason: string): AgentTraceSpan['stopped'] {
1341
1302
  /** Tool names that hand the work to another agent, for the Agents figure. */
1342
1303
  const AGENT_TOOLS = new Set(['task', 'subagent']);
1343
1304
 
1344
- /**
1345
- * One session transcript as spans, tracks, markers, and totals.
1346
- *
1347
- * An assistant message stamps its completion and its `duration` says when the call began, so a
1348
- * model span runs `ts - duration` to `ts`. A tool call is emitted by the message requesting it and
1349
- * closed by the `toolResult` with the same call id, so the two bound a tool span. A user message
1350
- * opens a turn the next user message closes, and everything that is not a message rides along as a
1351
- * marker, since it happened at a point in time rather than across one.
1352
- */
1305
+ /** One transcript as spans, tracks and markers: a model span runs `ts - duration` to `ts`. */
1353
1306
  export function readSessionTrace(sessionFile: string, limit = TRACE_SPAN_LIMIT): AgentSessionTrace {
1354
1307
  const project = sessionFile.split('/sessions/')[1]?.split('/')[0] ?? '';
1355
1308
  const empty: AgentSessionTrace = {
@@ -1575,12 +1528,7 @@ export function emptyRequestEntry(): { entry: unknown; output: unknown; messageR
1575
1528
  return { entry: null, output: null, messageRole: '', agent: { model: null, thinkingLevel: null, mode: null, fallback: null } };
1576
1529
  }
1577
1530
 
1578
- /**
1579
- * One request's journal payload, read on demand by the request drawer. `entry` is the raw
1580
- * journal line, `output` is the message that line carries, and `agent` is the agent state in
1581
- * force when the request ran. All are empty when the entry journals nothing: a `model_usage`
1582
- * row carries no message, and a deleted session cannot be re-read.
1583
- */
1531
+ /** One request's journal payload; empty when the entry journals nothing or its session is gone. */
1584
1532
  export function readAgentRequestEntry(
1585
1533
  sessionFile: string,
1586
1534
  entryId: string,
@@ -1621,8 +1569,8 @@ export function readAgentRequestEntry(
1621
1569
  }
1622
1570
 
1623
1571
 
1624
- /** Reads both omp databases. `available` is false when stats.db holds no `messages` table. */
1625
- export function readAgentStats(range: RangeKey = '24h', view: AgentView = 'all', now = Date.now()): AgentStats {
1572
+ /** Reads the usage store; the caller runs `syncUsageDb`, so a poll never pays for a sync it did not start. */
1573
+ export function readAgentStats(range: RangeKey = '24h', view: AgentView = 'all', now = Date.now(), offset = 0): AgentStats {
1626
1574
  const span = RANGE_MS[range];
1627
1575
  const cutoff = span > 0 ? now - span : 0;
1628
1576
  const parts = new Set(VIEW_PARTS[view]);
@@ -1637,18 +1585,18 @@ export function readAgentStats(range: RangeKey = '24h', view: AgentView = 'all',
1637
1585
  }
1638
1586
 
1639
1587
  // The bucket width depends on the span, so the bounds come first.
1640
- const bounds = readBlocks(db, [['bounds', `SELECT MIN(timestamp), MAX(timestamp) FROM messages ${rangeWhere(cutoff)}`]]);
1588
+ const bounds = readBlocks(db, [['bounds', `SELECT MIN(${COL.timestamp}), MAX(${COL.timestamp}) FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)}`]]);
1641
1589
  const [firstTs, lastTs] = bounds.get('bounds')?.[0] ?? [];
1642
1590
  stats.bucketMs = bucketSize(range, numOrNull(firstTs), numOrNull(lastTs));
1643
1591
 
1644
- Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, cols, parts));
1592
+ Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, parts, offset));
1645
1593
  if (parts.has('seriesByProvider')) stats.seriesByProvider = readProviderSeries(db, cutoff, stats.bucketMs);
1646
1594
  if (parts.has('modelSeries')) stats.modelSeries = readModelSeries(db, cutoff, stats.bucketMs);
1647
1595
  if (parts.has('modelPerformance')) stats.modelPerformance = readModelPerformance(db, cutoff, stats.bucketMs);
1648
1596
  if (parts.has('tools')) Object.assign(stats, readToolSeries(db, cutoff, stats.bucketMs));
1649
1597
  if (stats.byModel.length > 0) stats.topModels = stats.byModel.slice(0, 12);
1650
1598
  if (parts.has('providerHourly')) stats.providerHourly = readProviderHourly(db, cutoff);
1651
- if (parts.has('recent')) stats.requestStats = computeRequestStats(stats.recent);
1599
+ if (parts.has('recent')) stats.requestStats = computeRequestStats(stats.recent, stats.overall);
1652
1600
  if (parts.has('windows')) {
1653
1601
  Object.assign(stats, computeWindows(readWindowSamples(cutoff), readProviderTokens(db, cutoff)));
1654
1602
  }