@krtclcdy/tersio 2.25.4 → 2.25.5

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 (52) hide show
  1. package/README.md +13 -30
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +194 -0
  4. package/dist/cli/common.js +2 -2
  5. package/dist/cli/dashboard.js +125 -98
  6. package/dist/cli/doctor-fix.js +41 -44
  7. package/dist/cli/doctor.js +69 -7
  8. package/dist/cli/hosts.js +4 -4
  9. package/dist/cli/install.js +71 -32
  10. package/dist/cli/manifest.js +45 -17
  11. package/dist/cli/profile.js +1 -1
  12. package/dist/cli/uninstall.js +21 -5
  13. package/dist/cli/update.js +3 -3
  14. package/dist/cli/usage.js +75 -4
  15. package/dist/extensions/{combo-toggle → combo}/index.js +1 -1
  16. package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
  17. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -1
  18. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +1 -1
  19. package/dist/extensions/{opencode/server.js → server.js} +8 -8
  20. package/dist/extensions/shared/agent-stats.js +1262 -0
  21. package/dist/extensions/shared/brand-marks.js +154 -0
  22. package/dist/extensions/shared/plugin-settings.js +1 -1
  23. package/dist/extensions/shared/pricing.js +91 -27
  24. package/dist/extensions/shared/usage-ledger.js +79 -17
  25. package/dist/extensions/shared/usage-store.js +106 -22
  26. package/dist/extensions/{lib → shared}/utils.js +1 -1
  27. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -3
  28. package/dist/probe-catalog.js +48 -0
  29. package/dist/probe-catalog188.js +44 -0
  30. package/dist/tersio.js +2 -2
  31. package/extensions/{combo-toggle → combo}/index.ts +1 -1
  32. package/extensions/{tersio-commands → commands}/index.ts +1 -1
  33. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -1
  34. package/extensions/{rtk-session/index.ts → rtk/session.ts} +1 -1
  35. package/extensions/{opencode/server.ts → server.ts} +9 -9
  36. package/extensions/shared/agent-stats.ts +1657 -0
  37. package/extensions/shared/brand-marks.ts +163 -0
  38. package/extensions/shared/plugin-settings.ts +1 -1
  39. package/extensions/shared/pricing.ts +107 -28
  40. package/extensions/shared/usage-ledger.ts +94 -19
  41. package/extensions/shared/usage-store.ts +106 -22
  42. package/extensions/{lib → shared}/utils.ts +1 -1
  43. package/extensions/{ai-addons-updater → updater}/index.ts +15 -4
  44. package/package.json +13 -13
  45. /package/dist/extensions/{caveman-session → caveman}/index.js +0 -0
  46. /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  47. /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  48. /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
  49. /package/extensions/{caveman-session → caveman}/index.ts +0 -0
  50. /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  51. /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  52. /package/extensions/{caveman-session → caveman}/rule.md +0 -0
@@ -0,0 +1,1657 @@
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';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { SQLITE_READ_BUFFER } from './usage-ledger.ts';
10
+
11
+ export type RangeKey = '1h' | '24h' | '7d' | '30d' | '90d' | 'all';
12
+
13
+ export const RANGES: readonly RangeKey[] = ['1h', '24h', '7d', '30d', '90d', 'all'];
14
+
15
+ export function isRangeKey(v: unknown): v is RangeKey {
16
+ return typeof v === 'string' && (RANGES as readonly string[]).includes(v);
17
+ }
18
+
19
+ /** A page fetches only the parts it renders, so a route payload stays small. */
20
+ export type AgentView =
21
+ | 'all' | 'overview' | 'models' | 'providers' | 'costs' | 'carbon' | 'requests'
22
+ | 'errors' | 'traces' | 'tools' | 'projects';
23
+
24
+ export const AGENT_VIEWS: readonly AgentView[] = [
25
+ 'all', 'overview', 'models', 'providers', 'costs', 'carbon', 'requests',
26
+ 'errors', 'traces', 'tools', 'projects',
27
+ ];
28
+
29
+ export function isAgentView(v: unknown): v is AgentView {
30
+ return typeof v === 'string' && (AGENT_VIEWS as readonly string[]).includes(v);
31
+ }
32
+
33
+ /** Query group names, matching the blocks readStatsDb issues. */
34
+ const VIEW_PARTS: Record<AgentView, readonly string[]> = {
35
+ all: ['overall', 'byModel', 'byProvider', 'byAgentType', 'byProject', 'series', 'hourOfDay', 'recent', 'errors', 'traces',
36
+ 'seriesByProvider', 'modelSeries', 'modelPerformance', 'tools', 'windows', 'providerHourly'],
37
+ overview: ['overall', 'byModel', 'byProvider', 'byAgentType', 'byProject', 'series', 'hourOfDay', 'recent',
38
+ 'seriesByProvider', 'modelSeries'],
39
+ models: ['overall', 'byModel', 'modelSeries', 'modelPerformance', 'series'],
40
+ providers: ['overall', 'byProvider', 'series', 'seriesByProvider', 'providerHourly', 'windows'],
41
+ // `modelSeries` feeds the per-model stack, so the page's first paint is not an empty chart.
42
+ costs: ['overall', 'byModel', 'series', 'modelSeries'],
43
+ // The Carbon page needs the same two facts as Costs: per-model output tokens and the mix.
44
+ carbon: ['overall', 'byModel'],
45
+ requests: ['recent'],
46
+ errors: ['errors', 'overall', 'byModel', 'recent'],
47
+ traces: ['traces'],
48
+ tools: ['overall', 'tools'],
49
+ projects: ['overall', 'byProject'],
50
+ };
51
+
52
+ /** Window length in ms. `all` has no cutoff, so it reads as 0. */
53
+ const RANGE_MS: Record<RangeKey, number> = {
54
+ '1h': 3_600_000,
55
+ '24h': 86_400_000,
56
+ '7d': 604_800_000,
57
+ '30d': 2_592_000_000,
58
+ '90d': 7_776_000_000,
59
+ all: 0,
60
+ };
61
+
62
+ /** Bucket width per range, measured against the reference: 5 minutes, then 1 hour, then 1 day. */
63
+ const BUCKET_MS: Record<RangeKey, number> = {
64
+ '1h': 300_000,
65
+ '24h': 3_600_000,
66
+ '7d': 86_400_000,
67
+ '30d': 86_400_000,
68
+ '90d': 86_400_000,
69
+ all: 86_400_000,
70
+ };
71
+
72
+ /** A series never renders more than this many buckets; a wider span coarsens the bucket. */
73
+ const MAX_BUCKETS = 1500;
74
+
75
+ const RECENT_LIMIT = 200;
76
+ const ERROR_LIMIT = 300;
77
+ const TRACE_LIMIT = 300;
78
+ const TOOL_MODEL_LIMIT = 300;
79
+ const SERIES_PROVIDERS = 8;
80
+ const SERIES_MODELS = 12;
81
+
82
+ export function statsDbPath(): string {
83
+ return process.env.TERSIO_AGENT_STATS_DB ?? path.join(os.homedir(), '.omp', 'stats.db');
84
+ }
85
+
86
+ export function agentDbPath(): string {
87
+ return process.env.TERSIO_AGENT_SESSIONS_DB ?? path.join(os.homedir(), '.omp', 'agent', 'agent.db');
88
+ }
89
+
90
+ export interface AgentTokenMix {
91
+ input: number;
92
+ output: number;
93
+ cacheRead: number;
94
+ cacheWrite: number;
95
+ total: number;
96
+ }
97
+
98
+ export interface AgentCostMix {
99
+ input: number;
100
+ output: number;
101
+ cacheRead: number;
102
+ cacheWrite: number;
103
+ total: number;
104
+ }
105
+
106
+ /** Output cost is the remainder: omp prices the whole request and the three cache-side parts. */
107
+ function costMixOf(f: Facts): AgentCostMix {
108
+ const input = f.costInput;
109
+ const cacheRead = f.costCacheRead;
110
+ const cacheWrite = f.costCacheWrite;
111
+ return {
112
+ input,
113
+ cacheRead,
114
+ cacheWrite,
115
+ output: Math.max(0, f.costUsd - input - cacheRead - cacheWrite),
116
+ total: f.costUsd,
117
+ };
118
+ }
119
+
120
+ export interface AgentOverall extends AgentTokenMix {
121
+ /** The estimate split the way a bill splits it: input, output, cache read, cache write. */
122
+ costMix: AgentCostMix;
123
+ requests: number;
124
+ failed: number;
125
+ successful: number;
126
+ errorRate: number;
127
+ cacheRate: number;
128
+ costUsd: number;
129
+ unpricedRequests: number;
130
+ avgDurationMs: number;
131
+ avgTtftMs: number;
132
+ avgTokensPerSecond: number;
133
+ /** The longest single request in the range; null when none carries a duration. */
134
+ maxDurationMs: number | null;
135
+ firstTs: number | null;
136
+ lastTs: number | null;
137
+ }
138
+
139
+ export interface AgentRow extends AgentTokenMix {
140
+ key: string;
141
+ /** The provider this row belongs to, empty when the group is not per provider. */
142
+ provider: string;
143
+ /** The row's estimate split by billing component. */
144
+ costMix: AgentCostMix;
145
+ requests: number;
146
+ failed: number;
147
+ errorRate: number;
148
+ cacheRate: number;
149
+ costUsd: number;
150
+ unpricedRequests: number;
151
+ avgDurationMs: number;
152
+ avgTtftMs: number;
153
+ avgTokensPerSecond: number;
154
+ firstTs: number | null;
155
+ lastTs: number | null;
156
+ /** Providers only: how many models the provider served. */
157
+ models: number;
158
+ }
159
+
160
+ export interface AgentBucket {
161
+ ts: number;
162
+ requests: number;
163
+ errors: number;
164
+ tokens: number;
165
+ costUsd: number;
166
+ }
167
+
168
+ /** Throughput and first-token latency for one model and provider in one bucket. */
169
+ export interface AgentModelPerformancePoint {
170
+ ts: number;
171
+ requests: number;
172
+ avgTokensPerSecond: number | null;
173
+ avgTtftMs: number | null;
174
+ }
175
+
176
+ export interface AgentHour {
177
+ hour: number;
178
+ requests: number;
179
+ errors: number;
180
+ tokens: number;
181
+ costUsd: number;
182
+ }
183
+
184
+ export interface AgentTypeShare extends AgentTokenMix {
185
+ agentType: string;
186
+ requests: number;
187
+ costUsd: number;
188
+ }
189
+
190
+ export interface AgentRequestRow {
191
+ ts: number;
192
+ provider: string;
193
+ model: string;
194
+ project: string;
195
+ api: string;
196
+ input: number;
197
+ output: number;
198
+ cacheRead: number;
199
+ cacheWrite: number;
200
+ totalTokens: number;
201
+ costUsd: number;
202
+ unpriced: boolean;
203
+ durationMs: number | null;
204
+ ttftMs: number | null;
205
+ stopReason: string;
206
+ errorMessage: string | null;
207
+ agentType: string;
208
+ sessionFile: string;
209
+ entryId: string;
210
+ }
211
+
212
+ export interface AgentErrorGroup {
213
+ signature: string;
214
+ count: number;
215
+ firstSeen: number;
216
+ lastSeen: number;
217
+ latest: AgentRequestRow;
218
+ models: Array<{ model: string; provider: string; count: number }>;
219
+ }
220
+
221
+ export interface AgentErrorModelRow {
222
+ model: string;
223
+ provider: string;
224
+ count: number;
225
+ }
226
+
227
+ /** One session row on the Traces page: a root session with its child transcripts folded in. */
228
+ export interface AgentTraceRow {
229
+ /** Absolute root session file path, which is also the trace key. */
230
+ sessionFile: string;
231
+ /** Mangled session folder from the stats database. */
232
+ project: string;
233
+ /** Working directory from the root session's own journal line, when it still reads. */
234
+ cwd: string;
235
+ /** The session's own title, when it still reads. */
236
+ title: string | null;
237
+ requests: number;
238
+ tokens: number;
239
+ costUsd: number;
240
+ unpriced: number;
241
+ /** Every model the session and its children ran on, sorted. */
242
+ models: string[];
243
+ toolCalls: number;
244
+ /** Child transcripts folded into this row. */
245
+ subagents: number;
246
+ firstTs: number;
247
+ lastTs: number;
248
+ }
249
+
250
+ export interface AgentToolRow {
251
+ tool: string;
252
+ calls: number;
253
+ errors: number;
254
+ argsChars: number;
255
+ resultChars: number;
256
+ totalTokensShare: number;
257
+ outputTokensShare: number;
258
+ costShare: number;
259
+ unpricedShare: number;
260
+ lastUsed: number | null;
261
+ model: string;
262
+ provider: string;
263
+ }
264
+
265
+ export interface AgentWindowPoint {
266
+ ts: number;
267
+ usedFraction: number | null;
268
+ exhausted: boolean;
269
+ }
270
+
271
+ /** The Requests page's summary row, computed over the loaded window. */
272
+ export interface AgentRequestStats {
273
+ requests: number;
274
+ failed: number;
275
+ aborted: number;
276
+ tokens: number;
277
+ costUsd: number;
278
+ unpriced: number;
279
+ medianDurationMs: number;
280
+ p95DurationMs: number;
281
+ medianTtftMs: number;
282
+ oldest: number | null;
283
+ newest: number | null;
284
+ }
285
+
286
+ /** Peak burn hours: one row per provider per local hour. */
287
+ export interface AgentProviderHour {
288
+ provider: string;
289
+ hour: number;
290
+ totalTokens: number;
291
+ outputTokens: number;
292
+ requests: number;
293
+ }
294
+
295
+ /** One account's utilization series inside one window. */
296
+ export interface AgentWindowSeries {
297
+ provider: string;
298
+ accountKey: string;
299
+ accountLabel: string;
300
+ windowKey: string;
301
+ windowLabel: string;
302
+ points: AgentWindowPoint[];
303
+ }
304
+
305
+ /** What one subscription window costs in quota terms. */
306
+ export interface AgentWindowInsight {
307
+ provider: string;
308
+ windowKey: string;
309
+ windowLabel: string;
310
+ accounts: number;
311
+ cycles: number;
312
+ fractionConsumed: number;
313
+ /** Tokens one full window buys, extrapolated from local burn. */
314
+ estTokensPerWindow: number | null;
315
+ /** Peak of the sum across accounts: 1.7 means demand held 1.7 windows at once. */
316
+ peakConcurrentFraction: number;
317
+ /** Accounts needed to keep the peak under the target utilization. */
318
+ idealAccounts: number;
319
+ exhaustedEvents: number;
320
+ }
321
+
322
+ /** Kind of one trace span: a user turn, a model call, a tool call, or session metadata. */
323
+ export type AgentTraceSpanKind = 'turn' | 'model' | 'tool' | 'background';
324
+
325
+ /** One span on the flamegraph: an interval with the identity and cost of the work it holds. */
326
+ export interface AgentTraceSpan {
327
+ /** Entry id from the journal, or `${entryId}#${index}` for one of a message's tool calls. */
328
+ id: string;
329
+ kind: AgentTraceSpanKind;
330
+ /** Short name: the tool, the model, or `Prompt`. */
331
+ label: string;
332
+ /** First line of the text the span carries. */
333
+ detail: string;
334
+ start: number;
335
+ end: number;
336
+ model: string;
337
+ provider: string;
338
+ tool: string;
339
+ tokens: number;
340
+ costUsd: number;
341
+ isError: boolean;
342
+ /** How the request stopped, from the assistant message's stop reason. */
343
+ stopped: 'completed' | 'aborted' | 'failed';
344
+ /** Journal entry id, so the drawer can read the full payload on demand. */
345
+ entryId: string;
346
+ }
347
+
348
+ /** A mode or title change: drawn as a diamond on the timeline. */
349
+ export interface AgentTraceMarker {
350
+ id: string;
351
+ ts: number;
352
+ kind: 'model' | 'think' | 'session';
353
+ label: string;
354
+ }
355
+
356
+ /** One flamegraph row: the main session, or a subagent's own row. */
357
+ export interface AgentTraceTrack {
358
+ id: string;
359
+ label: string;
360
+ parentId: string | null;
361
+ spans: AgentTraceSpan[];
362
+ }
363
+
364
+ /** Duration totals for one tool across the session: total, mean, and the longest single call. */
365
+ export interface AgentTraceToolStat {
366
+ tool: string;
367
+ calls: number;
368
+ errors: number;
369
+ totalMs: number;
370
+ avgMs: number;
371
+ maxMs: number;
372
+ }
373
+
374
+ /** The nine headline figures the reference trace view opens with. */
375
+ export interface AgentTraceSummary {
376
+ wallMs: number;
377
+ modelMs: number;
378
+ toolMs: number;
379
+ idleMs: number;
380
+ turns: number;
381
+ requests: number;
382
+ toolCalls: number;
383
+ agents: number;
384
+ totalTokens: number;
385
+ costUsd: number;
386
+ }
387
+
388
+ export interface AgentSessionTrace {
389
+ sessionFile: string;
390
+ project: string;
391
+ title: string;
392
+ cwd: string;
393
+ startedAt: number;
394
+ endedAt: number;
395
+ tracks: AgentTraceTrack[];
396
+ markers: AgentTraceMarker[];
397
+ summary: AgentTraceSummary;
398
+ toolStats: AgentTraceToolStat[];
399
+ truncated: boolean;
400
+ }
401
+
402
+ export interface AgentStats {
403
+ available: boolean;
404
+ range: RangeKey;
405
+ bucketMs: number;
406
+ cutoff: number;
407
+ generatedAt: number;
408
+ overall: AgentOverall;
409
+ byModel: AgentRow[];
410
+ byProvider: AgentRow[];
411
+ byProject: AgentRow[];
412
+ byAgentType: AgentTypeShare[];
413
+ series: AgentBucket[];
414
+ seriesByProvider: Array<{ provider: string; points: AgentBucket[] }>;
415
+ modelSeries: Array<{ model: string; points: AgentBucket[] }>;
416
+ /** Per-bucket throughput and TTFT per model and provider: the model detail chart. */
417
+ modelPerformance: Array<{ model: string; provider: string; points: AgentModelPerformancePoint[] }>;
418
+ hourOfDay: AgentHour[];
419
+ topModels: AgentRow[];
420
+ recent: AgentRequestRow[];
421
+ errorGroups: AgentErrorGroup[];
422
+ errorModels: AgentErrorModelRow[];
423
+ traces: AgentTraceRow[];
424
+ tools: AgentToolRow[];
425
+ toolsByModel: AgentToolRow[];
426
+ toolSeries: Array<{ ts: number; tool: string; calls: number; errors: number }>;
427
+ usageSeries: AgentWindowSeries[];
428
+ windowInsights: AgentWindowInsight[];
429
+ providerHourly: AgentProviderHour[];
430
+ requestStats: AgentRequestStats;
431
+ /** Raw failure rows inside the range, newest first: the Errors page's list. */
432
+ errors: AgentRequestRow[];
433
+ }
434
+
435
+ export function emptyAgentStats(range: RangeKey = '24h'): AgentStats {
436
+ return {
437
+ available: false,
438
+ range,
439
+ bucketMs: BUCKET_MS[range],
440
+ cutoff: 0,
441
+ generatedAt: Date.now(),
442
+ overall: {
443
+ requests: 0, failed: 0, successful: 0, errorRate: 0, cacheRate: 0,
444
+ costUsd: 0, unpricedRequests: 0,
445
+ avgDurationMs: 0, avgTtftMs: 0, avgTokensPerSecond: 0, maxDurationMs: null,
446
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0,
447
+ costMix: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
448
+ firstTs: null, lastTs: null,
449
+ },
450
+ byModel: [], byProvider: [], byProject: [], byAgentType: [],
451
+ series: [], seriesByProvider: [], modelSeries: [], modelPerformance: [], hourOfDay: [], topModels: [],
452
+ recent: [], errorGroups: [], errorModels: [], traces: [],
453
+ tools: [], toolsByModel: [], toolSeries: [],
454
+ usageSeries: [], windowInsights: [], providerHourly: [],
455
+ requestStats: {
456
+ requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0,
457
+ medianDurationMs: 0, p95DurationMs: 0, medianTtftMs: 0, oldest: null, newest: null,
458
+ },
459
+ errors: [],
460
+ };
461
+ }
462
+
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
+ */
470
+ 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
+ }
498
+ }
499
+
500
+ const BLOCK = '@@blk@@';
501
+
502
+ function blocks(out: string): Map<string, string[][]> {
503
+ 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'));
513
+ }
514
+ return map;
515
+ }
516
+
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
+ function tableColumns(db: string, table: string): Set<string> {
540
+ try {
541
+ const out = sqliteScript(db, `SELECT name FROM pragma_table_info('${table}');\n`);
542
+ return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean));
543
+ } catch {
544
+ return new Set();
545
+ }
546
+ }
547
+
548
+ function num(v: string | undefined): number {
549
+ const n = Number(v);
550
+ return Number.isFinite(n) ? n : 0;
551
+ }
552
+
553
+ function numOrNull(v: string | undefined): number | null {
554
+ if (v === undefined || v === '') return null;
555
+ const n = Number(v);
556
+ return Number.isFinite(n) ? n : null;
557
+ }
558
+
559
+ /** A text column must stay on one line: the parser splits rows on tabs and newlines. */
560
+ function txt(column: string): string {
561
+ return `replace(replace(replace(COALESCE(${column}, ''), char(10), ' '), char(13), ' '), char(9), ' ')`;
562
+ }
563
+
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`;
570
+ }
571
+
572
+ /** The additive facts every messages group reads. */
573
+ function messageFacts(p = ''): string {
574
+ return [
575
+ '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)`,
596
+ ].join(', ');
597
+ }
598
+
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
+ }
621
+
622
+ interface Facts {
623
+ requests: number; failed: number;
624
+ input: number; output: number; cacheRead: number; cacheWrite: number; total: number;
625
+ costUsd: number;
626
+ costInput: number; costCacheRead: number; costCacheWrite: number;
627
+ unpriced: number;
628
+ durationSum: number; durationN: number; ttftSum: number; ttftN: number;
629
+ tpsSum: number; tpsN: number;
630
+ maxDurationMs: number | null;
631
+ firstTs: number | null; lastTs: number | null;
632
+ }
633
+
634
+ function parseFacts(row: string[] | undefined, at: number): Facts {
635
+ const g = (i: number): number => num(row?.[at + i]);
636
+ return {
637
+ requests: g(0), failed: g(1),
638
+ input: g(2), output: g(3), cacheRead: g(4), cacheWrite: g(5), total: g(6),
639
+ costUsd: g(7),
640
+ costInput: g(8), costCacheRead: g(9), costCacheWrite: g(10),
641
+ unpriced: g(11),
642
+ durationSum: g(12), durationN: g(13), ttftSum: g(14), ttftN: g(15),
643
+ tpsSum: g(16), tpsN: g(17),
644
+ firstTs: numOrNull(row?.[at + 18]), lastTs: numOrNull(row?.[at + 19]),
645
+ maxDurationMs: numOrNull(row?.[at + 20]),
646
+ };
647
+ }
648
+
649
+ function mix(f: Facts): AgentTokenMix {
650
+ return { input: f.input, output: f.output, cacheRead: f.cacheRead, cacheWrite: f.cacheWrite, total: f.total };
651
+ }
652
+
653
+ const cacheRateOf = (f: Facts): number => (f.input + f.cacheRead > 0 ? f.cacheRead / (f.input + f.cacheRead) : 0);
654
+
655
+ function overallOf(f: Facts): AgentOverall {
656
+ return {
657
+ ...mix(f),
658
+ costMix: costMixOf(f),
659
+ requests: f.requests,
660
+ failed: f.failed,
661
+ successful: f.requests - f.failed,
662
+ errorRate: f.requests > 0 ? f.failed / f.requests : 0,
663
+ cacheRate: cacheRateOf(f),
664
+ costUsd: f.costUsd,
665
+ unpricedRequests: f.unpriced,
666
+ avgDurationMs: f.durationN > 0 ? f.durationSum / f.durationN : 0,
667
+ avgTtftMs: f.ttftN > 0 ? f.ttftSum / f.ttftN : 0,
668
+ avgTokensPerSecond: f.tpsN > 0 ? f.tpsSum / f.tpsN : 0,
669
+ maxDurationMs: f.maxDurationMs,
670
+ firstTs: f.firstTs,
671
+ lastTs: f.lastTs,
672
+ };
673
+ }
674
+
675
+ function rowOf(key: string, f: Facts, models: number, provider = ''): AgentRow {
676
+ return {
677
+ ...mix(f),
678
+ key,
679
+ provider,
680
+ costMix: costMixOf(f),
681
+ requests: f.requests,
682
+ failed: f.failed,
683
+ errorRate: f.requests > 0 ? f.failed / f.requests : 0,
684
+ cacheRate: cacheRateOf(f),
685
+ costUsd: f.costUsd,
686
+ unpricedRequests: f.unpriced,
687
+ avgDurationMs: f.durationN > 0 ? f.durationSum / f.durationN : 0,
688
+ avgTtftMs: f.ttftN > 0 ? f.ttftSum / f.ttftN : 0,
689
+ avgTokensPerSecond: f.tpsN > 0 ? f.tpsSum / f.tpsN : 0,
690
+ firstTs: f.firstTs,
691
+ lastTs: f.lastTs,
692
+ models,
693
+ };
694
+ }
695
+
696
+ function rangeWhere(cutoff: number, column = 'timestamp'): string {
697
+ return cutoff > 0 ? `WHERE ${column} >= ${Math.floor(cutoff)}` : '';
698
+ }
699
+
700
+ /** Utilization is a fraction with float noise; four decimals keep the payload small. */
701
+ function round4(v: number | null): number | null {
702
+ return v === null ? null : Math.round(v * 1e4) / 1e4;
703
+ }
704
+
705
+ // ------------------------------------------------------------------- the aggregate
706
+
707
+ function bucketSize(range: RangeKey, firstTs: number | null, lastTs: number | null): number {
708
+ const base = BUCKET_MS[range];
709
+ if (firstTs === null || lastTs === null) return base;
710
+ const span = Math.max(0, lastTs - firstTs);
711
+ if (span / base <= MAX_BUCKETS) return base;
712
+ return Math.max(base, Math.ceil(span / MAX_BUCKETS / base) * base);
713
+ }
714
+
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')}`;
727
+
728
+ 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.
751
+ const traceModels = readTraceModels(db);
752
+ const traceTools = readTraceToolCounts(db);
753
+ const traces = foldTraces((out.get('traces') ?? []).map((r) => {
754
+ // Both readers are keyed by root, so every file of a session reads the session's totals.
755
+ const root = rootSessionFile(r[0] ?? '');
756
+ return {
757
+ sessionFile: r[0] ?? '', project: r[1] ?? '', cwd: '', title: null,
758
+ requests: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]), unpriced: num(r[5]),
759
+ models: traceModels.get(root) ?? [],
760
+ toolCalls: traceTools.get(root) ?? 0,
761
+ subagents: 0, firstTs: num(r[6]), lastTs: num(r[7]),
762
+ };
763
+ }));
764
+ applySessionMetadata(traces);
765
+
766
+ const requestRows = (rows: string[][]): AgentRequestRow[] => rows.map((r) => ({
767
+ ts: num(r[0]),
768
+ provider: r[1] ?? '',
769
+ model: r[2] ?? '',
770
+ project: r[3] ?? '',
771
+ api: r[4] ?? '',
772
+ input: num(r[5]), output: num(r[6]), cacheRead: num(r[7]), cacheWrite: num(r[8]), totalTokens: num(r[9]),
773
+ costUsd: num(r[10]),
774
+ unpriced: num(r[11]) > 0,
775
+ durationMs: numOrNull(r[12]),
776
+ ttftMs: numOrNull(r[13]),
777
+ stopReason: r[14] ?? '',
778
+ errorMessage: r[15] || null,
779
+ agentType: r[16] ?? '',
780
+ sessionFile: r[17] ?? '',
781
+ entryId: r[18] ?? '',
782
+ }));
783
+
784
+ const errorRows = requestRows(out.get('errors') ?? []);
785
+ const errorCounts = new Map<string, AgentErrorModelRow>();
786
+ for (const row of errorRows) {
787
+ const key = `${row.model}\u0000${row.provider}`;
788
+ const hit = errorCounts.get(key);
789
+ if (hit) hit.count += 1;
790
+ else errorCounts.set(key, { model: row.model, provider: row.provider, count: 1 });
791
+ }
792
+
793
+ const byModel = (out.get('byModel') ?? [])
794
+ .map((r) => rowOf(r[0] ?? '', parseFacts(r, 2), 0, r[1] ?? ''));
795
+
796
+ return {
797
+ overall: overallOf(parseFacts(out.get('overall')?.[0], 0)),
798
+ byModel,
799
+ byProvider: (out.get('byProvider') ?? []).map((r) => rowOf(r[0] ?? '', parseFacts(r, 2), num(r[1]), r[0] ?? '')),
800
+ byProject: (out.get('byProject') ?? []).map((r) => rowOf(r[0] ?? '', parseFacts(r, 1), 0)),
801
+ byAgentType: (out.get('byAgentType') ?? []).map((r) => {
802
+ const f = parseFacts(r, 1);
803
+ return { ...mix(f), agentType: r[0] ?? '', requests: f.requests, costUsd: f.costUsd };
804
+ }),
805
+ series: (out.get('series') ?? []).map((r) => ({
806
+ ts: num(r[0]), requests: num(r[1]), errors: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]),
807
+ })),
808
+ hourOfDay: (out.get('hourOfDay') ?? []).map((r) => ({
809
+ hour: num(r[0]), requests: num(r[1]), errors: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]),
810
+ })),
811
+ recent: requestRows(out.get('recent') ?? []),
812
+ errorGroups: groupErrors(errorRows),
813
+ errors: errorRows,
814
+ errorModels: [...errorCounts.values()].toSorted((a, b) => b.count - a.count),
815
+ traces: traces,
816
+ };
817
+ }
818
+
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
+ */
826
+ function foldTraces(rows: AgentTraceRow[]): AgentTraceRow[] {
827
+ const byRoot = new Map<string, AgentTraceRow>();
828
+ for (const row of rows) {
829
+ const rootFile = rootSessionFile(row.sessionFile);
830
+ const held = byRoot.get(rootFile);
831
+ if (!held) {
832
+ byRoot.set(rootFile, { ...row, sessionFile: rootFile, models: [...row.models] });
833
+ continue;
834
+ }
835
+ held.requests += row.requests;
836
+ held.tokens += row.tokens;
837
+ held.costUsd += row.costUsd;
838
+ held.unpriced += row.unpriced;
839
+ held.models = [...new Set([...held.models, ...row.models])].toSorted();
840
+ held.firstTs = Math.min(held.firstTs, row.firstTs);
841
+ held.lastTs = Math.max(held.lastTs, row.lastTs);
842
+ held.subagents += 1;
843
+ }
844
+ return [...byRoot.values()]
845
+ .map((row) => ({ ...row, models: row.models.toSorted() }))
846
+ .toSorted((a, b) => b.lastTs - a.lastTs);
847
+ }
848
+
849
+ /** The root session file for a transcript, which is itself when it is already a root. */
850
+ export function rootSessionFile(sessionFile: string): string {
851
+ const marker = '/sessions/';
852
+ const at = sessionFile.indexOf(marker);
853
+ if (at < 0) return sessionFile;
854
+ const base = sessionFile.slice(0, at + marker.length);
855
+ const parts = sessionFile.slice(at + marker.length).split('/');
856
+ // `<project>/<root>.jsonl` is a root; `<project>/<root>/<child>.jsonl` belongs to that root.
857
+ if (parts.length < 3) return sessionFile;
858
+ return `${base}${parts[0]}/${parts[1]}.jsonl`;
859
+ }
860
+
861
+ /** Same normalization as the reference: ids and counters collapse, an HTTP status does not. */
862
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
863
+ const PREFIXED_ID_RE = /\b(?:req|msg|call|toolu|chatcmpl|resp|run|gen)[-_][A-Za-z0-9_-]{6,}/g;
864
+ const HEX_RE = /\b[0-9a-f]{12,}\b/gi;
865
+ const NUMBER_RE = /(?<![\w.-])\d+(?:\.\d+)?/g;
866
+ const HTTP_STATUS_RE = /^[1-5]\d\d$/;
867
+ const SIGNATURE_MAX = 180;
868
+
869
+ export function errorSignature(message: string | null): string {
870
+ if (!message?.trim()) return 'Unknown error';
871
+ const normalized = message
872
+ .replace(/\s+/g, ' ')
873
+ .trim()
874
+ .replace(UUID_RE, '<id>')
875
+ .replace(PREFIXED_ID_RE, '<id>')
876
+ .replace(HEX_RE, '<hex>')
877
+ .replace(NUMBER_RE, (n) => (HTTP_STATUS_RE.test(n) ? n : 'N'));
878
+ return normalized.length > SIGNATURE_MAX ? `${normalized.slice(0, SIGNATURE_MAX - 1)}…` : normalized;
879
+ }
880
+
881
+ function groupErrors(rows: AgentRequestRow[]): AgentErrorGroup[] {
882
+ const groups = new Map<string, AgentErrorGroup>();
883
+ for (const row of rows) {
884
+ const signature = errorSignature(row.errorMessage);
885
+ const hit = groups.get(signature);
886
+ if (!hit) {
887
+ groups.set(signature, {
888
+ signature, count: 1, firstSeen: row.ts, lastSeen: row.ts, latest: row,
889
+ models: [{ model: row.model, provider: row.provider, count: 1 }],
890
+ });
891
+ continue;
892
+ }
893
+ hit.count += 1;
894
+ hit.firstSeen = Math.min(hit.firstSeen, row.ts);
895
+ hit.lastSeen = Math.max(hit.lastSeen, row.ts);
896
+ const model = hit.models.find((m) => m.model === row.model && m.provider === row.provider);
897
+ if (model) model.count += 1;
898
+ else hit.models.push({ model: row.model, provider: row.provider, count: 1 });
899
+ if (row.ts >= hit.latest.ts) hit.latest = row;
900
+ }
901
+ return [...groups.values()]
902
+ .map((g) => ({ ...g, models: g.models.toSorted((a, b) => b.count - a.count) }))
903
+ .toSorted((a, b) => b.count - a.count || b.lastSeen - a.lastSeen);
904
+ }
905
+
906
+ 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`]]);
910
+ const perProvider = new Map<string, { cost: number; points: AgentBucket[] }>();
911
+ for (const r of out.get('providerSeries') ?? []) {
912
+ const provider = r[0] ?? '';
913
+ const held = perProvider.get(provider) ?? { cost: 0, points: [] };
914
+ const point: AgentBucket = { ts: num(r[1]), requests: num(r[2]), errors: 0, tokens: num(r[3]), costUsd: num(r[4]) };
915
+ held.points.push(point);
916
+ held.cost += point.costUsd;
917
+ perProvider.set(provider, held);
918
+ }
919
+ return [...perProvider.entries()]
920
+ .toSorted((a, b) => b[1].cost - a[1].cost)
921
+ .slice(0, SERIES_PROVIDERS)
922
+ .map(([provider, held]) => ({ provider, points: held.points }));
923
+ }
924
+
925
+ 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')},
933
+ 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}`;
943
+ 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`],
949
+ ]);
950
+ const parseTool = (r: string[], at: number): AgentToolRow => ({
951
+ tool: r[1] ?? '',
952
+ 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] ?? '',
963
+ });
964
+ return {
965
+ tools: (out.get('byTool') ?? []).map((r) => parseTool(r, 13)),
966
+ toolsByModel: (out.get('byToolModel') ?? []).map((r) => parseTool(r, 13)),
967
+ toolSeries: (out.get('toolSeries') ?? []).map((r) => ({
968
+ ts: num(r[0]), tool: r[1] ?? '', calls: num(r[2]), errors: num(r[3]),
969
+ })),
970
+ };
971
+ }
972
+
973
+ /** Tool calls per root session, with every child transcript folded in. */
974
+ function readTraceToolCounts(db: string): Map<string, number> {
975
+ const counts = new Map<string, number>();
976
+ try {
977
+ const out = sqliteScript(db, 'SELECT session_file, COUNT(*) FROM tool_calls GROUP BY 1;\n');
978
+ for (const line of out.split('\n')) {
979
+ if (!line.trim()) continue;
980
+ const [file, count] = line.split('\t');
981
+ if (!file) continue;
982
+ const root = rootSessionFile(file);
983
+ counts.set(root, (counts.get(root) ?? 0) + num(count));
984
+ }
985
+ } catch { /* no tool rows: keep zero */ }
986
+ return counts;
987
+ }
988
+
989
+ /** Distinct models per root session, with every child transcript folded in. */
990
+ function readTraceModels(db: string): Map<string, string[]> {
991
+ const models = new Map<string, Set<string>>();
992
+ try {
993
+ const out = sqliteScript(db, 'SELECT session_file, model FROM messages GROUP BY session_file, model;\n');
994
+ for (const line of out.split('\n')) {
995
+ if (!line.trim()) continue;
996
+ const [file, model] = line.split('\t');
997
+ if (!file || !model) continue;
998
+ const set = models.get(rootSessionFile(file)) ?? new Set<string>();
999
+ set.add(model);
1000
+ models.set(rootSessionFile(file), set);
1001
+ }
1002
+ } catch { /* an unreadable table leaves the list empty */ }
1003
+ return new Map([...models.entries()].map(([file, set]) => [file, [...set].toSorted()]));
1004
+ }
1005
+
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
+ */
1012
+ function applySessionMetadata(traces: AgentTraceRow[]): void {
1013
+ for (const trace of traces) {
1014
+ if (!trace.sessionFile.endsWith('.jsonl')) continue;
1015
+ let fd: number | null = null;
1016
+ try {
1017
+ fd = fs.openSync(trace.sessionFile, 'r');
1018
+ const buffer = Buffer.alloc(32 * 1024);
1019
+ const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
1020
+ for (const line of buffer.toString('utf8', 0, read).split('\n')) {
1021
+ if (!line.trim()) continue;
1022
+ if (line.includes('"cwd"') && !trace.cwd) {
1023
+ const match = line.match(/"cwd"\s*:\s*"([^"]+)"/);
1024
+ if (match) trace.cwd = match[1];
1025
+ }
1026
+ if (line.includes('"title"') && !trace.title) {
1027
+ const match = line.match(/"title"\s*:\s*"([^"]*)"/);
1028
+ if (match) trace.title = match[1] || null;
1029
+ }
1030
+ if (trace.cwd && trace.title) break;
1031
+ }
1032
+ } catch { /* a file that no longer reads keeps its empty metadata */ }
1033
+ finally {
1034
+ if (fd !== null) fs.closeSync(fd);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ /** Daily tokens and cost for the busiest models: the Models table sparkline. */
1040
+ 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})`;
1043
+ 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`]]);
1046
+ const perModel = new Map<string, AgentBucket[]>();
1047
+ for (const r of out.get('modelSeries') ?? []) {
1048
+ const model = r[0] ?? '';
1049
+ const points = perModel.get(model) ?? [];
1050
+ points.push({ ts: num(r[1]), requests: num(r[2]), errors: 0, tokens: num(r[3]), costUsd: num(r[4]) });
1051
+ perModel.set(model, points);
1052
+ }
1053
+ return [...perModel.entries()].map(([model, points]) => ({ model, points }));
1054
+ }
1055
+
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
+ */
1061
+ 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})`;
1064
+ const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
1065
+ const out = readBlocks(db, [['modelPerformance', `SELECT ${txt('model')}, ${txt('provider')}, (timestamp / ${bucketMs}) * ${bucketMs},
1066
+ 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`]]);
1072
+ const groups = new Map<string, { model: string; provider: string; points: AgentModelPerformancePoint[] }>();
1073
+ for (const r of out.get('modelPerformance') ?? []) {
1074
+ const key = `${r[0] ?? ''}\u0000${r[1] ?? ''}`;
1075
+ const group = groups.get(key) ?? { model: r[0] ?? '', provider: r[1] ?? '', points: [] };
1076
+ const durationN = num(r[5]);
1077
+ const ttftN = num(r[7]);
1078
+ group.points.push({
1079
+ ts: num(r[2]),
1080
+ requests: num(r[3]),
1081
+ avgTokensPerSecond: durationN > 0 ? num(r[4]) / durationN : null,
1082
+ avgTtftMs: ttftN > 0 ? num(r[6]) / ttftN : null,
1083
+ });
1084
+ groups.set(key, group);
1085
+ }
1086
+ return [...groups.values()];
1087
+ }
1088
+
1089
+ // ------------------------------------------------------------ subscription windows
1090
+
1091
+ /** Reference constants for the quota half, copied from the omp implementation. */
1092
+ const WINDOW_RESET_DROP = 0.05;
1093
+ const WINDOW_MIN_EXTRAPOLATION = 0.1;
1094
+ const WINDOW_TARGET_PEAK = 0.9;
1095
+ const WINDOW_EXHAUSTED = 0.999;
1096
+ const WINDOW_MAX_POINTS = 400;
1097
+
1098
+ interface WindowSample {
1099
+ provider: string;
1100
+ accountKey: string;
1101
+ accountLabel: string;
1102
+ limitId: string;
1103
+ label: string;
1104
+ windowLabel: string;
1105
+ ts: number;
1106
+ usedFraction: number | null;
1107
+ exhausted: boolean;
1108
+ }
1109
+
1110
+ /** Peak per bucket, so a utilization spike survives the cap. */
1111
+ function downsampleWindow(points: AgentWindowPoint[]): AgentWindowPoint[] {
1112
+ if (points.length <= WINDOW_MAX_POINTS) return points;
1113
+ const first = points[0].ts;
1114
+ const last = points[points.length - 1].ts;
1115
+ const bucket = Math.max(1, Math.ceil((last - first) / WINDOW_MAX_POINTS));
1116
+ const out: AgentWindowPoint[] = [];
1117
+ let mark = -1;
1118
+ for (const point of points) {
1119
+ const b = Math.floor((point.ts - first) / bucket);
1120
+ if (b !== mark) {
1121
+ out.push(point);
1122
+ mark = b;
1123
+ continue;
1124
+ }
1125
+ const held = out[out.length - 1];
1126
+ if ((point.usedFraction ?? -1) >= (held.usedFraction ?? -1)) out[out.length - 1] = point;
1127
+ }
1128
+ return out;
1129
+ }
1130
+
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
+ */
1135
+ function peakConcurrent(samples: WindowSample[]): number {
1136
+ const events = samples.filter((sample) => sample.usedFraction !== null).toSorted((a, b) => a.ts - b.ts);
1137
+ const current = new Map<string, number>();
1138
+ let sum = 0;
1139
+ let peak = 0;
1140
+ for (const event of events) {
1141
+ const fraction = event.usedFraction ?? 0;
1142
+ sum += fraction - (current.get(event.accountKey) ?? 0);
1143
+ current.set(event.accountKey, fraction);
1144
+ if (sum > peak) peak = sum;
1145
+ }
1146
+ return peak;
1147
+ }
1148
+
1149
+ /** The limit label, plus the window label when it adds information. */
1150
+ function windowDisplayLabel(sample: WindowSample): string {
1151
+ if (!sample.windowLabel || sample.label.toLowerCase().includes(sample.windowLabel.toLowerCase())) return sample.label;
1152
+ return `${sample.label} · ${sample.windowLabel}`;
1153
+ }
1154
+
1155
+ /** Quota snapshots from agent.db, oldest first. Windows group by provider and limit id. */
1156
+ function readWindowSamples(cutoff: number): WindowSample[] {
1157
+ const db = agentDbPath();
1158
+ if (!fs.existsSync(db) || tableColumns(db, 'usage_history').size === 0) return [];
1159
+ const out = readBlocks(db, [['windows', `SELECT ${txt('provider')}, ${txt('account_key')},
1160
+ ${txt('email')}, ${txt('account_id')}, ${txt('limit_id')}, ${txt('label')}, ${txt('window_label')},
1161
+ used_fraction, status, recorded_at
1162
+ FROM usage_history ${rangeWhere(cutoff, 'recorded_at')} ORDER BY recorded_at`]]);
1163
+ return (out.get('windows') ?? []).map((r) => {
1164
+ const usedFraction = numOrNull(r[7]);
1165
+ const status = r[8] || null;
1166
+ return {
1167
+ provider: r[0] ?? '',
1168
+ accountKey: r[1] ?? '',
1169
+ accountLabel: r[2] || r[3] || r[1] || '',
1170
+ limitId: r[4] ?? '',
1171
+ label: r[5] ?? '',
1172
+ windowLabel: r[6] ?? '',
1173
+ ts: num(r[9]),
1174
+ usedFraction,
1175
+ exhausted: status === 'exhausted' || (usedFraction !== null && usedFraction >= WINDOW_EXHAUSTED),
1176
+ };
1177
+ });
1178
+ }
1179
+
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
+ */
1184
+ function computeWindows(
1185
+ samples: WindowSample[],
1186
+ tokensByProvider: Map<string, number>,
1187
+ ): { usageSeries: AgentWindowSeries[]; windowInsights: AgentWindowInsight[] } {
1188
+ const groups = new Map<string, WindowSample[]>();
1189
+ for (const sample of samples) {
1190
+ const key = `${sample.provider}\u0000${sample.limitId}`;
1191
+ const held = groups.get(key);
1192
+ if (held) held.push(sample);
1193
+ else groups.set(key, [sample]);
1194
+ }
1195
+
1196
+ const usageSeries: AgentWindowSeries[] = [];
1197
+ const windowInsights: AgentWindowInsight[] = [];
1198
+ for (const group of groups.values()) {
1199
+ const first = group[0];
1200
+ const windowLabel = windowDisplayLabel(group[group.length - 1]);
1201
+ const accounts = new Map<string, WindowSample[]>();
1202
+ for (const sample of group) {
1203
+ const held = accounts.get(sample.accountKey);
1204
+ if (held) held.push(sample);
1205
+ else accounts.set(sample.accountKey, [sample]);
1206
+ }
1207
+
1208
+ let fractionConsumed = 0;
1209
+ let cycles = 0;
1210
+ let exhaustedEvents = 0;
1211
+ for (const [accountKey, accountSamples] of accounts) {
1212
+ usageSeries.push({
1213
+ provider: first.provider,
1214
+ accountKey,
1215
+ accountLabel: accountSamples[accountSamples.length - 1].accountLabel,
1216
+ windowKey: first.limitId,
1217
+ windowLabel,
1218
+ points: downsampleWindow(accountSamples.map((sample) => ({
1219
+ ts: sample.ts,
1220
+ usedFraction: round4(sample.usedFraction),
1221
+ exhausted: sample.exhausted,
1222
+ }))),
1223
+ });
1224
+
1225
+ let previous: number | null = null;
1226
+ let wasExhausted = false;
1227
+ for (const sample of accountSamples) {
1228
+ if (sample.exhausted && !wasExhausted) exhaustedEvents += 1;
1229
+ wasExhausted = sample.exhausted;
1230
+ if (sample.usedFraction === null) continue;
1231
+ if (previous !== null) {
1232
+ const delta = sample.usedFraction - previous;
1233
+ if (delta > 0) fractionConsumed += delta;
1234
+ else if (delta < -WINDOW_RESET_DROP) cycles += 1;
1235
+ }
1236
+ previous = sample.usedFraction;
1237
+ }
1238
+ }
1239
+
1240
+ const providerTokens = tokensByProvider.get(first.provider) ?? 0;
1241
+ const peak = peakConcurrent(group);
1242
+ windowInsights.push({
1243
+ provider: first.provider,
1244
+ windowKey: first.limitId,
1245
+ windowLabel,
1246
+ accounts: accounts.size,
1247
+ cycles,
1248
+ fractionConsumed,
1249
+ estTokensPerWindow:
1250
+ providerTokens > 0 && fractionConsumed >= WINDOW_MIN_EXTRAPOLATION
1251
+ ? Math.round(providerTokens / fractionConsumed)
1252
+ : null,
1253
+ peakConcurrentFraction: peak,
1254
+ idealAccounts: Math.max(1, Math.ceil(peak / WINDOW_TARGET_PEAK)),
1255
+ exhaustedEvents,
1256
+ });
1257
+ }
1258
+
1259
+ usageSeries.sort(
1260
+ (a, b) =>
1261
+ a.provider.localeCompare(b.provider) ||
1262
+ a.windowKey.localeCompare(b.windowKey) ||
1263
+ a.accountLabel.localeCompare(b.accountLabel),
1264
+ );
1265
+ windowInsights.sort((a, b) => a.provider.localeCompare(b.provider) || b.fractionConsumed - a.fractionConsumed);
1266
+ return { usageSeries, windowInsights };
1267
+ }
1268
+
1269
+ /** Tokens per provider over the range: the currency a window insight extrapolates in. */
1270
+ 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`]]);
1273
+ return new Map((out.get('providerTokens') ?? []).map((r) => [r[0] ?? '', num(r[1])]));
1274
+ }
1275
+
1276
+ /** Peak burn hours: one row per provider per local hour, the shape the reference charts. */
1277
+ 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`]]);
1282
+ return (out.get('providerHourly') ?? []).map((r) => ({
1283
+ provider: r[0] ?? '',
1284
+ hour: num(r[1]),
1285
+ totalTokens: num(r[2]),
1286
+ outputTokens: num(r[3]),
1287
+ requests: num(r[4]),
1288
+ }));
1289
+ }
1290
+
1291
+ /** Middle value, ignoring nulls. The Requests page summarises latency this way. */
1292
+ function median(values: Array<number | null | undefined>): number {
1293
+ const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
1294
+ if (nums.length === 0) return 0;
1295
+ const mid = nums.length >> 1;
1296
+ return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
1297
+ }
1298
+
1299
+ /** The Requests page summary, computed over the rows the payload carries. */
1300
+ function computeRequestStats(rows: AgentRequestRow[]): AgentRequestStats {
1301
+ const durations = rows.map((row) => row.durationMs);
1302
+ const sorted = durations.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
1303
+ const pick = (q: number): number => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
1304
+ const times = rows.map((row) => row.ts);
1305
+ return {
1306
+ requests: rows.length,
1307
+ failed: rows.filter((row) => row.stopReason === 'error').length,
1308
+ 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,
1312
+ medianDurationMs: median(durations),
1313
+ p95DurationMs: pick(0.95),
1314
+ medianTtftMs: median(rows.map((row) => row.ttftMs)),
1315
+ oldest: times.length ? Math.min(...times) : null,
1316
+ newest: times.length ? Math.max(...times) : null,
1317
+ };
1318
+ }
1319
+
1320
+ /** How many spans one trace ships. A long session holds thousands; the transcript virtualises. */
1321
+ const TRACE_SPAN_LIMIT = 6000;
1322
+ /** How much of a message body a span carries: enough for one transcript line. */
1323
+ const TRACE_DETAIL_MAX = 120;
1324
+
1325
+ /** First non-empty line of a message body, for the span's detail line. */
1326
+ function firstLine(parts: Array<Record<string, unknown>>): string {
1327
+ const text = parts
1328
+ .map((part) => String(part.text ?? part.thinking ?? ''))
1329
+ .map((value) => value.replace(/\s+/g, ' ').trim())
1330
+ .find(Boolean);
1331
+ return text ? text.slice(0, TRACE_DETAIL_MAX) : '';
1332
+ }
1333
+
1334
+ /** How a request stopped, read from the assistant message's stop reason. */
1335
+ function stopState(stopReason: string): AgentTraceSpan['stopped'] {
1336
+ if (stopReason === 'error') return 'failed';
1337
+ if (stopReason === 'aborted') return 'aborted';
1338
+ return 'completed';
1339
+ }
1340
+
1341
+ /** Tool names that hand the work to another agent, for the Agents figure. */
1342
+ const AGENT_TOOLS = new Set(['task', 'subagent']);
1343
+
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
+ */
1353
+ export function readSessionTrace(sessionFile: string, limit = TRACE_SPAN_LIMIT): AgentSessionTrace {
1354
+ const project = sessionFile.split('/sessions/')[1]?.split('/')[0] ?? '';
1355
+ const empty: AgentSessionTrace = {
1356
+ sessionFile, project, title: '', cwd: project, startedAt: 0, endedAt: 0,
1357
+ tracks: [], markers: [], summary: emptyTraceSummary(), toolStats: [], truncated: false,
1358
+ };
1359
+ if (!sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile)) return empty;
1360
+
1361
+ let text: string;
1362
+ try {
1363
+ text = fs.readFileSync(sessionFile, 'utf8');
1364
+ } catch {
1365
+ return empty;
1366
+ }
1367
+
1368
+ // Pass one: pull each line into the shape the spans are cut from.
1369
+ type Raw = {
1370
+ entryId: string; ts: number; role: string; tool: string; detail: string;
1371
+ model: string; provider: string; tokens: number; costUsd: number;
1372
+ durationMs: number | null; startAt: number | null; isError: boolean;
1373
+ stopped: AgentTraceSpan['stopped']; callId: string; calls: Array<{ id: string; name: string }>;
1374
+ };
1375
+ const raw: Raw[] = [];
1376
+ const markers: AgentTraceMarker[] = [];
1377
+ let title = '';
1378
+ let truncated = false;
1379
+ for (const line of text.split('\n')) {
1380
+ if (!line.trim()) continue;
1381
+ let parsed: Record<string, unknown>;
1382
+ try {
1383
+ parsed = JSON.parse(line) as Record<string, unknown>;
1384
+ } catch {
1385
+ continue;
1386
+ }
1387
+ const entryId = String(parsed.id ?? '');
1388
+ // The line's stamp is when the entry landed; the message's own stamp, a number, is when it began.
1389
+ const ts = Date.parse(String(parsed.timestamp ?? '')) || 0;
1390
+ const own = (parsed.message as { timestamp?: unknown } | undefined)?.timestamp;
1391
+ const startAt = typeof own === 'number' && Number.isFinite(own) ? own : null;
1392
+ if (parsed.type !== 'message') {
1393
+ const marker = markerFor(entryId, ts, parsed);
1394
+ if (marker) markers.push(marker);
1395
+ if (parsed.type === 'title' && typeof parsed.title === 'string') title = parsed.title;
1396
+ continue;
1397
+ }
1398
+ const message = (parsed.message ?? {}) as Record<string, unknown>;
1399
+ const content = Array.isArray(message.content) ? message.content as Array<Record<string, unknown>> : [];
1400
+ const usage = (message.usage ?? {}) as Record<string, unknown>;
1401
+ const cost = (usage.cost ?? {}) as Record<string, unknown>;
1402
+ raw.push({
1403
+ entryId,
1404
+ ts,
1405
+ role: String(message.role ?? ''),
1406
+ tool: String(message.toolName ?? ''),
1407
+ detail: firstLine(content),
1408
+ model: String(message.model ?? ''),
1409
+ provider: String(message.provider ?? ''),
1410
+ tokens: num(String(usage.totalTokens ?? 0)),
1411
+ costUsd: num(String(cost.total ?? 0)),
1412
+ durationMs: typeof message.duration === 'number' ? message.duration : null,
1413
+ startAt,
1414
+ isError: message.isError === true,
1415
+ stopped: stopState(String(message.stopReason ?? '')),
1416
+ callId: String(message.toolCallId ?? ''),
1417
+ calls: content
1418
+ .filter((part) => part.type === 'toolCall')
1419
+ .map((part) => ({ id: String(part.id ?? ''), name: String(part.name ?? 'tool') })),
1420
+ });
1421
+ }
1422
+
1423
+ // A tool result closes the call it names, so the call's span runs to it.
1424
+ const resultAt = new Map<string, Raw>();
1425
+ const callIds = new Set<string>();
1426
+ for (const row of raw) {
1427
+ for (const call of row.calls) if (call.id) callIds.add(call.id);
1428
+ if (row.role === 'toolResult' && row.callId && !resultAt.has(row.callId)) resultAt.set(row.callId, row);
1429
+ }
1430
+
1431
+ const built: AgentTraceSpan[] = [];
1432
+ let turnOpen: AgentTraceSpan | null = null;
1433
+ for (const row of raw) {
1434
+ const base = {
1435
+ model: row.model, provider: row.provider, tokens: row.tokens, costUsd: row.costUsd,
1436
+ isError: row.isError, entryId: row.entryId,
1437
+ };
1438
+ if (row.role === 'assistant') {
1439
+ // A model call runs from its own start stamp to the line's stamp; the duration is the cross-check.
1440
+ const start = row.startAt !== null && row.startAt < row.ts ? row.startAt : row.durationMs && row.durationMs > 0 ? row.ts - row.durationMs : row.ts;
1441
+ if (turnOpen) turnOpen.end = start;
1442
+ turnOpen = null;
1443
+ built.push({ ...base, id: row.entryId || `model-${built.length}`, kind: 'model', label: row.model || 'assistant',
1444
+ detail: row.detail, start, end: row.ts, tool: '', stopped: row.stopped });
1445
+ row.calls.forEach((call, index) => {
1446
+ const result = call.id ? resultAt.get(call.id) : undefined;
1447
+ const end = result ? Math.max(result.ts, row.ts) : row.ts;
1448
+ built.push({ ...base, id: `${row.entryId}#${index}`, kind: 'tool', label: call.name, detail: result?.detail ?? '',
1449
+ start: row.ts, end, tool: call.name, isError: result ? result.isError : base.isError, stopped: 'completed' });
1450
+ });
1451
+ continue;
1452
+ }
1453
+ if (row.role === 'toolResult') {
1454
+ // A result whose call was compacted away still ran: draw it as a point.
1455
+ if (row.callId && callIds.has(row.callId)) continue;
1456
+ built.push({ ...base, id: row.entryId || `tool-${built.length}`, kind: 'tool', label: row.tool || 'tool',
1457
+ detail: row.detail, start: row.ts, end: row.ts, tool: row.tool, stopped: 'completed' });
1458
+ continue;
1459
+ }
1460
+ if (row.role === 'user') {
1461
+ if (turnOpen) turnOpen.end = row.ts;
1462
+ turnOpen = { ...base, id: `turn-${built.length}`, kind: 'turn', label: 'Prompt', detail: row.detail,
1463
+ start: row.ts, end: row.ts, tool: '', stopped: 'completed' };
1464
+ built.push(turnOpen);
1465
+ continue;
1466
+ }
1467
+ built.push({ ...base, id: row.entryId || `bg-${built.length}`, kind: 'background', label: row.role || 'system',
1468
+ detail: row.detail, start: row.ts, end: row.ts, tool: '', stopped: 'completed' });
1469
+ }
1470
+
1471
+ const lastEnd = built.reduce((max, span) => Math.max(max, span.end), 0);
1472
+ if (turnOpen) turnOpen.end = Math.max(turnOpen.end, lastEnd);
1473
+ // A session longer than the cap keeps its newest spans: the opening minutes are the least useful.
1474
+ const spans = built.length > limit ? built.slice(-limit) : built;
1475
+ if (built.length > limit) truncated = true;
1476
+ spans.sort((a, b) => a.start - b.start);
1477
+ const startedAt = spans.length ? Math.min(...spans.map((span) => span.start)) : 0;
1478
+ const endedAt = spans.length ? Math.max(...spans.map((span) => span.end)) : 0;
1479
+
1480
+ return {
1481
+ sessionFile, project, title, cwd: project, startedAt, endedAt,
1482
+ tracks: [{ id: 'main', label: 'Session', parentId: null, spans }],
1483
+ markers: markers.toSorted((a, b) => a.ts - b.ts),
1484
+ summary: summarizeTrace(spans, startedAt, endedAt),
1485
+ toolStats: buildToolStats(spans),
1486
+ truncated,
1487
+ };
1488
+ }
1489
+
1490
+ /** A non-message journal entry, when it marks a change worth a diamond on the timeline. */
1491
+ function markerFor(entryId: string, ts: number, parsed: Record<string, unknown>): AgentTraceMarker | null {
1492
+ const kind = String(parsed.type ?? '');
1493
+ if (kind === 'model_change') return { id: entryId || `model-${ts}`, ts, kind: 'model', label: String(parsed.model ?? 'model') };
1494
+ if (kind === 'thinking_level_change') {
1495
+ return { id: entryId || `think-${ts}`, ts, kind: 'think', label: String(parsed.level ?? 'thinking') };
1496
+ }
1497
+ if (kind === 'session') return { id: entryId || `session-${ts}`, ts, kind: 'session', label: 'session started' };
1498
+ // Only the changes ride along: a session fires hundreds of events that would bury the diamonds.
1499
+ return null;
1500
+ }
1501
+
1502
+ /** The empty summary, for a file that no longer reads. */
1503
+ function emptyTraceSummary(): AgentTraceSummary {
1504
+ return { wallMs: 0, modelMs: 0, toolMs: 0, idleMs: 0, turns: 0, requests: 0, toolCalls: 0, agents: 0, totalTokens: 0, costUsd: 0 };
1505
+ }
1506
+
1507
+ /** The nine figures: wall time split into model, tool, and idle, then the work counts. */
1508
+ function summarizeTrace(spans: AgentTraceSpan[], startedAt: number, endedAt: number): AgentTraceSummary {
1509
+ const wallMs = endedAt > startedAt ? endedAt - startedAt : 0;
1510
+ let modelMs = 0;
1511
+ let toolMs = 0;
1512
+ let totalTokens = 0;
1513
+ let costUsd = 0;
1514
+ for (const span of spans) {
1515
+ const held = Math.max(0, span.end - span.start);
1516
+ if (span.kind === 'model') modelMs += held;
1517
+ if (span.kind === 'tool') toolMs += held;
1518
+ if (span.kind === 'model') totalTokens += span.tokens;
1519
+ if (span.kind === 'model') costUsd += span.costUsd;
1520
+ }
1521
+ return {
1522
+ wallMs,
1523
+ modelMs,
1524
+ toolMs,
1525
+ // Idle is what no span covered: wall time minus the time a model or a tool held.
1526
+ idleMs: Math.max(0, wallMs - modelMs - toolMs),
1527
+ turns: spans.filter((span) => span.kind === 'turn').length,
1528
+ requests: spans.filter((span) => span.kind === 'model').length,
1529
+ toolCalls: spans.filter((span) => span.kind === 'tool').length,
1530
+ agents: spans.filter((span) => span.kind === 'tool' && AGENT_TOOLS.has(span.tool)).length,
1531
+ totalTokens,
1532
+ costUsd,
1533
+ };
1534
+ }
1535
+
1536
+ /** Per-tool duration totals, longest first. */
1537
+ function buildToolStats(spans: AgentTraceSpan[]): AgentTraceToolStat[] {
1538
+ const byTool = new Map<string, number[]>();
1539
+ for (const span of spans) {
1540
+ if (span.kind !== 'tool') continue;
1541
+ const held = Math.max(0, span.end - span.start);
1542
+ const list = byTool.get(span.tool || 'tool') ?? [];
1543
+ list.push(held);
1544
+ byTool.set(span.tool || 'tool', list);
1545
+ }
1546
+ return [...byTool.entries()]
1547
+ .map(([tool, held]) => {
1548
+ const sorted = held.toSorted((a, b) => a - b);
1549
+ const totalMs = sorted.reduce((sum, value) => sum + value, 0);
1550
+ return {
1551
+ tool,
1552
+ calls: held.length,
1553
+ errors: spans.filter((span) => span.kind === 'tool' && (span.tool || 'tool') === tool && span.isError).length,
1554
+ totalMs,
1555
+ avgMs: totalMs / Math.max(1, sorted.length),
1556
+ maxMs: sorted[held.length - 1] ?? 0,
1557
+ };
1558
+ })
1559
+ .toSorted((a, b) => b.totalMs - a.totalMs);
1560
+ }
1561
+
1562
+ /** The agent settings in force when a request ran, read from the journal's change entries. */
1563
+ export interface JournalAgent {
1564
+ /** The model id in force, from the closest `model_change` before the entry. */
1565
+ model: string | null;
1566
+ thinkingLevel: string | null;
1567
+ /** The assistant mode, such as `full` or `goal`, from `mode_change`. */
1568
+ mode: string | null;
1569
+ /** Whether the model id was a fallback at the time of the change. */
1570
+ fallback: boolean | null;
1571
+ }
1572
+
1573
+ /** The payload a request with no readable entry returns, so the route always answers with one shape. */
1574
+ export function emptyRequestEntry(): { entry: unknown; output: unknown; messageRole: string; agent: JournalAgent } {
1575
+ return { entry: null, output: null, messageRole: '', agent: { model: null, thinkingLevel: null, mode: null, fallback: null } };
1576
+ }
1577
+
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
+ */
1584
+ export function readAgentRequestEntry(
1585
+ sessionFile: string,
1586
+ entryId: string,
1587
+ ): { entry: unknown; output: unknown; messageRole: string; agent: JournalAgent } {
1588
+ const empty = emptyRequestEntry();
1589
+ if (!entryId || !sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile)) return empty;
1590
+ let text: string;
1591
+ try {
1592
+ text = fs.readFileSync(sessionFile, 'utf8');
1593
+ } catch {
1594
+ return empty;
1595
+ }
1596
+ const agent = { ...empty.agent };
1597
+ for (const line of text.split('\n')) {
1598
+ if (!line.trim()) continue;
1599
+ let parsed: Record<string, unknown>;
1600
+ try {
1601
+ parsed = JSON.parse(line) as Record<string, unknown>;
1602
+ } catch {
1603
+ continue;
1604
+ }
1605
+ if (String(parsed.id ?? '') !== entryId) {
1606
+ // A change entry updates the state every later request sees.
1607
+ if (parsed.type === 'model_change') {
1608
+ agent.model = String(parsed.model ?? '') || null;
1609
+ agent.fallback = parsed.resolvedModelIsFallback === true;
1610
+ } else if (parsed.type === 'thinking_level_change') {
1611
+ agent.thinkingLevel = String(parsed.thinkingLevel ?? '') || null;
1612
+ } else if (parsed.type === 'mode_change') {
1613
+ agent.mode = String(parsed.mode ?? '') || null;
1614
+ }
1615
+ continue;
1616
+ }
1617
+ const message = (parsed.message ?? null) as Record<string, unknown> | null;
1618
+ return { entry: parsed, output: message, messageRole: message ? String(message.role ?? '') : '', agent };
1619
+ }
1620
+ return empty;
1621
+ }
1622
+
1623
+
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 {
1626
+ const span = RANGE_MS[range];
1627
+ const cutoff = span > 0 ? now - span : 0;
1628
+ const parts = new Set(VIEW_PARTS[view]);
1629
+ const stats = emptyAgentStats(range);
1630
+ stats.cutoff = cutoff;
1631
+
1632
+ const db = statsDbPath();
1633
+ const cols = fs.existsSync(db) ? tableColumns(db, 'messages') : new Set<string>();
1634
+ if (cols.size === 0) {
1635
+ if (parts.has('windows')) Object.assign(stats, computeWindows(readWindowSamples(cutoff), new Map()));
1636
+ return stats;
1637
+ }
1638
+
1639
+ // 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)}`]]);
1641
+ const [firstTs, lastTs] = bounds.get('bounds')?.[0] ?? [];
1642
+ stats.bucketMs = bucketSize(range, numOrNull(firstTs), numOrNull(lastTs));
1643
+
1644
+ Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, cols, parts));
1645
+ if (parts.has('seriesByProvider')) stats.seriesByProvider = readProviderSeries(db, cutoff, stats.bucketMs);
1646
+ if (parts.has('modelSeries')) stats.modelSeries = readModelSeries(db, cutoff, stats.bucketMs);
1647
+ if (parts.has('modelPerformance')) stats.modelPerformance = readModelPerformance(db, cutoff, stats.bucketMs);
1648
+ if (parts.has('tools')) Object.assign(stats, readToolSeries(db, cutoff, stats.bucketMs));
1649
+ if (stats.byModel.length > 0) stats.topModels = stats.byModel.slice(0, 12);
1650
+ if (parts.has('providerHourly')) stats.providerHourly = readProviderHourly(db, cutoff);
1651
+ if (parts.has('recent')) stats.requestStats = computeRequestStats(stats.recent);
1652
+ if (parts.has('windows')) {
1653
+ Object.assign(stats, computeWindows(readWindowSamples(cutoff), readProviderTokens(db, cutoff)));
1654
+ }
1655
+ stats.available = true;
1656
+ return stats;
1657
+ }