@krtclcdy/tersio 2.25.4 → 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 (77) hide show
  1. package/README.md +27 -63
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +187 -0
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +29 -13
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +168 -110
  8. package/dist/cli/doctor-fix.js +232 -50
  9. package/dist/cli/doctor.js +281 -32
  10. package/dist/cli/hosts.js +29 -9
  11. package/dist/cli/install.js +204 -69
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +125 -17
  14. package/dist/cli/profile.js +14 -9
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +24 -8
  18. package/dist/cli/update.js +9 -10
  19. package/dist/cli/usage.js +75 -7
  20. package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
  21. package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
  22. package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +89 -0
  24. package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
  25. package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
  29. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
  30. package/dist/extensions/{opencode/server.js → server.js} +98 -14
  31. package/dist/extensions/shared/agent-stats.js +1198 -0
  32. package/dist/extensions/shared/brand-marks.js +153 -0
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +12 -7
  35. package/dist/extensions/shared/pricing.js +86 -30
  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 +113 -39
  42. package/dist/extensions/shared/usage-store.js +191 -48
  43. package/dist/extensions/{lib → shared}/utils.js +6 -6
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
  46. package/dist/probe-catalog.js +48 -0
  47. package/dist/probe-catalog188.js +44 -0
  48. package/dist/tersio.js +9 -4
  49. package/extensions/{caveman-session → caveman}/index.ts +4 -2
  50. package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
  51. package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
  52. package/extensions/caveman/rule.md +89 -0
  53. package/extensions/{combo-toggle → combo}/index.ts +62 -19
  54. package/extensions/{tersio-commands → commands}/index.ts +2 -2
  55. package/extensions/pstack/index.ts +128 -0
  56. package/extensions/pstack/rule.md +65 -0
  57. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
  58. package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
  59. package/extensions/{opencode/server.ts → server.ts} +81 -16
  60. package/extensions/shared/agent-stats.ts +1605 -0
  61. package/extensions/shared/brand-marks.ts +163 -0
  62. package/extensions/shared/carbon.ts +1 -1
  63. package/extensions/shared/plugin-settings.ts +13 -7
  64. package/extensions/shared/pricing.ts +95 -32
  65. package/extensions/shared/rtk-gain.ts +4 -9
  66. package/extensions/shared/session-state.ts +18 -13
  67. package/extensions/shared/sqlite.ts +168 -0
  68. package/extensions/shared/status.ts +9 -8
  69. package/extensions/shared/types.ts +2 -1
  70. package/extensions/shared/usage-ledger.ts +134 -46
  71. package/extensions/shared/usage-store.ts +202 -50
  72. package/extensions/{lib → shared}/utils.ts +6 -6
  73. package/extensions/timing/index.ts +45 -0
  74. package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
  75. package/package.json +27 -18
  76. package/dist/extensions/caveman-session/rule.md +0 -79
  77. package/extensions/caveman-session/rule.md +0 -79
@@ -0,0 +1,1198 @@
1
+ // Read-only aggregate over the usage store; missing SQLite, table, or metric reads as zeros.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { sqliteRead } from './sqlite.js';
6
+ import { usageDbPath } from './usage-store.js';
7
+ export const RANGES = ['1h', '24h', '7d', '30d', '90d', 'all'];
8
+ export function isRangeKey(v) {
9
+ return typeof v === 'string' && RANGES.includes(v);
10
+ }
11
+ export const AGENT_VIEWS = [
12
+ 'all', 'overview', 'models', 'providers', 'costs', 'carbon', 'requests',
13
+ 'errors', 'traces', 'tools', 'projects',
14
+ ];
15
+ export function isAgentView(v) {
16
+ return typeof v === 'string' && AGENT_VIEWS.includes(v);
17
+ }
18
+ /** Query group names, matching the blocks readStatsDb issues. */
19
+ const VIEW_PARTS = {
20
+ all: ['overall', 'byModel', 'byProvider', 'byAgentType', 'byProject', 'series', 'hourOfDay', 'recent', 'errors', 'traces',
21
+ 'seriesByProvider', 'modelSeries', 'modelPerformance', 'tools', 'windows', 'providerHourly'],
22
+ overview: ['overall', 'byModel', 'byProvider', 'byAgentType', 'byProject', 'series', 'hourOfDay', 'recent',
23
+ 'seriesByProvider', 'modelSeries'],
24
+ models: ['overall', 'byModel', 'modelSeries', 'modelPerformance', 'series'],
25
+ providers: ['overall', 'byProvider', 'series', 'seriesByProvider', 'providerHourly', 'windows'],
26
+ // `modelSeries` feeds the per-model stack, so the page's first paint is not an empty chart.
27
+ costs: ['overall', 'byModel', 'series', 'modelSeries'],
28
+ // The Carbon page needs the same two facts as Costs: per-model output tokens and the mix.
29
+ carbon: ['overall', 'byModel'],
30
+ requests: ['overall', 'recent'],
31
+ errors: ['errors', 'overall', 'byModel', 'recent'],
32
+ traces: ['traces'],
33
+ tools: ['overall', 'tools'],
34
+ projects: ['overall', 'byProject'],
35
+ };
36
+ /** Window length in ms. `all` has no cutoff, so it reads as 0. */
37
+ const RANGE_MS = {
38
+ '1h': 3_600_000,
39
+ '24h': 86_400_000,
40
+ '7d': 604_800_000,
41
+ '30d': 2_592_000_000,
42
+ '90d': 7_776_000_000,
43
+ all: 0,
44
+ };
45
+ /** Bucket width per range, measured against the reference: 5 minutes, then 1 hour, then 1 day. */
46
+ const BUCKET_MS = {
47
+ '1h': 300_000,
48
+ '24h': 3_600_000,
49
+ '7d': 86_400_000,
50
+ '30d': 86_400_000,
51
+ '90d': 86_400_000,
52
+ all: 86_400_000,
53
+ };
54
+ /** A series never renders more than this many buckets; a wider span coarsens the bucket. */
55
+ const MAX_BUCKETS = 1500;
56
+ const RECENT_LIMIT = 200;
57
+ const ERROR_LIMIT = 300;
58
+ const TRACE_LIMIT = 300;
59
+ const TOOL_MODEL_LIMIT = 300;
60
+ const SERIES_PROVIDERS = 8;
61
+ const SERIES_MODELS = 12;
62
+ /** The store every metric but subscription quota reads, kept in step by `tersio usage`. */
63
+ export function statsDbPath() {
64
+ return process.env.TERSIO_AGENT_STATS_DB ?? usageDbPath();
65
+ }
66
+ /** Quota snapshots. Only omp records these, so this stays omp's own database. */
67
+ export function agentDbPath() {
68
+ return process.env.TERSIO_AGENT_SESSIONS_DB ?? path.join(os.homedir(), '.omp', 'agent', 'agent.db');
69
+ }
70
+ /** The row shape every query groups by: a message joined to its file, since path and facts live apart. */
71
+ const SOURCE = 'messages m JOIN files f ON f.id = m.file_id';
72
+ /** The transcript path with any host-side row suffix removed, so one file groups as one session. */
73
+ const FILE_KEY = `substr(f.path, 1, instr(f.path || '#', '#') - 1)`;
74
+ /** Model-id spellings whose dashboard mark lives under another name; the first segment names the gateway. */
75
+ const PROVIDER_ALIASES = {
76
+ 'opencode-zen': 'opencode',
77
+ 'opencode-go': 'opencode',
78
+ 'gmi-cloud': 'gmicloud',
79
+ 'charm-hyper': 'charmhyper',
80
+ 'kimi-code': 'kimi',
81
+ 'openai-codex': 'openai',
82
+ 'github-copilot': 'githubcopilot',
83
+ 'ollama-cloud': 'ollama',
84
+ 'amd-radeon-cloud-cn': 'amd',
85
+ 'google-antigravity': 'google',
86
+ devin: 'cognition',
87
+ };
88
+ /** SQL: the gateway a model id names, from its first segment. Empty when the id carries none, not a guess. */
89
+ function providerExpr(model) {
90
+ const seg = `substr(${model}, 1, instr(${model} || '/', '/') - 1)`;
91
+ const named = `ltrim(${seg}, '~')`;
92
+ const cases = Object.entries(PROVIDER_ALIASES)
93
+ .map(([from, to]) => `WHEN '${from}' THEN '${to}'`)
94
+ .join(' ');
95
+ return `CASE WHEN instr(${model}, '/') = 0 THEN '' ELSE CASE ${named} ${cases} ELSE ${named} END END`;
96
+ }
97
+ /** SQL: the project a transcript belongs to, named by its session folder. A flat session tree uses its data dir. */
98
+ function projectExpr(file) {
99
+ const marker = `instr(${file}, '/sessions/')`;
100
+ const seg = `substr(${file}, ${marker} + 10, instr(substr(${file}, ${marker} + 10) || '/', '/') - 1)`;
101
+ const base = `replace(${file}, rtrim(${file}, replace(${file}, '/', '')), '')`;
102
+ const dir = `substr(${file}, 1, length(${file}) - length(${base}) - 1)`;
103
+ const dirBase = `replace(${dir}, rtrim(${dir}, replace(${dir}, '/', '')), '')`;
104
+ return `CASE WHEN ${marker} > 0 THEN ${seg} ELSE ${dirBase} END`;
105
+ }
106
+ /** Every column a query reads, mapped to the store's schema in one place. */
107
+ const COL = {
108
+ session: FILE_KEY,
109
+ entry: `COALESCE(m.id, '')`,
110
+ folder: projectExpr(FILE_KEY),
111
+ model: 'm.model',
112
+ provider: providerExpr('m.model'),
113
+ /** No route name is stored, and the Requests page reads one. */
114
+ api: "''",
115
+ timestamp: 'm.t',
116
+ // Bucketing form of the timestamp. REAL division does not floor, so the cast is needed.
117
+ bucket: 'CAST(COALESCE(m.t, 0) AS INTEGER)',
118
+ duration: 'm.d',
119
+ /** First-token latency. A host that records none leaves it NULL, which reads null, never zero. */
120
+ ttft: 'm.tf',
121
+ stopReason: `CASE m.st WHEN 'error' THEN 'error' WHEN 'aborted' THEN 'aborted' ELSE 'stop' END`,
122
+ errorMessage: 'm.note',
123
+ input: 'm.i',
124
+ output: 'm.o',
125
+ cacheRead: 'm.cr',
126
+ cacheWrite: 'm.cw',
127
+ total: '(m.i + m.o + m.cr + m.cw)',
128
+ /** The cost the host reported. A model the host priced nothing for reads $0.00 by design. */
129
+ costTotal: 'COALESCE(m.usd, 0)',
130
+ agentType: `COALESCE(m.a, 'main')`,
131
+ host: `COALESCE(m.h, '')`,
132
+ /** The store holds no per-component split, so the remainder rule below finds nothing to move. */
133
+ costInput: '0',
134
+ costCacheRead: '0',
135
+ costCacheWrite: '0',
136
+ /** No route is a subscription route here, so nothing is ever flagged unpriced. */
137
+ unpriced: '0',
138
+ };
139
+ /** Output cost is the remainder: omp prices the whole request and the three cache-side parts. */
140
+ function costMixOf(f) {
141
+ const input = f.costInput;
142
+ const cacheRead = f.costCacheRead;
143
+ const cacheWrite = f.costCacheWrite;
144
+ return {
145
+ input,
146
+ cacheRead,
147
+ cacheWrite,
148
+ output: Math.max(0, f.costUsd - input - cacheRead - cacheWrite),
149
+ total: f.costUsd,
150
+ };
151
+ }
152
+ export function emptyAgentStats(range = '24h') {
153
+ return {
154
+ available: false,
155
+ range,
156
+ bucketMs: BUCKET_MS[range],
157
+ cutoff: 0,
158
+ generatedAt: Date.now(),
159
+ overall: {
160
+ requests: 0, failed: 0, successful: 0, errorRate: 0, cacheRate: 0,
161
+ costUsd: 0, unpricedRequests: 0,
162
+ avgDurationMs: 0, avgTtftMs: 0, avgTokensPerSecond: 0, maxDurationMs: null,
163
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0,
164
+ costMix: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
165
+ firstTs: null, lastTs: null,
166
+ },
167
+ byModel: [], byProvider: [], byProject: [], byAgentType: [],
168
+ series: [], seriesByProvider: [], modelSeries: [], modelPerformance: [], hourOfDay: [], topModels: [],
169
+ recent: [], errorGroups: [], errorModels: [], traces: [],
170
+ tools: [], toolsByModel: [], toolSeries: [],
171
+ usageSeries: [], windowInsights: [], providerHourly: [],
172
+ requestStats: {
173
+ requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0, shipped: 0,
174
+ medianDurationMs: 0, p95DurationMs: 0, medianTtftMs: 0, oldest: null, newest: null,
175
+ },
176
+ errors: [],
177
+ };
178
+ }
179
+ /** Rows from a read-only script, tab-separated: whichever engine this host has answers it. */
180
+ export function sqliteScript(db, script) {
181
+ return sqliteRead(db, script);
182
+ }
183
+ /** One call per query: an unavailable table leaves its key absent, so a partial schema still reads. */
184
+ function readBlocks(db, queries) {
185
+ const map = new Map();
186
+ for (const [name, sql] of queries) {
187
+ try {
188
+ map.set(name, sqliteScript(db, sql).split('\n').filter((line) => line.trim()).map((line) => line.split('\t')));
189
+ }
190
+ catch { /* an unavailable table leaves its key absent */ }
191
+ }
192
+ return map;
193
+ }
194
+ function tableColumns(db, table) {
195
+ try {
196
+ const out = sqliteScript(db, `SELECT name FROM pragma_table_info('${table}');\n`);
197
+ return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean));
198
+ }
199
+ catch {
200
+ return new Set();
201
+ }
202
+ }
203
+ function num(v) {
204
+ const n = Number(v);
205
+ return Number.isFinite(n) ? n : 0;
206
+ }
207
+ function numOrNull(v) {
208
+ if (v === undefined || v === '')
209
+ return null;
210
+ const n = Number(v);
211
+ return Number.isFinite(n) ? n : null;
212
+ }
213
+ /** A text column must stay on one line: the parser splits rows on tabs and newlines. */
214
+ function txt(column) {
215
+ return `replace(replace(replace(COALESCE(${column}, ''), char(10), ' '), char(13), ' '), char(9), ' ')`;
216
+ }
217
+ /** The unpriced rule, kept for callers that group by it: no route here is a subscription route. */
218
+ function unpricedSql() {
219
+ return '0';
220
+ }
221
+ /** The additive facts every messages group reads. */
222
+ function messageFacts() {
223
+ return [
224
+ 'COUNT(*)',
225
+ `SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)`,
226
+ `SUM(${COL.input})`,
227
+ `SUM(${COL.output})`,
228
+ `SUM(${COL.cacheRead})`,
229
+ `SUM(${COL.cacheWrite})`,
230
+ `SUM(${COL.total})`,
231
+ `TOTAL(${COL.costTotal})`,
232
+ `TOTAL(${COL.costInput})`,
233
+ `TOTAL(${COL.costCacheRead})`,
234
+ `TOTAL(${COL.costCacheWrite})`,
235
+ `SUM(${COL.unpriced})`,
236
+ `TOTAL(${COL.duration})`,
237
+ `COUNT(${COL.duration})`,
238
+ `TOTAL(${COL.ttft})`,
239
+ `COUNT(${COL.ttft})`,
240
+ `TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END)`,
241
+ `COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END)`,
242
+ `MIN(${COL.timestamp})`,
243
+ `MAX(${COL.timestamp})`,
244
+ `MAX(${COL.duration})`,
245
+ ].join(', ');
246
+ }
247
+ /** Column order of a recent or error row, matching `requestColumns` below. */
248
+ const REQUEST_COLUMNS = [
249
+ COL.timestamp, COL.provider, COL.model, COL.folder, COL.api,
250
+ COL.input, COL.output, COL.cacheRead, COL.cacheWrite, COL.total,
251
+ COL.costTotal, COL.unpriced, COL.duration, COL.ttft, COL.stopReason, COL.errorMessage,
252
+ COL.agentType, COL.session, COL.entry,
253
+ ];
254
+ function parseFacts(row, at) {
255
+ const g = (i) => num(row?.[at + i]);
256
+ return {
257
+ requests: g(0), failed: g(1),
258
+ input: g(2), output: g(3), cacheRead: g(4), cacheWrite: g(5), total: g(6),
259
+ costUsd: g(7),
260
+ costInput: g(8), costCacheRead: g(9), costCacheWrite: g(10),
261
+ unpriced: g(11),
262
+ durationSum: g(12), durationN: g(13), ttftSum: g(14), ttftN: g(15),
263
+ tpsSum: g(16), tpsN: g(17),
264
+ firstTs: numOrNull(row?.[at + 18]), lastTs: numOrNull(row?.[at + 19]),
265
+ maxDurationMs: numOrNull(row?.[at + 20]),
266
+ };
267
+ }
268
+ function mix(f) {
269
+ return { input: f.input, output: f.output, cacheRead: f.cacheRead, cacheWrite: f.cacheWrite, total: f.total };
270
+ }
271
+ const cacheRateOf = (f) => (f.input + f.cacheRead > 0 ? f.cacheRead / (f.input + f.cacheRead) : 0);
272
+ function overallOf(f) {
273
+ return {
274
+ ...mix(f),
275
+ costMix: costMixOf(f),
276
+ requests: f.requests,
277
+ failed: f.failed,
278
+ successful: f.requests - f.failed,
279
+ errorRate: f.requests > 0 ? f.failed / f.requests : 0,
280
+ cacheRate: cacheRateOf(f),
281
+ costUsd: f.costUsd,
282
+ unpricedRequests: f.unpriced,
283
+ avgDurationMs: f.durationN > 0 ? f.durationSum / f.durationN : 0,
284
+ avgTtftMs: f.ttftN > 0 ? f.ttftSum / f.ttftN : 0,
285
+ avgTokensPerSecond: f.tpsN > 0 ? f.tpsSum / f.tpsN : 0,
286
+ maxDurationMs: f.maxDurationMs,
287
+ firstTs: f.firstTs,
288
+ lastTs: f.lastTs,
289
+ };
290
+ }
291
+ function rowOf(key, f, models, provider = '') {
292
+ return {
293
+ ...mix(f),
294
+ key,
295
+ provider,
296
+ costMix: costMixOf(f),
297
+ requests: f.requests,
298
+ failed: f.failed,
299
+ errorRate: f.requests > 0 ? f.failed / f.requests : 0,
300
+ cacheRate: cacheRateOf(f),
301
+ costUsd: f.costUsd,
302
+ unpricedRequests: f.unpriced,
303
+ avgDurationMs: f.durationN > 0 ? f.durationSum / f.durationN : 0,
304
+ avgTtftMs: f.ttftN > 0 ? f.ttftSum / f.ttftN : 0,
305
+ avgTokensPerSecond: f.tpsN > 0 ? f.tpsSum / f.tpsN : 0,
306
+ firstTs: f.firstTs,
307
+ lastTs: f.lastTs,
308
+ models,
309
+ };
310
+ }
311
+ function rangeWhere(cutoff, column = 'timestamp') {
312
+ return cutoff > 0 ? `WHERE ${column} >= ${Math.floor(cutoff)}` : '';
313
+ }
314
+ /** Utilization is a fraction with float noise; four decimals keep the payload small. */
315
+ function round4(v) {
316
+ return v === null ? null : Math.round(v * 1e4) / 1e4;
317
+ }
318
+ function bucketSize(range, firstTs, lastTs) {
319
+ const base = BUCKET_MS[range];
320
+ if (firstTs === null || lastTs === null)
321
+ return base;
322
+ const span = Math.max(0, lastTs - firstTs);
323
+ if (span / base <= MAX_BUCKETS)
324
+ return base;
325
+ return Math.max(base, Math.ceil(span / MAX_BUCKETS / base) * base);
326
+ }
327
+ function readStatsDb(db, cutoff, bucketMs, parts, offset = 0) {
328
+ const where = rangeWhere(cutoff, COL.timestamp);
329
+ const errorWhere = `WHERE ${COL.stopReason} = 'error'${cutoff > 0 ? ` AND ${COL.timestamp} >= ${Math.floor(cutoff)}` : ''}`;
330
+ const from = SOURCE;
331
+ const out = readBlocks(db, [
332
+ ['overall', `SELECT ${messageFacts()} FROM ${from} ${where}`],
333
+ ['byModel', `SELECT ${COL.model}, ${COL.provider}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 3 DESC`],
334
+ ['byProvider', `SELECT ${COL.provider}, COUNT(DISTINCT ${COL.model}), ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 3 DESC`],
335
+ ['byAgentType', `SELECT ${COL.agentType}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
336
+ ['byProject', `SELECT ${COL.folder}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
337
+ ['series', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, COUNT(*),
338
+ SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END),
339
+ SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
340
+ ['hourOfDay', `SELECT CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
341
+ COUNT(*), SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END), SUM(${COL.total}), TOTAL(${COL.costTotal})
342
+ FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
343
+ ['recent', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${where} ORDER BY ${COL.timestamp} DESC LIMIT ${RECENT_LIMIT} OFFSET ${Math.max(0, Math.floor(offset))}`],
344
+ ['errors', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${errorWhere} ORDER BY ${COL.timestamp} DESC LIMIT ${ERROR_LIMIT}`],
345
+ ['traces', `SELECT ${COL.session}, MAX(${COL.folder}),
346
+ COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}), SUM(${COL.unpriced}),
347
+ MIN(${COL.timestamp}), MAX(${COL.timestamp} + COALESCE(${COL.duration}, 0))
348
+ FROM ${from} ${where} GROUP BY 1 ORDER BY 8 DESC LIMIT ${TRACE_LIMIT}`],
349
+ ].filter(([name]) => parts.has(name)).map(([name, sql]) => [name, sql]));
350
+ // Models and tools are keyed by root, so a child outside the window still folds into its session.
351
+ const traceModels = readTraceModels(db);
352
+ const traceTools = readTraceToolCounts(db);
353
+ const traces = foldTraces((out.get('traces') ?? []).map((r) => {
354
+ // Both readers are keyed by root, so every file of a session reads the session's totals.
355
+ const root = rootSessionFile(r[0] ?? '');
356
+ return {
357
+ sessionFile: r[0] ?? '', project: r[1] ?? '', cwd: '', title: null,
358
+ requests: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]), unpriced: num(r[5]),
359
+ models: traceModels.get(root) ?? [],
360
+ toolCalls: traceTools.get(root) ?? 0,
361
+ subagents: 0, firstTs: num(r[6]), lastTs: num(r[7]),
362
+ };
363
+ }));
364
+ applySessionMetadata(traces);
365
+ const requestRows = (rows) => rows.map((r) => ({
366
+ ts: num(r[0]),
367
+ provider: r[1] ?? '',
368
+ model: r[2] ?? '',
369
+ project: r[3] ?? '',
370
+ api: r[4] ?? '',
371
+ input: num(r[5]), output: num(r[6]), cacheRead: num(r[7]), cacheWrite: num(r[8]), totalTokens: num(r[9]),
372
+ costUsd: num(r[10]),
373
+ unpriced: num(r[11]) > 0,
374
+ durationMs: numOrNull(r[12]),
375
+ ttftMs: numOrNull(r[13]),
376
+ stopReason: r[14] ?? '',
377
+ errorMessage: r[15] || null,
378
+ agentType: r[16] ?? '',
379
+ sessionFile: r[17] ?? '',
380
+ entryId: r[18] ?? '',
381
+ }));
382
+ const errorRows = requestRows(out.get('errors') ?? []);
383
+ const errorCounts = new Map();
384
+ for (const row of errorRows) {
385
+ const key = `${row.model}\u0000${row.provider}`;
386
+ const hit = errorCounts.get(key);
387
+ if (hit)
388
+ hit.count += 1;
389
+ else
390
+ errorCounts.set(key, { model: row.model, provider: row.provider, count: 1 });
391
+ }
392
+ const byModel = (out.get('byModel') ?? [])
393
+ .map((r) => rowOf(r[0] ?? '', parseFacts(r, 2), 0, r[1] ?? ''));
394
+ return {
395
+ overall: overallOf(parseFacts(out.get('overall')?.[0], 0)),
396
+ byModel,
397
+ byProvider: (out.get('byProvider') ?? []).map((r) => rowOf(r[0] ?? '', parseFacts(r, 2), num(r[1]), r[0] ?? '')),
398
+ byProject: (out.get('byProject') ?? []).map((r) => rowOf(r[0] ?? '', parseFacts(r, 1), 0)),
399
+ byAgentType: (out.get('byAgentType') ?? []).map((r) => {
400
+ const f = parseFacts(r, 1);
401
+ return { ...mix(f), agentType: r[0] ?? '', requests: f.requests, costUsd: f.costUsd };
402
+ }),
403
+ series: (out.get('series') ?? []).map((r) => ({
404
+ ts: num(r[0]), requests: num(r[1]), errors: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]),
405
+ })),
406
+ hourOfDay: (out.get('hourOfDay') ?? []).map((r) => ({
407
+ hour: num(r[0]), requests: num(r[1]), errors: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]),
408
+ })),
409
+ recent: requestRows(out.get('recent') ?? []),
410
+ errorGroups: groupErrors(errorRows),
411
+ errors: errorRows,
412
+ errorModels: [...errorCounts.values()].toSorted((a, b) => b.count - a.count),
413
+ traces: traces,
414
+ };
415
+ }
416
+ /** Fold every child transcript into its root session, or a row per file would show one row per subagent. */
417
+ function foldTraces(rows) {
418
+ const byRoot = new Map();
419
+ for (const row of rows) {
420
+ const rootFile = rootSessionFile(row.sessionFile);
421
+ const held = byRoot.get(rootFile);
422
+ if (!held) {
423
+ byRoot.set(rootFile, { ...row, sessionFile: rootFile, models: [...row.models] });
424
+ continue;
425
+ }
426
+ held.requests += row.requests;
427
+ held.tokens += row.tokens;
428
+ held.costUsd += row.costUsd;
429
+ held.unpriced += row.unpriced;
430
+ held.models = [...new Set([...held.models, ...row.models])].toSorted();
431
+ held.firstTs = Math.min(held.firstTs, row.firstTs);
432
+ held.lastTs = Math.max(held.lastTs, row.lastTs);
433
+ held.subagents += 1;
434
+ }
435
+ return [...byRoot.values()]
436
+ .map((row) => ({ ...row, models: row.models.toSorted() }))
437
+ .toSorted((a, b) => b.lastTs - a.lastTs);
438
+ }
439
+ /** The root session file for a transcript, which is itself when it is already a root. */
440
+ export function rootSessionFile(sessionFile) {
441
+ const marker = '/sessions/';
442
+ const at = sessionFile.indexOf(marker);
443
+ if (at < 0)
444
+ return sessionFile;
445
+ const base = sessionFile.slice(0, at + marker.length);
446
+ const parts = sessionFile.slice(at + marker.length).split('/');
447
+ // `<project>/<root>.jsonl` is a root; `<project>/<root>/<child>.jsonl` belongs to that root.
448
+ if (parts.length < 3)
449
+ return sessionFile;
450
+ return `${base}${parts[0]}/${parts[1]}.jsonl`;
451
+ }
452
+ /** Same normalization as the reference: ids and counters collapse, an HTTP status does not. */
453
+ 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;
454
+ const PREFIXED_ID_RE = /\b(?:req|msg|call|toolu|chatcmpl|resp|run|gen)[-_][A-Za-z0-9_-]{6,}/g;
455
+ const HEX_RE = /\b[0-9a-f]{12,}\b/gi;
456
+ const NUMBER_RE = /(?<![\w.-])\d+(?:\.\d+)?/g;
457
+ const HTTP_STATUS_RE = /^[1-5]\d\d$/;
458
+ const SIGNATURE_MAX = 180;
459
+ export function errorSignature(message) {
460
+ if (!message?.trim())
461
+ return 'Unknown error';
462
+ const normalized = message
463
+ .replace(/\s+/g, ' ')
464
+ .trim()
465
+ .replace(UUID_RE, '<id>')
466
+ .replace(PREFIXED_ID_RE, '<id>')
467
+ .replace(HEX_RE, '<hex>')
468
+ .replace(NUMBER_RE, (n) => (HTTP_STATUS_RE.test(n) ? n : 'N'));
469
+ return normalized.length > SIGNATURE_MAX ? `${normalized.slice(0, SIGNATURE_MAX - 1)}…` : normalized;
470
+ }
471
+ function groupErrors(rows) {
472
+ const groups = new Map();
473
+ for (const row of rows) {
474
+ const signature = errorSignature(row.errorMessage);
475
+ const hit = groups.get(signature);
476
+ if (!hit) {
477
+ groups.set(signature, {
478
+ signature, count: 1, firstSeen: row.ts, lastSeen: row.ts, latest: row,
479
+ models: [{ model: row.model, provider: row.provider, count: 1 }],
480
+ });
481
+ continue;
482
+ }
483
+ hit.count += 1;
484
+ hit.firstSeen = Math.min(hit.firstSeen, row.ts);
485
+ hit.lastSeen = Math.max(hit.lastSeen, row.ts);
486
+ const model = hit.models.find((m) => m.model === row.model && m.provider === row.provider);
487
+ if (model)
488
+ model.count += 1;
489
+ else
490
+ hit.models.push({ model: row.model, provider: row.provider, count: 1 });
491
+ if (row.ts >= hit.latest.ts)
492
+ hit.latest = row;
493
+ }
494
+ return [...groups.values()]
495
+ .map((g) => ({ ...g, models: g.models.toSorted((a, b) => b.count - a.count) }))
496
+ .toSorted((a, b) => b.count - a.count || b.lastSeen - a.lastSeen);
497
+ }
498
+ function readProviderSeries(db, cutoff, bucketMs) {
499
+ const out = readBlocks(db, [['providerSeries', `SELECT ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
500
+ COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal})
501
+ FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2 ORDER BY 1, 2`]]);
502
+ const perProvider = new Map();
503
+ for (const r of out.get('providerSeries') ?? []) {
504
+ const provider = r[0] ?? '';
505
+ const held = perProvider.get(provider) ?? { cost: 0, points: [] };
506
+ const point = { ts: num(r[1]), requests: num(r[2]), errors: 0, tokens: num(r[3]), costUsd: num(r[4]) };
507
+ held.points.push(point);
508
+ held.cost += point.costUsd;
509
+ perProvider.set(provider, held);
510
+ }
511
+ return [...perProvider.entries()]
512
+ .toSorted((a, b) => b[1].cost - a[1].cost)
513
+ .slice(0, SERIES_PROVIDERS)
514
+ .map(([provider, held]) => ({ provider, points: held.points }));
515
+ }
516
+ /** Tool calls from each message's names array: a name is one whole call, so per-call sizes read as zero. */
517
+ function readToolSeries(db, cutoff, bucketMs) {
518
+ const where = `WHERE ${COL.timestamp} >= ${Math.floor(cutoff)}`;
519
+ const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
520
+ const from = `${SOURCE}, json_each(${names}) te`;
521
+ const facts = `
522
+ ${COL.timestamp},
523
+ te.value, ${COL.model}, ${COL.provider},
524
+ COUNT(*),
525
+ TOTAL(${COL.total}),
526
+ TOTAL(${COL.output}),
527
+ TOTAL(${COL.costTotal}),
528
+ MAX(${COL.timestamp})`;
529
+ const out = readBlocks(db, [
530
+ ['byTool', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value ORDER BY 5 DESC`],
531
+ ['byToolModel', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value, ${COL.model}, ${COL.provider}
532
+ ORDER BY 5 DESC LIMIT ${TOOL_MODEL_LIMIT}`],
533
+ ['toolSeries', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, te.value, COUNT(*),
534
+ SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)
535
+ FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 1`],
536
+ ]);
537
+ const parseTool = (r) => ({
538
+ tool: r[1] ?? '',
539
+ calls: num(r[4]),
540
+ errors: 0,
541
+ argsChars: 0,
542
+ resultChars: 0,
543
+ totalTokensShare: num(r[5]),
544
+ outputTokensShare: num(r[6]),
545
+ costShare: num(r[7]),
546
+ unpricedShare: 0,
547
+ lastUsed: numOrNull(r[8]),
548
+ model: r[2] ?? '',
549
+ provider: r[3] ?? '',
550
+ });
551
+ return {
552
+ tools: (out.get('byTool') ?? []).map(parseTool),
553
+ toolsByModel: (out.get('byToolModel') ?? []).map(parseTool),
554
+ toolSeries: (out.get('toolSeries') ?? []).map((r) => ({
555
+ ts: num(r[0]), tool: r[1] ?? '', calls: num(r[2]), errors: num(r[3]),
556
+ })),
557
+ };
558
+ }
559
+ /** Tool calls per root session, with every child transcript folded in. */
560
+ function readTraceToolCounts(db) {
561
+ const counts = new Map();
562
+ try {
563
+ const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
564
+ const out = sqliteScript(db, `SELECT ${COL.session}, COUNT(*) FROM ${SOURCE}, json_each(${names}) te GROUP BY 1;\n`);
565
+ for (const line of out.split('\n')) {
566
+ if (!line.trim())
567
+ continue;
568
+ const [file, count] = line.split('\t');
569
+ if (!file)
570
+ continue;
571
+ const root = rootSessionFile(file);
572
+ counts.set(root, (counts.get(root) ?? 0) + num(count));
573
+ }
574
+ }
575
+ catch { /* no tool rows: keep zero */ }
576
+ return counts;
577
+ }
578
+ /** Distinct models per root session, with every child transcript folded in. */
579
+ function readTraceModels(db) {
580
+ const models = new Map();
581
+ try {
582
+ const out = sqliteScript(db, `SELECT ${COL.session}, ${COL.model} FROM ${SOURCE} GROUP BY 1, 2;\n`);
583
+ for (const line of out.split('\n')) {
584
+ if (!line.trim())
585
+ continue;
586
+ const [file, model] = line.split('\t');
587
+ if (!file || !model)
588
+ continue;
589
+ const set = models.get(rootSessionFile(file)) ?? new Set();
590
+ set.add(model);
591
+ models.set(rootSessionFile(file), set);
592
+ }
593
+ }
594
+ catch { /* an unreadable table leaves the list empty */ }
595
+ return new Map([...models.entries()].map(([file, set]) => [file, [...set].toSorted()]));
596
+ }
597
+ /** A session's title and cwd: the `session` line carries the directory and the `title` line sits above it. */
598
+ function applySessionMetadata(traces) {
599
+ for (const trace of traces) {
600
+ if (!trace.sessionFile.endsWith('.jsonl'))
601
+ continue;
602
+ let fd = null;
603
+ try {
604
+ fd = fs.openSync(trace.sessionFile, 'r');
605
+ const buffer = Buffer.alloc(32 * 1024);
606
+ const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
607
+ for (const line of buffer.toString('utf8', 0, read).split('\n')) {
608
+ if (!line.trim())
609
+ continue;
610
+ if (line.includes('"cwd"') && !trace.cwd) {
611
+ const match = line.match(/"cwd"\s*:\s*"([^"]+)"/);
612
+ if (match)
613
+ trace.cwd = match[1];
614
+ }
615
+ if (line.includes('"title"') && !trace.title) {
616
+ const match = line.match(/"title"\s*:\s*"([^"]*)"/);
617
+ if (match)
618
+ trace.title = match[1] || null;
619
+ }
620
+ if (trace.cwd && trace.title)
621
+ break;
622
+ }
623
+ }
624
+ catch { /* a file that no longer reads keeps its empty metadata */ }
625
+ finally {
626
+ if (fd !== null)
627
+ fs.closeSync(fd);
628
+ }
629
+ }
630
+ }
631
+ /** Daily tokens and cost for the busiest models: the Models table sparkline. */
632
+ function readModelSeries(db, cutoff, bucketMs) {
633
+ const scope = rangeWhere(cutoff, COL.timestamp);
634
+ const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
635
+ const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
636
+ const out = readBlocks(db, [['modelSeries', `SELECT ${COL.model}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
637
+ COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${SOURCE} ${clause} GROUP BY 1, 2 ORDER BY 1, 2`]]);
638
+ const perModel = new Map();
639
+ for (const r of out.get('modelSeries') ?? []) {
640
+ const model = r[0] ?? '';
641
+ const points = perModel.get(model) ?? [];
642
+ points.push({ ts: num(r[1]), requests: num(r[2]), errors: 0, tokens: num(r[3]), costUsd: num(r[4]) });
643
+ perModel.set(model, points);
644
+ }
645
+ return [...perModel.entries()].map(([model, points]) => ({ model, points }));
646
+ }
647
+ /** Per-bucket throughput and ttft for the busiest models; a null average means no request carried timing. */
648
+ function readModelPerformance(db, cutoff, bucketMs) {
649
+ const scope = rangeWhere(cutoff, COL.timestamp);
650
+ const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
651
+ const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
652
+ const out = readBlocks(db, [['modelPerformance', `SELECT ${COL.model}, ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
653
+ COUNT(*),
654
+ TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END),
655
+ COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END),
656
+ TOTAL(${COL.ttft}),
657
+ COUNT(${COL.ttft})
658
+ FROM ${SOURCE} ${clause} GROUP BY 1, 2, 3 ORDER BY 1, 2, 3`]]);
659
+ const groups = new Map();
660
+ for (const r of out.get('modelPerformance') ?? []) {
661
+ const key = `${r[0] ?? ''}\u0000${r[1] ?? ''}`;
662
+ const group = groups.get(key) ?? { model: r[0] ?? '', provider: r[1] ?? '', points: [] };
663
+ const durationN = num(r[5]);
664
+ const ttftN = num(r[7]);
665
+ group.points.push({
666
+ ts: num(r[2]),
667
+ requests: num(r[3]),
668
+ avgTokensPerSecond: durationN > 0 ? num(r[4]) / durationN : null,
669
+ avgTtftMs: ttftN > 0 ? num(r[6]) / ttftN : null,
670
+ });
671
+ groups.set(key, group);
672
+ }
673
+ return [...groups.values()];
674
+ }
675
+ /** Reference constants for the quota half, copied from the omp implementation. */
676
+ const WINDOW_RESET_DROP = 0.05;
677
+ const WINDOW_MIN_EXTRAPOLATION = 0.1;
678
+ const WINDOW_TARGET_PEAK = 0.9;
679
+ const WINDOW_EXHAUSTED = 0.999;
680
+ const WINDOW_MAX_POINTS = 400;
681
+ /** Peak per bucket, so a utilization spike survives the cap. */
682
+ function downsampleWindow(points) {
683
+ if (points.length <= WINDOW_MAX_POINTS)
684
+ return points;
685
+ const first = points[0].ts;
686
+ const last = points[points.length - 1].ts;
687
+ const bucket = Math.max(1, Math.ceil((last - first) / WINDOW_MAX_POINTS));
688
+ const out = [];
689
+ let mark = -1;
690
+ for (const point of points) {
691
+ const b = Math.floor((point.ts - first) / bucket);
692
+ if (b !== mark) {
693
+ out.push(point);
694
+ mark = b;
695
+ continue;
696
+ }
697
+ const held = out[out.length - 1];
698
+ if ((point.usedFraction ?? -1) >= (held.usedFraction ?? -1))
699
+ out[out.length - 1] = point;
700
+ }
701
+ return out;
702
+ }
703
+ /** Peak summed quota across accounts, forward-filling last known fractions (1.7 = 1.7 windows held). */
704
+ function peakConcurrent(samples) {
705
+ const events = samples.filter((sample) => sample.usedFraction !== null).toSorted((a, b) => a.ts - b.ts);
706
+ const current = new Map();
707
+ let sum = 0;
708
+ let peak = 0;
709
+ for (const event of events) {
710
+ const fraction = event.usedFraction ?? 0;
711
+ sum += fraction - (current.get(event.accountKey) ?? 0);
712
+ current.set(event.accountKey, fraction);
713
+ if (sum > peak)
714
+ peak = sum;
715
+ }
716
+ return peak;
717
+ }
718
+ /** The limit label, plus the window label when it adds information. */
719
+ function windowDisplayLabel(sample) {
720
+ if (!sample.windowLabel || sample.label.toLowerCase().includes(sample.windowLabel.toLowerCase()))
721
+ return sample.label;
722
+ return `${sample.label} · ${sample.windowLabel}`;
723
+ }
724
+ /** Quota snapshots from agent.db, oldest first. Windows group by provider and limit id. */
725
+ function readWindowSamples(cutoff) {
726
+ const db = agentDbPath();
727
+ if (!fs.existsSync(db) || tableColumns(db, 'usage_history').size === 0)
728
+ return [];
729
+ const out = readBlocks(db, [['windows', `SELECT ${txt('provider')}, ${txt('account_key')},
730
+ ${txt('email')}, ${txt('account_id')}, ${txt('limit_id')}, ${txt('label')}, ${txt('window_label')},
731
+ used_fraction, status, recorded_at
732
+ FROM usage_history ${rangeWhere(cutoff, 'recorded_at')} ORDER BY recorded_at`]]);
733
+ return (out.get('windows') ?? []).map((r) => {
734
+ const usedFraction = numOrNull(r[7]);
735
+ const status = r[8] || null;
736
+ return {
737
+ provider: r[0] ?? '',
738
+ accountKey: r[1] ?? '',
739
+ accountLabel: r[2] || r[3] || r[1] || '',
740
+ limitId: r[4] ?? '',
741
+ label: r[5] ?? '',
742
+ windowLabel: r[6] ?? '',
743
+ ts: num(r[9]),
744
+ usedFraction,
745
+ exhausted: status === 'exhausted' || (usedFraction !== null && usedFraction >= WINDOW_EXHAUSTED),
746
+ };
747
+ });
748
+ }
749
+ /** Window utilization; a window is one `(provider, limitId)` pair, since limits can share a duration. */
750
+ function computeWindows(samples, tokensByProvider) {
751
+ const groups = new Map();
752
+ for (const sample of samples) {
753
+ const key = `${sample.provider}\u0000${sample.limitId}`;
754
+ const held = groups.get(key);
755
+ if (held)
756
+ held.push(sample);
757
+ else
758
+ groups.set(key, [sample]);
759
+ }
760
+ const usageSeries = [];
761
+ const windowInsights = [];
762
+ for (const group of groups.values()) {
763
+ const first = group[0];
764
+ const windowLabel = windowDisplayLabel(group[group.length - 1]);
765
+ const accounts = new Map();
766
+ for (const sample of group) {
767
+ const held = accounts.get(sample.accountKey);
768
+ if (held)
769
+ held.push(sample);
770
+ else
771
+ accounts.set(sample.accountKey, [sample]);
772
+ }
773
+ let fractionConsumed = 0;
774
+ let cycles = 0;
775
+ let exhaustedEvents = 0;
776
+ for (const [accountKey, accountSamples] of accounts) {
777
+ usageSeries.push({
778
+ provider: first.provider,
779
+ accountKey,
780
+ accountLabel: accountSamples[accountSamples.length - 1].accountLabel,
781
+ windowKey: first.limitId,
782
+ windowLabel,
783
+ points: downsampleWindow(accountSamples.map((sample) => ({
784
+ ts: sample.ts,
785
+ usedFraction: round4(sample.usedFraction),
786
+ exhausted: sample.exhausted,
787
+ }))),
788
+ });
789
+ let previous = null;
790
+ let wasExhausted = false;
791
+ for (const sample of accountSamples) {
792
+ if (sample.exhausted && !wasExhausted)
793
+ exhaustedEvents += 1;
794
+ wasExhausted = sample.exhausted;
795
+ if (sample.usedFraction === null)
796
+ continue;
797
+ if (previous !== null) {
798
+ const delta = sample.usedFraction - previous;
799
+ if (delta > 0)
800
+ fractionConsumed += delta;
801
+ else if (delta < -WINDOW_RESET_DROP)
802
+ cycles += 1;
803
+ }
804
+ previous = sample.usedFraction;
805
+ }
806
+ }
807
+ const providerTokens = tokensByProvider.get(first.provider) ?? 0;
808
+ const peak = peakConcurrent(group);
809
+ windowInsights.push({
810
+ provider: first.provider,
811
+ windowKey: first.limitId,
812
+ windowLabel,
813
+ accounts: accounts.size,
814
+ cycles,
815
+ fractionConsumed,
816
+ estTokensPerWindow: providerTokens > 0 && fractionConsumed >= WINDOW_MIN_EXTRAPOLATION
817
+ ? Math.round(providerTokens / fractionConsumed)
818
+ : null,
819
+ peakConcurrentFraction: peak,
820
+ idealAccounts: Math.max(1, Math.ceil(peak / WINDOW_TARGET_PEAK)),
821
+ exhaustedEvents,
822
+ });
823
+ }
824
+ usageSeries.sort((a, b) => a.provider.localeCompare(b.provider) ||
825
+ a.windowKey.localeCompare(b.windowKey) ||
826
+ a.accountLabel.localeCompare(b.accountLabel));
827
+ windowInsights.sort((a, b) => a.provider.localeCompare(b.provider) || b.fractionConsumed - a.fractionConsumed);
828
+ return { usageSeries, windowInsights };
829
+ }
830
+ /** Tokens per provider over the range: the currency a window insight extrapolates in. */
831
+ function readProviderTokens(db, cutoff) {
832
+ const out = readBlocks(db, [['providerTokens', `SELECT ${COL.provider}, SUM(${COL.total})
833
+ FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1`]]);
834
+ return new Map((out.get('providerTokens') ?? []).map((r) => [r[0] ?? '', num(r[1])]));
835
+ }
836
+ /** Peak burn hours: one row per provider per local hour, the shape the reference charts. */
837
+ function readProviderHourly(db, cutoff) {
838
+ const out = readBlocks(db, [['providerHourly', `SELECT ${COL.provider},
839
+ CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
840
+ SUM(${COL.total}), SUM(${COL.output}), COUNT(*)
841
+ FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2`]]);
842
+ return (out.get('providerHourly') ?? []).map((r) => ({
843
+ provider: r[0] ?? '',
844
+ hour: num(r[1]),
845
+ totalTokens: num(r[2]),
846
+ outputTokens: num(r[3]),
847
+ requests: num(r[4]),
848
+ }));
849
+ }
850
+ /** Middle value, ignoring nulls. The Requests page summarises latency this way. */
851
+ function median(values) {
852
+ const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
853
+ if (nums.length === 0)
854
+ return 0;
855
+ const mid = nums.length >> 1;
856
+ return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
857
+ }
858
+ /** Requests summary; percentiles stay over the capped slice, so a p95 of 200 of 2,400 describes those 200. */
859
+ function computeRequestStats(rows, range) {
860
+ const durations = rows.map((row) => row.durationMs);
861
+ const sorted = durations.filter((v) => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
862
+ const pick = (q) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
863
+ const times = rows.map((row) => row.ts);
864
+ return {
865
+ requests: range ? range.requests : rows.length,
866
+ failed: range ? range.failed : rows.filter((row) => row.stopReason === 'error').length,
867
+ aborted: rows.filter((row) => row.stopReason === 'aborted').length,
868
+ tokens: range ? range.total : rows.reduce((sum, row) => sum + row.totalTokens, 0),
869
+ costUsd: range ? range.costUsd : rows.reduce((sum, row) => sum + row.costUsd, 0),
870
+ unpriced: range ? range.unpricedRequests : rows.filter((row) => row.unpriced).length,
871
+ shipped: rows.length,
872
+ medianDurationMs: median(durations),
873
+ p95DurationMs: pick(0.95),
874
+ medianTtftMs: median(rows.map((row) => row.ttftMs)),
875
+ oldest: range ? range.firstTs : times.length ? Math.min(...times) : null,
876
+ newest: range ? range.lastTs : times.length ? Math.max(...times) : null,
877
+ };
878
+ }
879
+ /** How many spans one trace ships. A long session holds thousands; the transcript virtualises. */
880
+ const TRACE_SPAN_LIMIT = 6000;
881
+ /** How much of a message body a span carries: enough for one transcript line. */
882
+ const TRACE_DETAIL_MAX = 120;
883
+ /** First non-empty line of a message body, for the span's detail line. */
884
+ function firstLine(parts) {
885
+ const text = parts
886
+ .map((part) => String(part.text ?? part.thinking ?? ''))
887
+ .map((value) => value.replace(/\s+/g, ' ').trim())
888
+ .find(Boolean);
889
+ return text ? text.slice(0, TRACE_DETAIL_MAX) : '';
890
+ }
891
+ /** How a request stopped, read from the assistant message's stop reason. */
892
+ function stopState(stopReason) {
893
+ if (stopReason === 'error')
894
+ return 'failed';
895
+ if (stopReason === 'aborted')
896
+ return 'aborted';
897
+ return 'completed';
898
+ }
899
+ /** Tool names that hand the work to another agent, for the Agents figure. */
900
+ const AGENT_TOOLS = new Set(['task', 'subagent']);
901
+ /** One transcript as spans, tracks and markers: a model span runs `ts - duration` to `ts`. */
902
+ export function readSessionTrace(sessionFile, limit = TRACE_SPAN_LIMIT) {
903
+ const project = sessionFile.split('/sessions/')[1]?.split('/')[0] ?? '';
904
+ const empty = {
905
+ sessionFile, project, title: '', cwd: project, startedAt: 0, endedAt: 0,
906
+ tracks: [], markers: [], summary: emptyTraceSummary(), toolStats: [], truncated: false,
907
+ };
908
+ if (!sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile))
909
+ return empty;
910
+ let text;
911
+ try {
912
+ text = fs.readFileSync(sessionFile, 'utf8');
913
+ }
914
+ catch {
915
+ return empty;
916
+ }
917
+ const raw = [];
918
+ const markers = [];
919
+ let title = '';
920
+ let truncated = false;
921
+ for (const line of text.split('\n')) {
922
+ if (!line.trim())
923
+ continue;
924
+ let parsed;
925
+ try {
926
+ parsed = JSON.parse(line);
927
+ }
928
+ catch {
929
+ continue;
930
+ }
931
+ const entryId = String(parsed.id ?? '');
932
+ // The line's stamp is when the entry landed; the message's own stamp, a number, is when it began.
933
+ const ts = Date.parse(String(parsed.timestamp ?? '')) || 0;
934
+ const own = parsed.message?.timestamp;
935
+ const startAt = typeof own === 'number' && Number.isFinite(own) ? own : null;
936
+ if (parsed.type !== 'message') {
937
+ const marker = markerFor(entryId, ts, parsed);
938
+ if (marker)
939
+ markers.push(marker);
940
+ if (parsed.type === 'title' && typeof parsed.title === 'string')
941
+ title = parsed.title;
942
+ continue;
943
+ }
944
+ const message = (parsed.message ?? {});
945
+ const content = Array.isArray(message.content) ? message.content : [];
946
+ const usage = (message.usage ?? {});
947
+ const cost = (usage.cost ?? {});
948
+ raw.push({
949
+ entryId,
950
+ ts,
951
+ role: String(message.role ?? ''),
952
+ tool: String(message.toolName ?? ''),
953
+ detail: firstLine(content),
954
+ model: String(message.model ?? ''),
955
+ provider: String(message.provider ?? ''),
956
+ tokens: num(String(usage.totalTokens ?? 0)),
957
+ costUsd: num(String(cost.total ?? 0)),
958
+ durationMs: typeof message.duration === 'number' ? message.duration : null,
959
+ startAt,
960
+ isError: message.isError === true,
961
+ stopped: stopState(String(message.stopReason ?? '')),
962
+ callId: String(message.toolCallId ?? ''),
963
+ calls: content
964
+ .filter((part) => part.type === 'toolCall')
965
+ .map((part) => ({ id: String(part.id ?? ''), name: String(part.name ?? 'tool') })),
966
+ });
967
+ }
968
+ // A tool result closes the call it names, so the call's span runs to it.
969
+ const resultAt = new Map();
970
+ const callIds = new Set();
971
+ for (const row of raw) {
972
+ for (const call of row.calls)
973
+ if (call.id)
974
+ callIds.add(call.id);
975
+ if (row.role === 'toolResult' && row.callId && !resultAt.has(row.callId))
976
+ resultAt.set(row.callId, row);
977
+ }
978
+ const built = [];
979
+ let turnOpen = null;
980
+ for (const row of raw) {
981
+ const base = {
982
+ model: row.model, provider: row.provider, tokens: row.tokens, costUsd: row.costUsd,
983
+ isError: row.isError, entryId: row.entryId,
984
+ };
985
+ if (row.role === 'assistant') {
986
+ // A model call runs from its own start stamp to the line's stamp; the duration is the cross-check.
987
+ const start = row.startAt !== null && row.startAt < row.ts ? row.startAt : row.durationMs && row.durationMs > 0 ? row.ts - row.durationMs : row.ts;
988
+ if (turnOpen)
989
+ turnOpen.end = start;
990
+ turnOpen = null;
991
+ built.push({ ...base, id: row.entryId || `model-${built.length}`, kind: 'model', label: row.model || 'assistant',
992
+ detail: row.detail, start, end: row.ts, tool: '', stopped: row.stopped });
993
+ row.calls.forEach((call, index) => {
994
+ const result = call.id ? resultAt.get(call.id) : undefined;
995
+ const end = result ? Math.max(result.ts, row.ts) : row.ts;
996
+ built.push({ ...base, id: `${row.entryId}#${index}`, kind: 'tool', label: call.name, detail: result?.detail ?? '',
997
+ start: row.ts, end, tool: call.name, isError: result ? result.isError : base.isError, stopped: 'completed' });
998
+ });
999
+ continue;
1000
+ }
1001
+ if (row.role === 'toolResult') {
1002
+ // A result whose call was compacted away still ran: draw it as a point.
1003
+ if (row.callId && callIds.has(row.callId))
1004
+ continue;
1005
+ built.push({ ...base, id: row.entryId || `tool-${built.length}`, kind: 'tool', label: row.tool || 'tool',
1006
+ detail: row.detail, start: row.ts, end: row.ts, tool: row.tool, stopped: 'completed' });
1007
+ continue;
1008
+ }
1009
+ if (row.role === 'user') {
1010
+ if (turnOpen)
1011
+ turnOpen.end = row.ts;
1012
+ turnOpen = { ...base, id: `turn-${built.length}`, kind: 'turn', label: 'Prompt', detail: row.detail,
1013
+ start: row.ts, end: row.ts, tool: '', stopped: 'completed' };
1014
+ built.push(turnOpen);
1015
+ continue;
1016
+ }
1017
+ built.push({ ...base, id: row.entryId || `bg-${built.length}`, kind: 'background', label: row.role || 'system',
1018
+ detail: row.detail, start: row.ts, end: row.ts, tool: '', stopped: 'completed' });
1019
+ }
1020
+ const lastEnd = built.reduce((max, span) => Math.max(max, span.end), 0);
1021
+ if (turnOpen)
1022
+ turnOpen.end = Math.max(turnOpen.end, lastEnd);
1023
+ // A session longer than the cap keeps its newest spans: the opening minutes are the least useful.
1024
+ const spans = built.length > limit ? built.slice(-limit) : built;
1025
+ if (built.length > limit)
1026
+ truncated = true;
1027
+ spans.sort((a, b) => a.start - b.start);
1028
+ const startedAt = spans.length ? Math.min(...spans.map((span) => span.start)) : 0;
1029
+ const endedAt = spans.length ? Math.max(...spans.map((span) => span.end)) : 0;
1030
+ return {
1031
+ sessionFile, project, title, cwd: project, startedAt, endedAt,
1032
+ tracks: [{ id: 'main', label: 'Session', parentId: null, spans }],
1033
+ markers: markers.toSorted((a, b) => a.ts - b.ts),
1034
+ summary: summarizeTrace(spans, startedAt, endedAt),
1035
+ toolStats: buildToolStats(spans),
1036
+ truncated,
1037
+ };
1038
+ }
1039
+ /** A non-message journal entry, when it marks a change worth a diamond on the timeline. */
1040
+ function markerFor(entryId, ts, parsed) {
1041
+ const kind = String(parsed.type ?? '');
1042
+ if (kind === 'model_change')
1043
+ return { id: entryId || `model-${ts}`, ts, kind: 'model', label: String(parsed.model ?? 'model') };
1044
+ if (kind === 'thinking_level_change') {
1045
+ return { id: entryId || `think-${ts}`, ts, kind: 'think', label: String(parsed.level ?? 'thinking') };
1046
+ }
1047
+ if (kind === 'session')
1048
+ return { id: entryId || `session-${ts}`, ts, kind: 'session', label: 'session started' };
1049
+ // Only the changes ride along: a session fires hundreds of events that would bury the diamonds.
1050
+ return null;
1051
+ }
1052
+ /** The empty summary, for a file that no longer reads. */
1053
+ function emptyTraceSummary() {
1054
+ return { wallMs: 0, modelMs: 0, toolMs: 0, idleMs: 0, turns: 0, requests: 0, toolCalls: 0, agents: 0, totalTokens: 0, costUsd: 0 };
1055
+ }
1056
+ /** The nine figures: wall time split into model, tool, and idle, then the work counts. */
1057
+ function summarizeTrace(spans, startedAt, endedAt) {
1058
+ const wallMs = endedAt > startedAt ? endedAt - startedAt : 0;
1059
+ let modelMs = 0;
1060
+ let toolMs = 0;
1061
+ let totalTokens = 0;
1062
+ let costUsd = 0;
1063
+ for (const span of spans) {
1064
+ const held = Math.max(0, span.end - span.start);
1065
+ if (span.kind === 'model')
1066
+ modelMs += held;
1067
+ if (span.kind === 'tool')
1068
+ toolMs += held;
1069
+ if (span.kind === 'model')
1070
+ totalTokens += span.tokens;
1071
+ if (span.kind === 'model')
1072
+ costUsd += span.costUsd;
1073
+ }
1074
+ return {
1075
+ wallMs,
1076
+ modelMs,
1077
+ toolMs,
1078
+ // Idle is what no span covered: wall time minus the time a model or a tool held.
1079
+ idleMs: Math.max(0, wallMs - modelMs - toolMs),
1080
+ turns: spans.filter((span) => span.kind === 'turn').length,
1081
+ requests: spans.filter((span) => span.kind === 'model').length,
1082
+ toolCalls: spans.filter((span) => span.kind === 'tool').length,
1083
+ agents: spans.filter((span) => span.kind === 'tool' && AGENT_TOOLS.has(span.tool)).length,
1084
+ totalTokens,
1085
+ costUsd,
1086
+ };
1087
+ }
1088
+ /** Per-tool duration totals, longest first. */
1089
+ function buildToolStats(spans) {
1090
+ const byTool = new Map();
1091
+ for (const span of spans) {
1092
+ if (span.kind !== 'tool')
1093
+ continue;
1094
+ const held = Math.max(0, span.end - span.start);
1095
+ const list = byTool.get(span.tool || 'tool') ?? [];
1096
+ list.push(held);
1097
+ byTool.set(span.tool || 'tool', list);
1098
+ }
1099
+ return [...byTool.entries()]
1100
+ .map(([tool, held]) => {
1101
+ const sorted = held.toSorted((a, b) => a - b);
1102
+ const totalMs = sorted.reduce((sum, value) => sum + value, 0);
1103
+ return {
1104
+ tool,
1105
+ calls: held.length,
1106
+ errors: spans.filter((span) => span.kind === 'tool' && (span.tool || 'tool') === tool && span.isError).length,
1107
+ totalMs,
1108
+ avgMs: totalMs / Math.max(1, sorted.length),
1109
+ maxMs: sorted[held.length - 1] ?? 0,
1110
+ };
1111
+ })
1112
+ .toSorted((a, b) => b.totalMs - a.totalMs);
1113
+ }
1114
+ /** The payload a request with no readable entry returns, so the route always answers with one shape. */
1115
+ export function emptyRequestEntry() {
1116
+ return { entry: null, output: null, messageRole: '', agent: { model: null, thinkingLevel: null, mode: null, fallback: null } };
1117
+ }
1118
+ /** One request's journal payload; empty when the entry journals nothing or its session is gone. */
1119
+ export function readAgentRequestEntry(sessionFile, entryId) {
1120
+ const empty = emptyRequestEntry();
1121
+ if (!entryId || !sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile))
1122
+ return empty;
1123
+ let text;
1124
+ try {
1125
+ text = fs.readFileSync(sessionFile, 'utf8');
1126
+ }
1127
+ catch {
1128
+ return empty;
1129
+ }
1130
+ const agent = { ...empty.agent };
1131
+ for (const line of text.split('\n')) {
1132
+ if (!line.trim())
1133
+ continue;
1134
+ let parsed;
1135
+ try {
1136
+ parsed = JSON.parse(line);
1137
+ }
1138
+ catch {
1139
+ continue;
1140
+ }
1141
+ if (String(parsed.id ?? '') !== entryId) {
1142
+ // A change entry updates the state every later request sees.
1143
+ if (parsed.type === 'model_change') {
1144
+ agent.model = String(parsed.model ?? '') || null;
1145
+ agent.fallback = parsed.resolvedModelIsFallback === true;
1146
+ }
1147
+ else if (parsed.type === 'thinking_level_change') {
1148
+ agent.thinkingLevel = String(parsed.thinkingLevel ?? '') || null;
1149
+ }
1150
+ else if (parsed.type === 'mode_change') {
1151
+ agent.mode = String(parsed.mode ?? '') || null;
1152
+ }
1153
+ continue;
1154
+ }
1155
+ const message = (parsed.message ?? null);
1156
+ return { entry: parsed, output: message, messageRole: message ? String(message.role ?? '') : '', agent };
1157
+ }
1158
+ return empty;
1159
+ }
1160
+ /** Reads the usage store; the caller runs `syncUsageDb`, so a poll never pays for a sync it did not start. */
1161
+ export function readAgentStats(range = '24h', view = 'all', now = Date.now(), offset = 0) {
1162
+ const span = RANGE_MS[range];
1163
+ const cutoff = span > 0 ? now - span : 0;
1164
+ const parts = new Set(VIEW_PARTS[view]);
1165
+ const stats = emptyAgentStats(range);
1166
+ stats.cutoff = cutoff;
1167
+ const db = statsDbPath();
1168
+ const cols = fs.existsSync(db) ? tableColumns(db, 'messages') : new Set();
1169
+ if (cols.size === 0) {
1170
+ if (parts.has('windows'))
1171
+ Object.assign(stats, computeWindows(readWindowSamples(cutoff), new Map()));
1172
+ return stats;
1173
+ }
1174
+ // The bucket width depends on the span, so the bounds come first.
1175
+ const bounds = readBlocks(db, [['bounds', `SELECT MIN(${COL.timestamp}), MAX(${COL.timestamp}) FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)}`]]);
1176
+ const [firstTs, lastTs] = bounds.get('bounds')?.[0] ?? [];
1177
+ stats.bucketMs = bucketSize(range, numOrNull(firstTs), numOrNull(lastTs));
1178
+ Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, parts, offset));
1179
+ if (parts.has('seriesByProvider'))
1180
+ stats.seriesByProvider = readProviderSeries(db, cutoff, stats.bucketMs);
1181
+ if (parts.has('modelSeries'))
1182
+ stats.modelSeries = readModelSeries(db, cutoff, stats.bucketMs);
1183
+ if (parts.has('modelPerformance'))
1184
+ stats.modelPerformance = readModelPerformance(db, cutoff, stats.bucketMs);
1185
+ if (parts.has('tools'))
1186
+ Object.assign(stats, readToolSeries(db, cutoff, stats.bucketMs));
1187
+ if (stats.byModel.length > 0)
1188
+ stats.topModels = stats.byModel.slice(0, 12);
1189
+ if (parts.has('providerHourly'))
1190
+ stats.providerHourly = readProviderHourly(db, cutoff);
1191
+ if (parts.has('recent'))
1192
+ stats.requestStats = computeRequestStats(stats.recent, stats.overall);
1193
+ if (parts.has('windows')) {
1194
+ Object.assign(stats, computeWindows(readWindowSamples(cutoff), readProviderTokens(db, cutoff)));
1195
+ }
1196
+ stats.available = true;
1197
+ return stats;
1198
+ }