@krtclcdy/tersio 2.25.5 → 2.25.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +17 -36
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/cli/backups.js +7 -14
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +28 -12
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +66 -35
  8. package/dist/cli/doctor-fix.js +204 -19
  9. package/dist/cli/doctor.js +226 -39
  10. package/dist/cli/hosts.js +25 -5
  11. package/dist/cli/install.js +144 -48
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +87 -7
  14. package/dist/cli/profile.js +13 -8
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +3 -3
  18. package/dist/cli/update.js +6 -7
  19. package/dist/cli/usage.js +6 -9
  20. package/dist/extensions/caveman/index.js +4 -2
  21. package/dist/extensions/caveman/rule-megacave.md +5 -3
  22. package/dist/extensions/caveman/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +55 -45
  24. package/dist/extensions/combo/index.js +62 -21
  25. package/dist/extensions/commands/index.js +1 -1
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/rtk/filter.js +0 -1
  29. package/dist/extensions/rtk/session.js +4 -4
  30. package/dist/extensions/server.js +90 -6
  31. package/dist/extensions/shared/agent-stats.js +214 -278
  32. package/dist/extensions/shared/brand-marks.js +33 -34
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +11 -6
  35. package/dist/extensions/shared/pricing.js +8 -16
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +53 -41
  42. package/dist/extensions/shared/usage-store.js +111 -52
  43. package/dist/extensions/shared/utils.js +5 -5
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/updater/index.js +3 -6
  46. package/dist/tersio.js +8 -3
  47. package/extensions/caveman/index.ts +4 -2
  48. package/extensions/caveman/rule-megacave.md +5 -3
  49. package/extensions/caveman/rule-ultra.md +4 -4
  50. package/extensions/caveman/rule.md +55 -45
  51. package/extensions/combo/index.ts +62 -19
  52. package/extensions/commands/index.ts +1 -1
  53. package/extensions/pstack/index.ts +128 -0
  54. package/extensions/pstack/rule.md +65 -0
  55. package/extensions/rtk/filter.ts +0 -1
  56. package/extensions/rtk/session.ts +4 -4
  57. package/extensions/server.ts +72 -7
  58. package/extensions/shared/agent-stats.ts +226 -278
  59. package/extensions/shared/brand-marks.ts +34 -34
  60. package/extensions/shared/carbon.ts +1 -1
  61. package/extensions/shared/plugin-settings.ts +12 -6
  62. package/extensions/shared/pricing.ts +10 -26
  63. package/extensions/shared/rtk-gain.ts +4 -9
  64. package/extensions/shared/session-state.ts +18 -13
  65. package/extensions/shared/sqlite.ts +168 -0
  66. package/extensions/shared/status.ts +9 -8
  67. package/extensions/shared/types.ts +2 -1
  68. package/extensions/shared/usage-ledger.ts +60 -47
  69. package/extensions/shared/usage-store.ts +120 -52
  70. package/extensions/shared/utils.ts +5 -5
  71. package/extensions/timing/index.ts +45 -0
  72. package/extensions/updater/index.ts +3 -8
  73. package/package.json +17 -8
@@ -1,12 +1,9 @@
1
- // extensions/shared/agent-stats.ts: read-only aggregate over the agent statistics databases.
2
- // Today that is omp's stats.db and agent.db, so the naming stays host-neutral. Every metric
3
- // matches @oh-my-pi/omp-stats: same SQL facts, same rules. Missing sqlite3 or a
4
- // missing table degrades to zeros, never to a thrown error.
5
- import { execFileSync } from 'node:child_process';
1
+ // Read-only aggregate over the usage store; missing SQLite, table, or metric reads as zeros.
6
2
  import fs from 'node:fs';
7
3
  import os from 'node:os';
8
4
  import path from 'node:path';
9
- import { SQLITE_READ_BUFFER } from './usage-ledger.js';
5
+ import { sqliteRead } from './sqlite.js';
6
+ import { usageDbPath } from './usage-store.js';
10
7
  export const RANGES = ['1h', '24h', '7d', '30d', '90d', 'all'];
11
8
  export function isRangeKey(v) {
12
9
  return typeof v === 'string' && RANGES.includes(v);
@@ -30,7 +27,7 @@ const VIEW_PARTS = {
30
27
  costs: ['overall', 'byModel', 'series', 'modelSeries'],
31
28
  // The Carbon page needs the same two facts as Costs: per-model output tokens and the mix.
32
29
  carbon: ['overall', 'byModel'],
33
- requests: ['recent'],
30
+ requests: ['overall', 'recent'],
34
31
  errors: ['errors', 'overall', 'byModel', 'recent'],
35
32
  traces: ['traces'],
36
33
  tools: ['overall', 'tools'],
@@ -62,12 +59,83 @@ const TRACE_LIMIT = 300;
62
59
  const TOOL_MODEL_LIMIT = 300;
63
60
  const SERIES_PROVIDERS = 8;
64
61
  const SERIES_MODELS = 12;
62
+ /** The store every metric but subscription quota reads, kept in step by `tersio usage`. */
65
63
  export function statsDbPath() {
66
- return process.env.TERSIO_AGENT_STATS_DB ?? path.join(os.homedir(), '.omp', 'stats.db');
64
+ return process.env.TERSIO_AGENT_STATS_DB ?? usageDbPath();
67
65
  }
66
+ /** Quota snapshots. Only omp records these, so this stays omp's own database. */
68
67
  export function agentDbPath() {
69
68
  return process.env.TERSIO_AGENT_SESSIONS_DB ?? path.join(os.homedir(), '.omp', 'agent', 'agent.db');
70
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
+ };
71
139
  /** Output cost is the remainder: omp prices the whole request and the three cache-side parts. */
72
140
  function costMixOf(f) {
73
141
  const input = f.costInput;
@@ -102,95 +170,27 @@ export function emptyAgentStats(range = '24h') {
102
170
  tools: [], toolsByModel: [], toolSeries: [],
103
171
  usageSeries: [], windowInsights: [], providerHourly: [],
104
172
  requestStats: {
105
- requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0,
173
+ requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0, shipped: 0,
106
174
  medianDurationMs: 0, p95DurationMs: 0, medianTtftMs: 0, oldest: null, newest: null,
107
175
  },
108
176
  errors: [],
109
177
  };
110
178
  }
111
- // ---------------------------------------------------------------- sqlite access
112
- /**
113
- * The omp databases run in WAL mode. Ordered fallbacks, each verified against the live pair:
114
- * `-readonly` needs the `-shm` side file, `immutable=1` silently ignores a WAL, and a copy of
115
- * db + wal + shm replays the log to the same row count the running server reports.
116
- */
179
+ /** Rows from a read-only script, tab-separated: whichever engine this host has answers it. */
117
180
  export function sqliteScript(db, script) {
118
- if (!fs.existsSync(db))
119
- throw new Error(`missing database: ${db}`);
120
- const opts = {
121
- encoding: 'utf8',
122
- timeout: 30_000,
123
- stdio: ['pipe', 'pipe', 'ignore'],
124
- maxBuffer: SQLITE_READ_BUFFER,
125
- input: script,
126
- };
127
- try {
128
- return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
129
- }
130
- catch { /* the side files may be gone; try the next rung */ }
131
- if (!fs.existsSync(`${db}-wal`)) {
132
- try {
133
- return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
134
- }
135
- catch { /* fall through to the copy */ }
136
- }
137
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-omp-'));
138
- const target = path.join(dir, path.basename(db));
139
- try {
140
- fs.copyFileSync(db, target);
141
- for (const suffix of ['-wal', '-shm']) {
142
- if (fs.existsSync(`${db}${suffix}`))
143
- fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
144
- }
145
- return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
146
- }
147
- finally {
148
- fs.rmSync(dir, { recursive: true, force: true });
149
- }
181
+ return sqliteRead(db, script);
150
182
  }
151
- const BLOCK = '@@blk@@';
152
- function blocks(out) {
183
+ /** One call per query: an unavailable table leaves its key absent, so a partial schema still reads. */
184
+ function readBlocks(db, queries) {
153
185
  const map = new Map();
154
- let current = null;
155
- for (const line of out.split('\n')) {
156
- if (!line)
157
- continue;
158
- if (line.startsWith(BLOCK)) {
159
- current = [];
160
- map.set(line.slice(BLOCK.length), current);
161
- continue;
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')));
162
189
  }
163
- if (current)
164
- current.push(line.split('\t'));
190
+ catch { /* an unavailable table leaves its key absent */ }
165
191
  }
166
192
  return map;
167
193
  }
168
- /**
169
- * One sqlite3 call for a group of queries. A failing statement aborts the script, so the
170
- * partial output is kept and only the missing blocks re-run one at a time.
171
- */
172
- function readBlocks(db, queries) {
173
- const script = queries.map(([name, sql]) => `SELECT '${BLOCK}${name}';\n${sql};\n`).join('');
174
- try {
175
- return blocks(sqliteScript(db, script));
176
- }
177
- catch (error) {
178
- // A failing statement aborts the script, so its stdout holds every block that ran.
179
- if (!error || typeof error !== 'object' || !('stdout' in error) || typeof error.stdout !== 'string')
180
- throw error;
181
- const merged = blocks(error.stdout);
182
- for (const [name, sql] of queries) {
183
- if (merged.has(name))
184
- continue;
185
- try {
186
- for (const [k, rows] of blocks(sqliteScript(db, `SELECT '${BLOCK}${name}';\n${sql};\n`)))
187
- merged.set(k, rows);
188
- }
189
- catch { /* an unavailable table leaves its key absent */ }
190
- }
191
- return merged;
192
- }
193
- }
194
194
  function tableColumns(db, table) {
195
195
  try {
196
196
  const out = sqliteScript(db, `SELECT name FROM pragma_table_info('${table}');\n`);
@@ -214,60 +214,43 @@ function numOrNull(v) {
214
214
  function txt(column) {
215
215
  return `replace(replace(replace(COALESCE(${column}, ''), char(10), ' '), char(13), ' '), char(9), ' ')`;
216
216
  }
217
- // ------------------------------------------------------------------- sql builders
218
- /** The unpriced rule, copied from the reference: a subscription route with no price is not "free". */
219
- function unpricedSql(p = '') {
220
- return `CASE WHEN ${p}total_tokens > 0 AND ${p}cost_total = 0
221
- AND (${p}provider = 'xai-oauth' OR ${p}cost_unpriced = 1) THEN 1 ELSE 0 END`;
217
+ /** The unpriced rule, kept for callers that group by it: no route here is a subscription route. */
218
+ function unpricedSql() {
219
+ return '0';
222
220
  }
223
221
  /** The additive facts every messages group reads. */
224
- function messageFacts(p = '') {
222
+ function messageFacts() {
225
223
  return [
226
224
  'COUNT(*)',
227
- `SUM(CASE WHEN ${p}stop_reason = 'error' THEN 1 ELSE 0 END)`,
228
- `SUM(${p}input_tokens)`,
229
- `SUM(${p}output_tokens)`,
230
- `SUM(${p}cache_read_tokens)`,
231
- `SUM(${p}cache_write_tokens)`,
232
- `SUM(${p}total_tokens)`,
233
- `TOTAL(${p}cost_total)`,
234
- `TOTAL(${p}cost_input)`,
235
- `TOTAL(${p}cost_cache_read)`,
236
- `TOTAL(${p}cost_cache_write)`,
237
- `SUM(${unpricedSql(p)})`,
238
- `TOTAL(${p}duration)`,
239
- `COUNT(${p}duration)`,
240
- `TOTAL(${p}ttft)`,
241
- `COUNT(${p}ttft)`,
242
- `TOTAL(CASE WHEN ${p}duration > 0 THEN ${p}output_tokens * 1000.0 / ${p}duration END)`,
243
- `COUNT(CASE WHEN ${p}duration > 0 THEN 1 END)`,
244
- `MIN(${p}timestamp)`,
245
- `MAX(${p}timestamp)`,
246
- `MAX(${p}duration)`,
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})`,
247
245
  ].join(', ');
248
246
  }
249
- /** The zero and null literals an older omp schema needs in place of a named column. */
250
- const SHIMS = [
251
- ['stop_reason', "''"],
252
- ['cost_no_cache_input', '0'],
253
- ['cost_input', '0'],
254
- ['cost_cache_read', '0'],
255
- ['cost_cache_write', '0'],
256
- ['cost_unpriced', '0'],
257
- ['provider', "''"],
258
- ['folder', "''"],
259
- ['agent_type', "''"],
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,
260
253
  ];
261
- /** Applies the shims: a column the table does not hold reads as its literal. */
262
- function withShims(sql, cols) {
263
- let out = sql;
264
- for (const [column, literal] of SHIMS) {
265
- if (cols.has(column))
266
- continue;
267
- out = out.split(column).join(literal);
268
- }
269
- return out;
270
- }
271
254
  function parseFacts(row, at) {
272
255
  const g = (i) => num(row?.[at + i]);
273
256
  return {
@@ -332,7 +315,6 @@ function rangeWhere(cutoff, column = 'timestamp') {
332
315
  function round4(v) {
333
316
  return v === null ? null : Math.round(v * 1e4) / 1e4;
334
317
  }
335
- // ------------------------------------------------------------------- the aggregate
336
318
  function bucketSize(range, firstTs, lastTs) {
337
319
  const base = BUCKET_MS[range];
338
320
  if (firstTs === null || lastTs === null)
@@ -342,38 +324,30 @@ function bucketSize(range, firstTs, lastTs) {
342
324
  return base;
343
325
  return Math.max(base, Math.ceil(span / MAX_BUCKETS / base) * base);
344
326
  }
345
- // ------------------------------------------------------------------- the aggregate
346
- function readStatsDb(db, cutoff, bucketMs, cols, parts) {
347
- const where = rangeWhere(cutoff);
348
- const errCol = cols.has('error_message') ? txt('error_message') : "''";
349
- const apiCol = cols.has('api') ? txt('api') : "''";
350
- const errorWhere = `WHERE stop_reason = 'error'${cutoff > 0 ? ` AND timestamp >= ${Math.floor(cutoff)}` : ''}`;
351
- const requestColumns = `timestamp, ${txt('provider')}, ${txt('model')}, ${txt('folder')}, ${apiCol},
352
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
353
- cost_total, ${unpricedSql()}, duration, ttft, ${txt('stop_reason')}, ${errCol},
354
- ${txt('agent_type')}, ${txt('session_file')}, ${txt('entry_id')}`;
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;
355
331
  const out = readBlocks(db, [
356
- ['overall', `SELECT ${messageFacts()} FROM messages ${where}`],
357
- ['byModel', `SELECT ${txt('model')}, ${txt('provider')}, ${messageFacts()} FROM messages ${where} GROUP BY model, provider ORDER BY 3 DESC`],
358
- ['byProvider', `SELECT ${txt('provider')}, COUNT(DISTINCT model), ${messageFacts()} FROM messages ${where} GROUP BY provider ORDER BY 3 DESC`],
359
- ['byAgentType', `SELECT ${txt('agent_type')}, ${messageFacts()} FROM messages ${where} GROUP BY 1 ORDER BY 2 DESC`],
360
- ['byProject', `SELECT ${txt('folder')}, ${messageFacts()} FROM messages ${where} GROUP BY 1 ORDER BY 2 DESC`],
361
- ['series', `SELECT (timestamp / ${bucketMs}) * ${bucketMs}, COUNT(*),
362
- SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END),
363
- SUM(total_tokens), TOTAL(cost_total) FROM messages ${where} GROUP BY 1 ORDER BY 1`],
364
- ['hourOfDay', `SELECT CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER),
365
- COUNT(*), SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END), SUM(total_tokens), TOTAL(cost_total)
366
- FROM messages ${where} GROUP BY 1 ORDER BY 1`],
367
- ['recent', `SELECT ${requestColumns} FROM messages ${where} ORDER BY timestamp DESC LIMIT ${RECENT_LIMIT}`],
368
- ['errors', `SELECT ${requestColumns} FROM messages ${errorWhere} ORDER BY timestamp DESC LIMIT ${ERROR_LIMIT}`],
369
- ['traces', `SELECT ${txt('session_file')}, MAX(${txt('folder')}),
370
- COUNT(*), SUM(total_tokens), TOTAL(cost_total), SUM(${unpricedSql()}),
371
- MIN(timestamp), MAX(timestamp + COALESCE(duration, 0))
372
- FROM messages ${where} GROUP BY session_file ORDER BY 8 DESC LIMIT ${TRACE_LIMIT}`],
373
- ].filter(([name]) => parts.has(name)).map(([name, sql]) => [name, withShims(sql, cols)]));
374
- // The trace rows are read per file, then folded per root. The model list and the tool counts are
375
- // read across every transcript and keyed by root, so a child that fell outside the window still
376
- // folds into the session it belongs to.
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.
377
351
  const traceModels = readTraceModels(db);
378
352
  const traceTools = readTraceToolCounts(db);
379
353
  const traces = foldTraces((out.get('traces') ?? []).map((r) => {
@@ -439,13 +413,7 @@ function readStatsDb(db, cutoff, bucketMs, cols, parts) {
439
413
  traces: traces,
440
414
  };
441
415
  }
442
- /**
443
- * Fold every child transcript into its root session, the way the reference lists them.
444
- *
445
- * A subagent writes its own file under its root's folder, so a row grouped by file alone would
446
- * show one row per subagent. Each root holds its children's requests, tokens, cost, models, and
447
- * time, counts the children folded in, and keeps the row even when its own file is gone.
448
- */
416
+ /** Fold every child transcript into its root session, or a row per file would show one row per subagent. */
449
417
  function foldTraces(rows) {
450
418
  const byRoot = new Map();
451
419
  for (const row of rows) {
@@ -528,9 +496,9 @@ function groupErrors(rows) {
528
496
  .toSorted((a, b) => b.count - a.count || b.lastSeen - a.lastSeen);
529
497
  }
530
498
  function readProviderSeries(db, cutoff, bucketMs) {
531
- const out = readBlocks(db, [['providerSeries', `SELECT ${txt('provider')}, (timestamp / ${bucketMs}) * ${bucketMs},
532
- COUNT(*), SUM(total_tokens), TOTAL(cost_total)
533
- FROM messages ${rangeWhere(cutoff)} GROUP BY 1, 2 ORDER BY 1, 2`]]);
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`]]);
534
502
  const perProvider = new Map();
535
503
  for (const r of out.get('providerSeries') ?? []) {
536
504
  const provider = r[0] ?? '';
@@ -545,49 +513,44 @@ function readProviderSeries(db, cutoff, bucketMs) {
545
513
  .slice(0, SERIES_PROVIDERS)
546
514
  .map(([provider, held]) => ({ provider, points: held.points }));
547
515
  }
516
+ /** Tool calls from each message's names array: a name is one whole call, so per-call sizes read as zero. */
548
517
  function readToolSeries(db, cutoff, bucketMs) {
549
- const cols = tableColumns(db, 'tool_calls');
550
- if (!cols.has('tool_name'))
551
- return {};
552
- const join = 'LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id';
553
- const where = `WHERE t.timestamp >= ${Math.floor(cutoff)}`;
554
- const facts = (bucket, extra) => `
555
- ${bucket},
556
- ${txt('t.tool_name')}, ${txt('t.model')}, ${txt('t.provider')},
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},
557
524
  COUNT(*),
558
- SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END),
559
- SUM(t.args_chars),
560
- SUM(COALESCE(t.result_chars, 0)),
561
- TOTAL(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn),
562
- TOTAL(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn),
563
- TOTAL(COALESCE(m.cost_total, 0) / t.calls_in_turn),
564
- TOTAL(CASE WHEN COALESCE(m.total_tokens, 0) > 0 AND COALESCE(m.cost_total, 0) = 0
565
- AND (m.provider = 'xai-oauth' OR m.cost_unpriced = 1) THEN 1 ELSE 0 END * 1.0 / t.calls_in_turn),
566
- MAX(t.timestamp)${extra}`;
525
+ TOTAL(${COL.total}),
526
+ TOTAL(${COL.output}),
527
+ TOTAL(${COL.costTotal}),
528
+ MAX(${COL.timestamp})`;
567
529
  const out = readBlocks(db, [
568
- ['byTool', `SELECT ${facts("'-'", '')} FROM tool_calls t ${join} ${where} GROUP BY t.tool_name ORDER BY 5 DESC`],
569
- ['byToolModel', `SELECT ${facts("'-'", ', t.model, t.provider')} FROM tool_calls t ${join} ${where}
570
- GROUP BY t.tool_name, t.model, t.provider ORDER BY 5 DESC LIMIT ${TOOL_MODEL_LIMIT}`],
571
- ['toolSeries', `SELECT (t.timestamp / ${bucketMs}) * ${bucketMs}, ${txt('t.tool_name')}, COUNT(*),
572
- SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) FROM tool_calls t ${where} GROUP BY 1, 2 ORDER BY 1`],
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`],
573
536
  ]);
574
- const parseTool = (r, at) => ({
537
+ const parseTool = (r) => ({
575
538
  tool: r[1] ?? '',
576
539
  calls: num(r[4]),
577
- errors: num(r[5]),
578
- argsChars: num(r[6]),
579
- resultChars: num(r[7]),
580
- totalTokensShare: num(r[8]),
581
- outputTokensShare: num(r[9]),
582
- costShare: num(r[10]),
583
- unpricedShare: num(r[11]),
584
- lastUsed: numOrNull(r[12]),
585
- model: r[at] ?? '',
586
- provider: r[at + 1] ?? '',
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] ?? '',
587
550
  });
588
551
  return {
589
- tools: (out.get('byTool') ?? []).map((r) => parseTool(r, 13)),
590
- toolsByModel: (out.get('byToolModel') ?? []).map((r) => parseTool(r, 13)),
552
+ tools: (out.get('byTool') ?? []).map(parseTool),
553
+ toolsByModel: (out.get('byToolModel') ?? []).map(parseTool),
591
554
  toolSeries: (out.get('toolSeries') ?? []).map((r) => ({
592
555
  ts: num(r[0]), tool: r[1] ?? '', calls: num(r[2]), errors: num(r[3]),
593
556
  })),
@@ -597,7 +560,8 @@ function readToolSeries(db, cutoff, bucketMs) {
597
560
  function readTraceToolCounts(db) {
598
561
  const counts = new Map();
599
562
  try {
600
- const out = sqliteScript(db, 'SELECT session_file, COUNT(*) FROM tool_calls GROUP BY 1;\n');
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`);
601
565
  for (const line of out.split('\n')) {
602
566
  if (!line.trim())
603
567
  continue;
@@ -615,7 +579,7 @@ function readTraceToolCounts(db) {
615
579
  function readTraceModels(db) {
616
580
  const models = new Map();
617
581
  try {
618
- const out = sqliteScript(db, 'SELECT session_file, model FROM messages GROUP BY session_file, model;\n');
582
+ const out = sqliteScript(db, `SELECT ${COL.session}, ${COL.model} FROM ${SOURCE} GROUP BY 1, 2;\n`);
619
583
  for (const line of out.split('\n')) {
620
584
  if (!line.trim())
621
585
  continue;
@@ -630,12 +594,7 @@ function readTraceModels(db) {
630
594
  catch { /* an unreadable table leaves the list empty */ }
631
595
  return new Map([...models.entries()].map(([file, set]) => [file, [...set].toSorted()]));
632
596
  }
633
- /**
634
- * Each root session's own title and working directory, read from the head of its transcript.
635
- *
636
- * The `session` line carries the working directory and the `title` line sits above it, so one head
637
- * read answers both. A rotated or unreadable file leaves the row's folder and file name to speak.
638
- */
597
+ /** A session's title and cwd: the `session` line carries the directory and the `title` line sits above it. */
639
598
  function applySessionMetadata(traces) {
640
599
  for (const trace of traces) {
641
600
  if (!trace.sessionFile.endsWith('.jsonl'))
@@ -671,11 +630,11 @@ function applySessionMetadata(traces) {
671
630
  }
672
631
  /** Daily tokens and cost for the busiest models: the Models table sparkline. */
673
632
  function readModelSeries(db, cutoff, bucketMs) {
674
- const scope = rangeWhere(cutoff);
675
- const top = `model IN (SELECT model FROM messages ${scope} GROUP BY model ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
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})`;
676
635
  const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
677
- const out = readBlocks(db, [['modelSeries', `SELECT ${txt('model')}, (timestamp / ${bucketMs}) * ${bucketMs},
678
- COUNT(*), SUM(total_tokens), TOTAL(cost_total) FROM messages ${clause} GROUP BY 1, 2 ORDER BY 1, 2`]]);
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`]]);
679
638
  const perModel = new Map();
680
639
  for (const r of out.get('modelSeries') ?? []) {
681
640
  const model = r[0] ?? '';
@@ -685,22 +644,18 @@ function readModelSeries(db, cutoff, bucketMs) {
685
644
  }
686
645
  return [...perModel.entries()].map(([model, points]) => ({ model, points }));
687
646
  }
688
- /**
689
- * Throughput and first-token latency per bucket for the busiest models, the chart behind a
690
- * model's expanded row. Both averages are null in a bucket where no request carried the timing,
691
- * because a non-null zero would draw a flat line through a real gap.
692
- */
647
+ /** Per-bucket throughput and ttft for the busiest models; a null average means no request carried timing. */
693
648
  function readModelPerformance(db, cutoff, bucketMs) {
694
- const scope = rangeWhere(cutoff);
695
- const top = `model IN (SELECT model FROM messages ${scope} GROUP BY model ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
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})`;
696
651
  const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
697
- const out = readBlocks(db, [['modelPerformance', `SELECT ${txt('model')}, ${txt('provider')}, (timestamp / ${bucketMs}) * ${bucketMs},
652
+ const out = readBlocks(db, [['modelPerformance', `SELECT ${COL.model}, ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
698
653
  COUNT(*),
699
- TOTAL(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration END),
700
- COUNT(CASE WHEN duration > 0 THEN 1 END),
701
- TOTAL(ttft),
702
- COUNT(ttft)
703
- FROM messages ${clause} GROUP BY 1, 2, 3 ORDER BY 1, 2, 3`]]);
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`]]);
704
659
  const groups = new Map();
705
660
  for (const r of out.get('modelPerformance') ?? []) {
706
661
  const key = `${r[0] ?? ''}\u0000${r[1] ?? ''}`;
@@ -717,7 +672,6 @@ function readModelPerformance(db, cutoff, bucketMs) {
717
672
  }
718
673
  return [...groups.values()];
719
674
  }
720
- // ------------------------------------------------------------ subscription windows
721
675
  /** Reference constants for the quota half, copied from the omp implementation. */
722
676
  const WINDOW_RESET_DROP = 0.05;
723
677
  const WINDOW_MIN_EXTRAPOLATION = 0.1;
@@ -746,10 +700,7 @@ function downsampleWindow(points) {
746
700
  }
747
701
  return out;
748
702
  }
749
- /**
750
- * Peak of the sum across accounts at any sampled instant, forward filling each account's last
751
- * known fraction. A peak of 1.7 means demand simultaneously held 1.7 windows of quota.
752
- */
703
+ /** Peak summed quota across accounts, forward-filling last known fractions (1.7 = 1.7 windows held). */
753
704
  function peakConcurrent(samples) {
754
705
  const events = samples.filter((sample) => sample.usedFraction !== null).toSorted((a, b) => a.ts - b.ts);
755
706
  const current = new Map();
@@ -795,10 +746,7 @@ function readWindowSamples(cutoff) {
795
746
  };
796
747
  });
797
748
  }
798
- /**
799
- * Utilization series and per-window insights. A window is one `(provider, limitId)` pair: distinct
800
- * limits can share a duration label, and merging them interleaves unrelated fractions per account.
801
- */
749
+ /** Window utilization; a window is one `(provider, limitId)` pair, since limits can share a duration. */
802
750
  function computeWindows(samples, tokensByProvider) {
803
751
  const groups = new Map();
804
752
  for (const sample of samples) {
@@ -881,16 +829,16 @@ function computeWindows(samples, tokensByProvider) {
881
829
  }
882
830
  /** Tokens per provider over the range: the currency a window insight extrapolates in. */
883
831
  function readProviderTokens(db, cutoff) {
884
- const out = readBlocks(db, [['providerTokens', `SELECT ${txt('provider')}, SUM(total_tokens)
885
- FROM messages ${rangeWhere(cutoff)} GROUP BY 1`]]);
832
+ const out = readBlocks(db, [['providerTokens', `SELECT ${COL.provider}, SUM(${COL.total})
833
+ FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1`]]);
886
834
  return new Map((out.get('providerTokens') ?? []).map((r) => [r[0] ?? '', num(r[1])]));
887
835
  }
888
836
  /** Peak burn hours: one row per provider per local hour, the shape the reference charts. */
889
837
  function readProviderHourly(db, cutoff) {
890
- const out = readBlocks(db, [['providerHourly', `SELECT ${txt('provider')},
891
- CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER),
892
- SUM(total_tokens), SUM(output_tokens), COUNT(*)
893
- FROM messages ${rangeWhere(cutoff)} GROUP BY 1, 2`]]);
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`]]);
894
842
  return (out.get('providerHourly') ?? []).map((r) => ({
895
843
  provider: r[0] ?? '',
896
844
  hour: num(r[1]),
@@ -907,24 +855,25 @@ function median(values) {
907
855
  const mid = nums.length >> 1;
908
856
  return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
909
857
  }
910
- /** The Requests page summary, computed over the rows the payload carries. */
911
- function computeRequestStats(rows) {
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) {
912
860
  const durations = rows.map((row) => row.durationMs);
913
861
  const sorted = durations.filter((v) => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
914
862
  const pick = (q) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
915
863
  const times = rows.map((row) => row.ts);
916
864
  return {
917
- requests: rows.length,
918
- failed: rows.filter((row) => row.stopReason === 'error').length,
865
+ requests: range ? range.requests : rows.length,
866
+ failed: range ? range.failed : rows.filter((row) => row.stopReason === 'error').length,
919
867
  aborted: rows.filter((row) => row.stopReason === 'aborted').length,
920
- tokens: rows.reduce((sum, row) => sum + row.totalTokens, 0),
921
- costUsd: rows.reduce((sum, row) => sum + row.costUsd, 0),
922
- unpriced: rows.filter((row) => row.unpriced).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,
923
872
  medianDurationMs: median(durations),
924
873
  p95DurationMs: pick(0.95),
925
874
  medianTtftMs: median(rows.map((row) => row.ttftMs)),
926
- oldest: times.length ? Math.min(...times) : null,
927
- newest: times.length ? Math.max(...times) : null,
875
+ oldest: range ? range.firstTs : times.length ? Math.min(...times) : null,
876
+ newest: range ? range.lastTs : times.length ? Math.max(...times) : null,
928
877
  };
929
878
  }
930
879
  /** How many spans one trace ships. A long session holds thousands; the transcript virtualises. */
@@ -949,15 +898,7 @@ function stopState(stopReason) {
949
898
  }
950
899
  /** Tool names that hand the work to another agent, for the Agents figure. */
951
900
  const AGENT_TOOLS = new Set(['task', 'subagent']);
952
- /**
953
- * One session transcript as spans, tracks, markers, and totals.
954
- *
955
- * An assistant message stamps its completion and its `duration` says when the call began, so a
956
- * model span runs `ts - duration` to `ts`. A tool call is emitted by the message requesting it and
957
- * closed by the `toolResult` with the same call id, so the two bound a tool span. A user message
958
- * opens a turn the next user message closes, and everything that is not a message rides along as a
959
- * marker, since it happened at a point in time rather than across one.
960
- */
901
+ /** One transcript as spans, tracks and markers: a model span runs `ts - duration` to `ts`. */
961
902
  export function readSessionTrace(sessionFile, limit = TRACE_SPAN_LIMIT) {
962
903
  const project = sessionFile.split('/sessions/')[1]?.split('/')[0] ?? '';
963
904
  const empty = {
@@ -1174,12 +1115,7 @@ function buildToolStats(spans) {
1174
1115
  export function emptyRequestEntry() {
1175
1116
  return { entry: null, output: null, messageRole: '', agent: { model: null, thinkingLevel: null, mode: null, fallback: null } };
1176
1117
  }
1177
- /**
1178
- * One request's journal payload, read on demand by the request drawer. `entry` is the raw
1179
- * journal line, `output` is the message that line carries, and `agent` is the agent state in
1180
- * force when the request ran. All are empty when the entry journals nothing: a `model_usage`
1181
- * row carries no message, and a deleted session cannot be re-read.
1182
- */
1118
+ /** One request's journal payload; empty when the entry journals nothing or its session is gone. */
1183
1119
  export function readAgentRequestEntry(sessionFile, entryId) {
1184
1120
  const empty = emptyRequestEntry();
1185
1121
  if (!entryId || !sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile))
@@ -1221,8 +1157,8 @@ export function readAgentRequestEntry(sessionFile, entryId) {
1221
1157
  }
1222
1158
  return empty;
1223
1159
  }
1224
- /** Reads both omp databases. `available` is false when stats.db holds no `messages` table. */
1225
- export function readAgentStats(range = '24h', view = 'all', now = Date.now()) {
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) {
1226
1162
  const span = RANGE_MS[range];
1227
1163
  const cutoff = span > 0 ? now - span : 0;
1228
1164
  const parts = new Set(VIEW_PARTS[view]);
@@ -1236,10 +1172,10 @@ export function readAgentStats(range = '24h', view = 'all', now = Date.now()) {
1236
1172
  return stats;
1237
1173
  }
1238
1174
  // The bucket width depends on the span, so the bounds come first.
1239
- const bounds = readBlocks(db, [['bounds', `SELECT MIN(timestamp), MAX(timestamp) FROM messages ${rangeWhere(cutoff)}`]]);
1175
+ const bounds = readBlocks(db, [['bounds', `SELECT MIN(${COL.timestamp}), MAX(${COL.timestamp}) FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)}`]]);
1240
1176
  const [firstTs, lastTs] = bounds.get('bounds')?.[0] ?? [];
1241
1177
  stats.bucketMs = bucketSize(range, numOrNull(firstTs), numOrNull(lastTs));
1242
- Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, cols, parts));
1178
+ Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, parts, offset));
1243
1179
  if (parts.has('seriesByProvider'))
1244
1180
  stats.seriesByProvider = readProviderSeries(db, cutoff, stats.bucketMs);
1245
1181
  if (parts.has('modelSeries'))
@@ -1253,7 +1189,7 @@ export function readAgentStats(range = '24h', view = 'all', now = Date.now()) {
1253
1189
  if (parts.has('providerHourly'))
1254
1190
  stats.providerHourly = readProviderHourly(db, cutoff);
1255
1191
  if (parts.has('recent'))
1256
- stats.requestStats = computeRequestStats(stats.recent);
1192
+ stats.requestStats = computeRequestStats(stats.recent, stats.overall);
1257
1193
  if (parts.has('windows')) {
1258
1194
  Object.assign(stats, computeWindows(readWindowSamples(cutoff), readProviderTokens(db, cutoff)));
1259
1195
  }