@kitsunemori96/opencode-session-metrics 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,3892 @@
1
+ // src/index.ts
2
+ import express2 from "express";
3
+ import { existsSync as existsSync5 } from "node:fs";
4
+ import path3 from "node:path";
5
+
6
+ // src/config.ts
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { existsSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
11
+ var ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
12
+ var INSTALLED = `${path.sep}node_modules${path.sep}`;
13
+ var USER_DATA_DIR = path.join(
14
+ process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
15
+ "opencode-session-metrics"
16
+ );
17
+ var DATA_DIR = process.env.OSM_DATA_DIR ?? (ROOT.includes(INSTALLED) ? USER_DATA_DIR : path.join(ROOT, ".data"));
18
+ var SOURCE_DB = process.env.OSM_SOURCE_DB ?? path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
19
+ var QODER_HOME = process.env.OSM_QODER_HOME ?? path.join(os.homedir(), ".qoder");
20
+ var QODER_PROJECTS = path.join(QODER_HOME, "projects");
21
+ var QODER_LOGS = path.join(QODER_HOME, "logs", "sessions");
22
+ var SOURCE_KINDS = ["opencode", "qoder"];
23
+ function resolveSourceKind() {
24
+ const explicit = process.env.OSM_SOURCE?.trim();
25
+ if (explicit === "qoder" || explicit === "opencode") return explicit;
26
+ if (process.env.OSM_SOURCE_DB) return "opencode";
27
+ if (existsSync(SOURCE_DB)) return "opencode";
28
+ if (existsSync(QODER_PROJECTS)) return "qoder";
29
+ return "opencode";
30
+ }
31
+ var SOURCE_KIND = resolveSourceKind();
32
+ var METRICS_DB_BY_KIND = {
33
+ opencode: path.join(DATA_DIR, "metrics.db"),
34
+ qoder: path.join(DATA_DIR, "metrics-qoder.db")
35
+ };
36
+ var metricsDbPath = (kind) => METRICS_DB_BY_KIND[kind];
37
+ var METRICS_DB = METRICS_DB_BY_KIND.opencode;
38
+ var sourceProbe = (kind) => kind === "qoder" ? QODER_PROJECTS : SOURCE_DB;
39
+ var sourceAvailable = (kind) => existsSync(sourceProbe(kind));
40
+ var MODEL_CATALOG = path.join(DATA_DIR, "model-catalog.json");
41
+ var MODEL_OVERRIDES = path.join(DATA_DIR, "model-overrides.json");
42
+ var HOST = process.env.OSM_HOST ?? "127.0.0.1";
43
+ var PORT = Number(process.env.OSM_PORT ?? 8787);
44
+ var BUCKET_MS = 5 * 60 * 1e3;
45
+
46
+ // src/api.ts
47
+ import express from "express";
48
+ import cors from "cors";
49
+
50
+ // src/metrics/schema.ts
51
+ import { DatabaseSync } from "node:sqlite";
52
+
53
+ // src/metrics/histogram.ts
54
+ var logLadder = (name, perOctave) => ({
55
+ name,
56
+ key: (col) => `CAST(log2(MAX(${col}, 0) + 1) * ${perOctave} AS INTEGER)`,
57
+ lower: (bin) => Math.max(0, Math.pow(2, bin / perOctave) - 1)
58
+ });
59
+ var linearLadder = (name, step) => ({
60
+ name,
61
+ key: (col) => `CAST(MAX(${col}, 0) / ${step} AS INTEGER)`,
62
+ lower: (bin) => Math.max(0, bin * step)
63
+ });
64
+ var ladder = {
65
+ ms: logLadder("ms", 128),
66
+ share: linearLadder("share", 2e-3),
67
+ rate: linearLadder("rate", 0.25)
68
+ };
69
+ function histogramOf(rows) {
70
+ const h = /* @__PURE__ */ new Map();
71
+ for (const r of rows) {
72
+ const bin = Number(r.bin) || 0;
73
+ h.set(bin, (h.get(bin) ?? 0) + (Number(r.n) || 0));
74
+ }
75
+ return h;
76
+ }
77
+ function histogramCount(h) {
78
+ let n = 0;
79
+ for (const c of h.values()) n += c;
80
+ return n;
81
+ }
82
+ function quantile(h, p, l, ceiling = Infinity) {
83
+ const total = histogramCount(h);
84
+ if (total <= 0) return 0;
85
+ const clamped = Math.min(1, Math.max(0, p));
86
+ const rank = Math.min(total - 1, Math.max(0, Math.round(clamped * (total - 1))));
87
+ let before = 0;
88
+ for (const bin of [...h.keys()].sort((x, y) => x - y)) {
89
+ const n = h.get(bin) ?? 0;
90
+ if (rank < before + n) {
91
+ const inside = n > 0 ? (rank - before + 0.5) / n : 0;
92
+ const lo = l.lower(bin);
93
+ const hi = l.lower(bin + 1);
94
+ const at = lo + (hi - lo) * Math.min(1, Math.max(0, inside));
95
+ return Math.min(ceiling, Math.max(0, at));
96
+ }
97
+ before += n;
98
+ }
99
+ return Math.min(ceiling, l.lower([...h.keys()].sort((x, y) => x - y).pop() ?? 0));
100
+ }
101
+ function histMedian(h, l, ceiling = Infinity) {
102
+ return quantile(h, 0.5, l, ceiling);
103
+ }
104
+
105
+ // src/metrics/rollup.ts
106
+ var ROLL_VERSION = "2";
107
+ var DDL = (
108
+ /* sql */
109
+ `
110
+ CREATE TABLE IF NOT EXISTS roll_cell (
111
+ session_id TEXT NOT NULL,
112
+ day INTEGER NOT NULL,
113
+ project TEXT NOT NULL,
114
+ model TEXT NOT NULL,
115
+ agent TEXT NOT NULL,
116
+ turns INTEGER NOT NULL,
117
+ input INTEGER NOT NULL,
118
+ output INTEGER NOT NULL,
119
+ reasoning INTEGER NOT NULL,
120
+ cache_read INTEGER NOT NULL,
121
+ cache_write INTEGER NOT NULL,
122
+ cost REAL NOT NULL,
123
+ tools INTEGER NOT NULL,
124
+ tool_errors INTEGER NOT NULL,
125
+ additions INTEGER NOT NULL,
126
+ deletions INTEGER NOT NULL,
127
+ write_lines INTEGER NOT NULL,
128
+ win INTEGER NOT NULL,
129
+ stopped INTEGER NOT NULL,
130
+ first_created INTEGER NOT NULL,
131
+ last_completed INTEGER NOT NULL,
132
+ PRIMARY KEY (session_id, day, project, model, agent)
133
+ ) WITHOUT ROWID;
134
+
135
+ CREATE INDEX IF NOT EXISTS roll_cell_day ON roll_cell(day);
136
+
137
+ CREATE TABLE IF NOT EXISTS roll_hour (
138
+ day INTEGER NOT NULL,
139
+ hour INTEGER NOT NULL,
140
+ project TEXT NOT NULL,
141
+ model TEXT NOT NULL,
142
+ agent TEXT NOT NULL,
143
+ turns INTEGER NOT NULL,
144
+ PRIMARY KEY (day, hour, project, model, agent)
145
+ ) WITHOUT ROWID;
146
+
147
+ CREATE INDEX IF NOT EXISTS roll_hour_day ON roll_hour(day);
148
+
149
+ CREATE TABLE IF NOT EXISTS roll_tool (
150
+ day INTEGER NOT NULL,
151
+ name TEXT NOT NULL,
152
+ session_id TEXT NOT NULL,
153
+ project TEXT NOT NULL,
154
+ model TEXT NOT NULL,
155
+ agent TEXT NOT NULL,
156
+ calls INTEGER NOT NULL,
157
+ errors INTEGER NOT NULL,
158
+ ms_sum INTEGER NOT NULL,
159
+ ms_max INTEGER NOT NULL,
160
+ PRIMARY KEY (day, name, session_id, project, model, agent)
161
+ ) WITHOUT ROWID;
162
+
163
+ -- Day first, then name, then bin: a window is one contiguous range of the b-tree, and
164
+ -- the rows arrive nearly grouped the way the panel groups them. With the name first this
165
+ -- scan cost twice as much on a decade of calls (400k rows, one bin each).
166
+ CREATE TABLE IF NOT EXISTS roll_tool_bin (
167
+ day INTEGER NOT NULL,
168
+ name TEXT NOT NULL,
169
+ bin INTEGER NOT NULL,
170
+ project TEXT NOT NULL,
171
+ model TEXT NOT NULL,
172
+ agent TEXT NOT NULL,
173
+ n INTEGER NOT NULL,
174
+ PRIMARY KEY (day, name, bin, project, model, agent)
175
+ ) WITHOUT ROWID;
176
+ `
177
+ );
178
+ var CELL_COLS = [
179
+ "session_id",
180
+ "day",
181
+ "project",
182
+ "model",
183
+ "agent",
184
+ "turns",
185
+ "input",
186
+ "output",
187
+ "reasoning",
188
+ "cache_read",
189
+ "cache_write",
190
+ "cost",
191
+ "tools",
192
+ "tool_errors",
193
+ "additions",
194
+ "deletions",
195
+ "write_lines",
196
+ "win",
197
+ "stopped",
198
+ "first_created",
199
+ "last_completed"
200
+ ];
201
+ var CELL_SELECT = `t.session_id, t.day, COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main'),
202
+ COUNT(*), SUM(t.input), SUM(t.output), SUM(t.reasoning), SUM(t.cache_read), SUM(t.cache_write), SUM(t.cost),
203
+ SUM(COALESCE(t.tool_calls,0)), SUM(COALESCE(t.tool_errors,0)),
204
+ SUM(COALESCE(t.additions,0)), SUM(COALESCE(t.deletions,0)), SUM(COALESCE(t.write_lines,0)),
205
+ MAX(COALESCE(t.context_window,0)),
206
+ SUM(CASE WHEN COALESCE(t.finish,'') <> '' AND t.finish <> 'stop' THEN 1 ELSE 0 END),
207
+ MIN(t.created), MAX(COALESCE(t.completed, t.created))`;
208
+ var CELL_GROUP = "GROUP BY t.session_id, t.day, COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main')";
209
+ var HOUR_SELECT = `t.day, t.hour, COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main'), COUNT(*)`;
210
+ var HOUR_GROUP = "GROUP BY t.day, t.hour, COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main')";
211
+ var TOOL_SELECT = `t.day, tl.name, tl.session_id, COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main'),
212
+ COUNT(*), SUM(CASE WHEN tl.status = 'error' THEN 1 ELSE 0 END),
213
+ SUM(COALESCE(tl.dur_ms,0)), MAX(COALESCE(tl.dur_ms,0))`;
214
+ var TOOL_GROUP = "GROUP BY tl.name, t.day, tl.session_id, COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main')";
215
+ var TOOL_FROM = "FROM tools tl JOIN turns t ON t.id = tl.turn_id WHERE t.role = 'assistant'";
216
+ var DROP = "DROP TABLE IF EXISTS roll_cell; DROP TABLE IF EXISTS roll_hour; DROP TABLE IF EXISTS roll_tool; DROP TABLE IF EXISTS roll_tool_bin;";
217
+ var ready = /* @__PURE__ */ new WeakSet();
218
+ function ceilDay(ts) {
219
+ const d = new Date(ts);
220
+ d.setHours(0, 0, 0, 0);
221
+ return d.getTime() >= ts ? d.getTime() : d.getTime() + nextDayMs(d);
222
+ }
223
+ function floorDay(ts) {
224
+ const d = new Date(ts);
225
+ d.setHours(0, 0, 0, 0);
226
+ return d.getTime();
227
+ }
228
+ function nextDayMs(midnight) {
229
+ const next = new Date(midnight.getTime());
230
+ next.setDate(next.getDate() + 1);
231
+ return next.getTime() - midnight.getTime();
232
+ }
233
+ function hasTurns(db) {
234
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='turns'").get();
235
+ }
236
+ function rebuildAll(db) {
237
+ db.exec("BEGIN");
238
+ try {
239
+ db.exec("DELETE FROM roll_cell; DELETE FROM roll_hour; DELETE FROM roll_tool; DELETE FROM roll_tool_bin");
240
+ db.exec(
241
+ `INSERT INTO roll_cell(${CELL_COLS.join(",")}) SELECT ${CELL_SELECT} FROM turns t WHERE t.role = 'assistant' ${CELL_GROUP}`
242
+ );
243
+ db.exec(
244
+ `INSERT INTO roll_hour(day,hour,project,model,agent,turns) SELECT ${HOUR_SELECT} FROM turns t WHERE t.role = 'assistant' ${HOUR_GROUP}`
245
+ );
246
+ db.exec(`INSERT INTO roll_tool(day,name,session_id,project,model,agent,calls,errors,ms_sum,ms_max)
247
+ SELECT ${TOOL_SELECT} ${TOOL_FROM} ${TOOL_GROUP}`);
248
+ db.exec(
249
+ `INSERT INTO roll_tool_bin(day,name,bin,project,model,agent,n)
250
+ SELECT t.day, tl.name, ${ladder.ms.key("COALESCE(tl.dur_ms,0)")},
251
+ COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main'), COUNT(*)
252
+ ${TOOL_FROM}
253
+ GROUP BY 1,2,3,4,5,6`
254
+ );
255
+ setMeta(db, "rollVersion", ROLL_VERSION);
256
+ db.exec("COMMIT");
257
+ } catch (e) {
258
+ try {
259
+ db.exec("ROLLBACK");
260
+ } catch {
261
+ }
262
+ throw e;
263
+ }
264
+ ready.add(db);
265
+ }
266
+ function refreshRollups(db, sessionIds, days, full = false) {
267
+ if (!hasTurns(db)) return;
268
+ if (full) {
269
+ db.exec("DELETE FROM roll_cell; DELETE FROM roll_hour; DELETE FROM roll_tool; DELETE FROM roll_tool_bin");
270
+ }
271
+ if (sessionIds.length) {
272
+ const ph = sessionIds.map(() => "?").join(",");
273
+ db.prepare(`DELETE FROM roll_cell WHERE session_id IN (${ph})`).run(...sessionIds);
274
+ db.prepare(
275
+ `INSERT INTO roll_cell(${CELL_COLS.join(",")})
276
+ SELECT ${CELL_SELECT} FROM turns t WHERE t.role = 'assistant' AND t.session_id IN (${ph}) ${CELL_GROUP}`
277
+ ).run(...sessionIds);
278
+ db.prepare(`DELETE FROM roll_tool WHERE session_id IN (${ph})`).run(...sessionIds);
279
+ db.prepare(`INSERT INTO roll_tool(day,name,session_id,project,model,agent,calls,errors,ms_sum,ms_max)
280
+ SELECT ${TOOL_SELECT} ${TOOL_FROM} AND tl.session_id IN (${ph}) ${TOOL_GROUP}`).run(
281
+ ...sessionIds
282
+ );
283
+ }
284
+ if (days.length) {
285
+ const ph = days.map(() => "?").join(",");
286
+ db.prepare(`DELETE FROM roll_hour WHERE day IN (${ph})`).run(...days);
287
+ db.prepare(
288
+ `INSERT INTO roll_hour(day,hour,project,model,agent,turns)
289
+ SELECT ${HOUR_SELECT} FROM turns t WHERE t.role = 'assistant' AND t.day IN (${ph}) ${HOUR_GROUP}`
290
+ ).run(...days);
291
+ db.prepare(`DELETE FROM roll_tool_bin WHERE day IN (${ph})`).run(...days);
292
+ db.prepare(
293
+ `INSERT INTO roll_tool_bin(day,name,bin,project,model,agent,n)
294
+ SELECT t.day, tl.name, ${ladder.ms.key("COALESCE(tl.dur_ms,0)")},
295
+ COALESCE(t.project,'\u2014'), COALESCE(t.model,'unknown'), COALESCE(t.agent,'main'), COUNT(*)
296
+ ${TOOL_FROM} AND t.day IN (${ph})
297
+ GROUP BY 1,2,3,4,5,6`
298
+ ).run(...days);
299
+ }
300
+ setMeta(db, "rollVersion", ROLL_VERSION);
301
+ ready.add(db);
302
+ }
303
+ function ensureRollups(db) {
304
+ if (!hasTurns(db)) return;
305
+ const version = getMeta(db, "rollVersion");
306
+ if (version !== ROLL_VERSION) {
307
+ db.exec(DROP);
308
+ }
309
+ db.exec(DDL);
310
+ const seeded = db.prepare("SELECT 1 FROM turns WHERE role='assistant' LIMIT 1").get();
311
+ if (version !== ROLL_VERSION && seeded) rebuildAll(db);
312
+ ready.add(db);
313
+ }
314
+ function rollupsReady(db) {
315
+ return ready.has(db) && getMeta(db, "rollVersion") === ROLL_VERSION;
316
+ }
317
+ function rollSpan(db, slice) {
318
+ if (!rollupsReady(db)) return null;
319
+ const lo = ceilDay(slice.from);
320
+ const hi = floorDay(slice.to);
321
+ if (hi <= lo) return null;
322
+ return { lo, hi };
323
+ }
324
+ function cellFacet(slice) {
325
+ const parts = [];
326
+ const args = [];
327
+ if (slice.project) parts.push("project = ?"), args.push(slice.project);
328
+ if (slice.model) parts.push("model = ?"), args.push(slice.model);
329
+ if (slice.agent) parts.push("agent = ?"), args.push(slice.agent);
330
+ return { sql: parts.length ? ` AND ${parts.join(" AND ")}` : "", args };
331
+ }
332
+ function turnFacet(slice) {
333
+ const parts = [];
334
+ const args = [];
335
+ if (slice.project) parts.push("COALESCE(t.project,'\u2014') = ?"), args.push(slice.project);
336
+ if (slice.model) parts.push("COALESCE(t.model,'unknown') = ?"), args.push(slice.model);
337
+ if (slice.agent) parts.push("COALESCE(t.agent,'main') = ?"), args.push(slice.agent);
338
+ return { sql: parts.length ? ` AND ${parts.join(" AND ")}` : "", args };
339
+ }
340
+ function edgeSql(slice, span) {
341
+ const f = turnFacet(slice);
342
+ return {
343
+ sql: `t.role = 'assistant' AND ((t.created >= ? AND t.created < ?) OR (t.created >= ? AND t.created < ?))${f.sql}`,
344
+ args: [slice.from, span.lo, span.hi, slice.to, ...f.args]
345
+ };
346
+ }
347
+ var TURN_COLUMNS = `t.session_id, COALESCE(t.project,'\u2014') project, COALESCE(t.model,'unknown') model,
348
+ COALESCE(t.agent,'main') agent, t.day, t.bucket, t.created, COALESCE(t.completed,t.created) ended,
349
+ 1 turns, t.input, t.output, t.reasoning, t.cache_read, t.cache_write, t.cost,
350
+ COALESCE(t.tool_calls,0) tools, COALESCE(t.tool_errors,0) tool_errors,
351
+ COALESCE(t.additions,0) additions, COALESCE(t.deletions,0) deletions,
352
+ COALESCE(t.write_lines,0) write_lines, COALESCE(t.context_window,0) win,
353
+ CASE WHEN COALESCE(t.finish,'') <> '' AND t.finish <> 'stop' THEN 1 ELSE 0 END stopped`;
354
+ var CELL_TURNS = `session_id, project, model, agent, day, day bucket, first_created created, last_completed ended,
355
+ turns, input, output, reasoning, cache_read, cache_write, cost, tools, tool_errors,
356
+ additions, deletions, write_lines, win, stopped`;
357
+ function turnsFrom(where) {
358
+ return `SELECT ${TURN_COLUMNS} FROM turns t WHERE ${where.sql}`;
359
+ }
360
+ function rawCellSource(slice) {
361
+ const f = turnFacet(slice);
362
+ return {
363
+ sql: turnsFrom({ sql: `t.role = 'assistant' AND t.created >= ? AND t.created < ?${f.sql}`, args: [] }),
364
+ args: [slice.from, slice.to, ...f.args]
365
+ };
366
+ }
367
+ function cellSource(slice, span) {
368
+ const f = cellFacet(slice);
369
+ const e = edgeSql(slice, span);
370
+ return {
371
+ sql: `SELECT ${CELL_TURNS} FROM roll_cell WHERE day >= ? AND day < ?${f.sql}
372
+ UNION ALL ${turnsFrom(e)}`,
373
+ args: [span.lo, span.hi, ...f.args, ...e.args]
374
+ };
375
+ }
376
+ var TOOL_COLUMNS = `tl.name, tl.session_id, COALESCE(t.agent,'main') agent, t.day,
377
+ 1 calls, CASE WHEN tl.status = 'error' THEN 1 ELSE 0 END errors,
378
+ COALESCE(tl.dur_ms,0) ms, COALESCE(tl.dur_ms,0) mx`;
379
+ var TOOL_TURNS = "tools tl JOIN turns t ON t.id = tl.turn_id";
380
+ function rawToolSource(slice) {
381
+ const f = turnFacet(slice);
382
+ return {
383
+ sql: `SELECT ${TOOL_COLUMNS} FROM ${TOOL_TURNS}
384
+ WHERE t.role = 'assistant' AND t.created >= ? AND t.created < ?${f.sql}`,
385
+ args: [slice.from, slice.to, ...f.args]
386
+ };
387
+ }
388
+ function toolSource(slice, span) {
389
+ const f = cellFacet(slice);
390
+ const e = edgeSql(slice, span);
391
+ return {
392
+ sql: `SELECT name, session_id, agent, day, calls, errors, ms_sum ms, ms_max mx
393
+ FROM roll_tool WHERE day >= ? AND day < ?${f.sql}
394
+ UNION ALL
395
+ SELECT ${TOOL_COLUMNS} FROM ${TOOL_TURNS} WHERE ${e.sql}`,
396
+ args: [span.lo, span.hi, ...f.args, ...e.args]
397
+ };
398
+ }
399
+ var LATENCY_BINS = ladder.ms.key("COALESCE(tl.dur_ms,0)");
400
+ function turnBins(where) {
401
+ return `SELECT tl.name, ${LATENCY_BINS} bin, COUNT(*) n FROM ${TOOL_TURNS}
402
+ WHERE ${where.sql} GROUP BY tl.name, bin`;
403
+ }
404
+ function rawToolBins(slice) {
405
+ const f = turnFacet(slice);
406
+ return {
407
+ sql: turnBins({ sql: `t.role = 'assistant' AND t.created >= ? AND t.created < ?${f.sql}`, args: [] }),
408
+ args: [slice.from, slice.to, ...f.args]
409
+ };
410
+ }
411
+ function toolBins(slice, span) {
412
+ const f = cellFacet(slice);
413
+ const e = edgeSql(slice, span);
414
+ return {
415
+ sql: `SELECT name, bin, SUM(n) n FROM (
416
+ SELECT name, bin, n FROM roll_tool_bin WHERE day >= ? AND day < ?${f.sql}
417
+ UNION ALL ${turnBins(e)}
418
+ ) GROUP BY name, bin`,
419
+ args: [span.lo, span.hi, ...f.args, ...e.args]
420
+ };
421
+ }
422
+ function hourSource(slice, span) {
423
+ const f = cellFacet(slice);
424
+ const e = edgeSql(slice, span);
425
+ return {
426
+ sql: `SELECT day, hour, SUM(turns) n FROM roll_hour WHERE day >= ? AND day < ?${f.sql} GROUP BY day, hour
427
+ UNION ALL
428
+ SELECT t.day, t.hour, COUNT(*) FROM turns t WHERE ${e.sql} GROUP BY t.day, t.hour`,
429
+ args: [span.lo, span.hi, ...f.args, ...e.args]
430
+ };
431
+ }
432
+ function rawHourSource(slice) {
433
+ const f = turnFacet(slice);
434
+ return {
435
+ sql: `SELECT t.day, t.hour, COUNT(*) n FROM turns t
436
+ WHERE t.role = 'assistant' AND t.created >= ? AND t.created < ?${f.sql}
437
+ GROUP BY t.day, t.hour`,
438
+ args: [slice.from, slice.to, ...f.args]
439
+ };
440
+ }
441
+
442
+ // src/metrics/schema.ts
443
+ import { mkdirSync } from "node:fs";
444
+ function openMetrics(file = METRICS_DB) {
445
+ mkdirSync(DATA_DIR, { recursive: true });
446
+ const db = new DatabaseSync(file);
447
+ db.exec("PRAGMA journal_mode = WAL");
448
+ db.exec("PRAGMA foreign_keys = OFF");
449
+ ensureIndexes(db);
450
+ ensureRollups(db);
451
+ return db;
452
+ }
453
+ var FACT_TABLES = ["turns", "tools", "parts", "events", "sessions", "flow", "heat"];
454
+ var SCHEMA = (
455
+ /* sql */
456
+ `
457
+ CREATE TABLE IF NOT EXISTS models (
458
+ id TEXT PRIMARY KEY, provider TEXT, label TEXT,
459
+ context INTEGER DEFAULT 128000,
460
+ cost_input REAL DEFAULT 0, cost_output REAL DEFAULT 0,
461
+ cost_cache_read REAL DEFAULT 0, cost_cache_write REAL DEFAULT 0,
462
+ source TEXT
463
+ );
464
+
465
+ CREATE TABLE IF NOT EXISTS turns (
466
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, project TEXT NOT NULL,
467
+ seq INTEGER, created INTEGER, completed INTEGER, dur_ms INTEGER,
468
+ role TEXT, provider TEXT, model TEXT, variant TEXT, agent TEXT,
469
+ input INTEGER, output INTEGER, reasoning INTEGER,
470
+ cache_read INTEGER, cache_write INTEGER, cost REAL,
471
+ credits_micro INTEGER DEFAULT 0,
472
+ prompt_total INTEGER, occupancy INTEGER, context_window INTEGER,
473
+ fill REAL, fill_after REAL,
474
+ bucket INTEGER, day INTEGER, hour INTEGER,
475
+ finish TEXT, has_error INTEGER, tool_calls INTEGER, tool_errors INTEGER,
476
+ additions INTEGER, deletions INTEGER, write_lines INTEGER, text TEXT
477
+ );
478
+ CREATE INDEX IF NOT EXISTS turns_session ON turns(session_id);
479
+ CREATE INDEX IF NOT EXISTS turns_project_time ON turns(project, created);
480
+ CREATE INDEX IF NOT EXISTS turns_time ON turns(created);
481
+ CREATE INDEX IF NOT EXISTS turns_bucket ON turns(bucket);
482
+
483
+ CREATE TABLE IF NOT EXISTS tools (
484
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
485
+ session_id TEXT, turn_id TEXT, call_id TEXT, name TEXT, status TEXT,
486
+ started INTEGER, ended INTEGER, dur_ms INTEGER,
487
+ title TEXT, output_chars INTEGER, additions INTEGER, deletions INTEGER, files INTEGER, write_lines INTEGER,
488
+ error TEXT, sig TEXT, input_preview TEXT, target_session TEXT
489
+ );
490
+ CREATE INDEX IF NOT EXISTS tools_session ON tools(session_id, started);
491
+ CREATE INDEX IF NOT EXISTS tools_target ON tools(target_session);
492
+
493
+ -- The ordered content stream of every assistant turn: the text it wrote, the
494
+ -- reasoning it produced, and each tool call with its input and output. This is the
495
+ -- one table that is a transcript rather than a metric, so bodies are capped (see
496
+ -- PARTS_CAP in build.ts) \u2014 the UI renders at most 40 output lines and one input row
497
+ -- per key, and the caps keep a 234 MB source from becoming a 234 MB copy.
498
+ CREATE TABLE IF NOT EXISTS parts (
499
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
500
+ session_id TEXT NOT NULL, turn_id TEXT NOT NULL, ordinal INTEGER,
501
+ kind TEXT NOT NULL, -- text | reasoning | tool
502
+ text TEXT, -- body for text/reasoning
503
+ name TEXT, call_id TEXT, status TEXT, title TEXT,
504
+ input TEXT, output TEXT, exit_code INTEGER, truncated INTEGER,
505
+ started INTEGER, ended INTEGER, chars INTEGER
506
+ );
507
+ CREATE INDEX IF NOT EXISTS parts_turn ON parts(turn_id);
508
+ CREATE INDEX IF NOT EXISTS parts_session ON parts(session_id, id);
509
+
510
+ CREATE TABLE IF NOT EXISTS events (
511
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
512
+ session_id TEXT, turn_id TEXT, type TEXT, at INTEGER, end_at INTEGER, count INTEGER,
513
+ fill REAL, detail TEXT
514
+ );
515
+ CREATE INDEX IF NOT EXISTS events_session ON events(session_id, at);
516
+ CREATE INDEX IF NOT EXISTS events_time ON events(at);
517
+
518
+ CREATE TABLE IF NOT EXISTS sessions (
519
+ id TEXT PRIMARY KEY, project TEXT, directory TEXT, title TEXT,
520
+ agent TEXT, provider TEXT, model TEXT, variant TEXT, parent_id TEXT, depth INTEGER,
521
+ created INTEGER, updated INTEGER,
522
+ t_input INTEGER, t_output INTEGER, t_reasoning INTEGER, t_cache_read INTEGER, t_cache_write INTEGER,
523
+ cost REAL, credits_micro INTEGER DEFAULT 0, turns INTEGER, tool_calls INTEGER, tool_errors INTEGER,
524
+ compactions INTEGER, retries INTEGER, loops INTEGER,
525
+ additions INTEGER, deletions INTEGER, files_changed INTEGER,
526
+ peak_fill REAL, avg_fill REAL, eff_score REAL, tokens_per_line REAL,
527
+ first_prompt TEXT, duration_ms INTEGER, dominant_model TEXT, output_rate REAL
528
+ );
529
+ CREATE INDEX IF NOT EXISTS sessions_project ON sessions(project, created);
530
+ CREATE INDEX IF NOT EXISTS sessions_parent ON sessions(parent_id);
531
+
532
+ -- ECG substrate: token flow per (project, 5-min bucket). tokens_gen is the
533
+ -- generation signal (input+output+reasoning); cache_read is kept separate because
534
+ -- it is ~14x larger and would otherwise drown the waveform.
535
+ CREATE TABLE IF NOT EXISTS flow (
536
+ project TEXT, bucket INTEGER,
537
+ tokens_gen INTEGER, tokens_cache_read INTEGER, cost REAL,
538
+ sessions INTEGER, dominant_model TEXT, peak_fill REAL,
539
+ PRIMARY KEY (project, bucket)
540
+ );
541
+ CREATE INDEX IF NOT EXISTS flow_time ON flow(bucket);
542
+ CREATE INDEX IF NOT EXISTS flow_project ON flow(project, bucket);
543
+
544
+ -- Circadian spectrogram: kept per local day so the time scrubber can re-slice it.
545
+ CREATE TABLE IF NOT EXISTS heat (
546
+ project TEXT, day INTEGER, hour INTEGER,
547
+ tokens_gen INTEGER, tokens_cache_read INTEGER, cost REAL, turns INTEGER,
548
+ PRIMARY KEY (project, day, hour)
549
+ );
550
+ CREATE INDEX IF NOT EXISTS heat_idx ON heat(day, hour);
551
+
552
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
553
+ `
554
+ );
555
+ function initSchema(db, opts = {}) {
556
+ if (opts.drop) {
557
+ for (const t of FACT_TABLES) db.exec(`DROP TABLE IF EXISTS ${t}`);
558
+ }
559
+ db.exec(SCHEMA);
560
+ ensureIndexes(db);
561
+ ensureRollups(db);
562
+ setMeta(db, "schemaSig", SCHEMA_SIG);
563
+ }
564
+ var INDEXES = (
565
+ /* sql */
566
+ `
567
+ CREATE INDEX IF NOT EXISTS tools_turn ON tools(turn_id);
568
+ `
569
+ );
570
+ function ensureIndexes(db) {
571
+ const built = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='tools'").get();
572
+ if (!built) return;
573
+ db.exec(INDEXES);
574
+ }
575
+ var SCHEMA_SIG = fingerprint(SCHEMA);
576
+ function fingerprint(text) {
577
+ let h = 2166136261;
578
+ for (let i = 0; i < text.length; i++) {
579
+ h ^= text.charCodeAt(i);
580
+ h = Math.imul(h, 16777619);
581
+ }
582
+ return (h >>> 0).toString(36);
583
+ }
584
+ function setMeta(db, key, value) {
585
+ db.prepare("INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
586
+ }
587
+ function getMeta(db, key) {
588
+ const r = db.prepare("SELECT value FROM meta WHERE key=?").get(key);
589
+ return r?.value ?? null;
590
+ }
591
+ function getMetaNumber(db, key) {
592
+ return Number(getMeta(db, key) ?? 0) || 0;
593
+ }
594
+
595
+ // src/metrics/buckets.ts
596
+ var GRANULARITIES = ["5m", "hour", "day", "week", "month", "quarter", "year"];
597
+ var MAX_COLS = 200;
598
+ var MINUTE = 6e4;
599
+ var HOUR_MS = 36e5;
600
+ var DAY_MS = 864e5;
601
+ var WEEK_MS = 7 * DAY_MS;
602
+ function localDay(ts) {
603
+ const d = new Date(ts);
604
+ d.setHours(0, 0, 0, 0);
605
+ return d.getTime();
606
+ }
607
+ function localWeek(ts) {
608
+ const d = new Date(localDay(ts));
609
+ d.setDate(d.getDate() - (d.getDay() + 6) % 7);
610
+ return d.getTime();
611
+ }
612
+ function bucketStart(ts, gran) {
613
+ switch (gran) {
614
+ case "5m":
615
+ return Math.floor(ts / (5 * MINUTE)) * 5 * MINUTE;
616
+ case "hour":
617
+ return Math.floor(ts / HOUR_MS) * HOUR_MS;
618
+ case "day":
619
+ return localDay(ts);
620
+ case "week":
621
+ return localWeek(ts);
622
+ case "month": {
623
+ const d = new Date(ts);
624
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
625
+ }
626
+ case "quarter": {
627
+ const d = new Date(ts);
628
+ return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1).getTime();
629
+ }
630
+ case "year": {
631
+ const d = new Date(ts);
632
+ return new Date(d.getFullYear(), 0, 1).getTime();
633
+ }
634
+ }
635
+ }
636
+ function nextBucket(ts, gran) {
637
+ switch (gran) {
638
+ case "5m":
639
+ return ts + 5 * MINUTE;
640
+ case "hour":
641
+ return ts + HOUR_MS;
642
+ case "day":
643
+ return ts + DAY_MS;
644
+ case "week":
645
+ return ts + WEEK_MS;
646
+ case "month": {
647
+ const d = new Date(ts);
648
+ return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
649
+ }
650
+ case "quarter": {
651
+ const d = new Date(ts);
652
+ return new Date(d.getFullYear(), d.getMonth() + 3, 1).getTime();
653
+ }
654
+ case "year": {
655
+ const d = new Date(ts);
656
+ return new Date(d.getFullYear() + 1, 0, 1).getTime();
657
+ }
658
+ }
659
+ }
660
+ var monthsBetween = (a, b) => {
661
+ const da = new Date(a);
662
+ const db = new Date(b);
663
+ return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth());
664
+ };
665
+ function bucketCount(from, to, gran) {
666
+ if (!(to >= from)) return 1;
667
+ const first = bucketStart(from, gran);
668
+ switch (gran) {
669
+ case "5m":
670
+ return Math.floor((to - first) / (5 * MINUTE)) + 1;
671
+ case "hour":
672
+ return Math.floor((to - first) / HOUR_MS) + 1;
673
+ case "day":
674
+ return Math.floor((to - first) / DAY_MS) + 1;
675
+ case "week":
676
+ return Math.round((localWeek(to) - first) / WEEK_MS) + 1;
677
+ case "month":
678
+ return monthsBetween(first, to) + 1;
679
+ case "quarter":
680
+ return Math.floor(monthsBetween(first, to) / 3) + 1;
681
+ case "year":
682
+ return new Date(to).getFullYear() - new Date(first).getFullYear() + 1;
683
+ }
684
+ }
685
+ function bucketStarts(from, to, gran) {
686
+ const count = Math.min(bucketCount(from, to, gran), MAX_COLS * 4);
687
+ const out = [];
688
+ let ts = bucketStart(from, gran);
689
+ for (let i = 0; i < count; i++) {
690
+ out.push(ts);
691
+ ts = nextBucket(ts, gran);
692
+ }
693
+ return out;
694
+ }
695
+ function bucketKeySql(gran) {
696
+ switch (gran) {
697
+ case "5m":
698
+ return "t.bucket";
699
+ case "hour":
700
+ return "(t.bucket - (t.bucket % 3600000))";
701
+ case "day":
702
+ return "t.day";
703
+ // `t.day` is a local midnight, so its weekday has to be read in local time too —
704
+ // reading it as UTC gives the previous day's weekday west of Greenwich and lands the
705
+ // whole week a day early.
706
+ case "week":
707
+ return "(t.day - ((CAST(strftime('%w', t.day / 1000, 'unixepoch', 'localtime') AS INTEGER) + 6) % 7) * 86400000)";
708
+ case "month":
709
+ return "strftime('%Y-%m', t.created / 1000, 'unixepoch', 'localtime')";
710
+ case "quarter":
711
+ return "strftime('%Y', t.created / 1000, 'unixepoch', 'localtime') || '-Q' || CAST((CAST(strftime('%m', t.created / 1000, 'unixepoch', 'localtime') AS INTEGER) - 1) / 3 AS INTEGER)";
712
+ case "year":
713
+ return "strftime('%Y', t.created / 1000, 'unixepoch', 'localtime')";
714
+ }
715
+ }
716
+ function calendarKey(ts, gran) {
717
+ const d = new Date(ts);
718
+ const year = String(d.getFullYear()).padStart(4, "0");
719
+ if (gran === "year") return year;
720
+ if (gran === "quarter") return `${year}-Q${Math.floor(d.getMonth() / 3)}`;
721
+ return `${year}-${String(d.getMonth() + 1).padStart(2, "0")}`;
722
+ }
723
+ function bucketIndexer(gran, starts) {
724
+ const last = Math.max(0, starts.length - 1);
725
+ if (gran === "month" || gran === "quarter" || gran === "year") {
726
+ const table = /* @__PURE__ */ new Map();
727
+ starts.forEach((ts, i) => table.set(calendarKey(ts, gran), i));
728
+ return (key) => table.get(String(key)) ?? -1;
729
+ }
730
+ const step = gran === "5m" ? 5 * MINUTE : gran === "hour" ? HOUR_MS : gran === "day" ? DAY_MS : WEEK_MS;
731
+ const first = starts[0] ?? 0;
732
+ return (key) => {
733
+ const i = Math.floor((Number(key) - first) / step);
734
+ return i < 0 ? 0 : i > last ? last : i;
735
+ };
736
+ }
737
+ function pickGranularity(from, to, requested = null) {
738
+ const span = Math.max(0, to - from);
739
+ const natural = span <= 3 * DAY_MS ? "hour" : span <= 180 * DAY_MS ? "day" : "week";
740
+ let unit = requested ?? natural;
741
+ while (bucketCount(from, to, unit) > MAX_COLS) {
742
+ const next = GRANULARITIES[GRANULARITIES.indexOf(unit) + 1];
743
+ if (!next) break;
744
+ unit = next;
745
+ }
746
+ return unit;
747
+ }
748
+ function isGranularity(value) {
749
+ return typeof value === "string" && GRANULARITIES.includes(value);
750
+ }
751
+
752
+ // src/metrics/analytics.ts
753
+ var ZERO = {
754
+ input: 0,
755
+ output: 0,
756
+ reasoning: 0,
757
+ cacheRead: 0,
758
+ cacheWrite: 0,
759
+ total: 0,
760
+ cost: 0,
761
+ turns: 0,
762
+ tools: 0,
763
+ errors: 0,
764
+ sessions: 0,
765
+ additions: 0,
766
+ deletions: 0,
767
+ writeLines: 0
768
+ };
769
+ var num = (v) => {
770
+ const n = Number(v ?? 0);
771
+ return Number.isFinite(n) ? n : 0;
772
+ };
773
+ function turnWhere(slice) {
774
+ const parts = ["t.role = 'assistant'", "t.created >= ?", "t.created < ?"];
775
+ const args = [slice.from, slice.to];
776
+ if (slice.project) {
777
+ parts.push("COALESCE(t.project,'\u2014') = ?");
778
+ args.push(slice.project);
779
+ }
780
+ if (slice.model) {
781
+ parts.push("COALESCE(t.model,'unknown') = ?");
782
+ args.push(slice.model);
783
+ }
784
+ if (slice.agent) {
785
+ parts.push("COALESCE(t.agent,'main') = ?");
786
+ args.push(slice.agent);
787
+ }
788
+ return { sql: parts.join(" AND "), args };
789
+ }
790
+ function granularityOf(db, slice) {
791
+ const row = db.prepare("SELECT MIN(created) a FROM turns WHERE role='assistant'").get();
792
+ const dataFrom = num(row?.a) || slice.to;
793
+ const from = slice.from > 0 ? slice.from : dataFrom;
794
+ const requested = slice.gran ?? null;
795
+ return { gran: pickGranularity(from, slice.to, requested), from, requested };
796
+ }
797
+ function resolveSlice(db, slice) {
798
+ const { gran, from } = granularityOf(db, slice);
799
+ return { slice: { ...slice, from }, gran };
800
+ }
801
+ function totalsOf(db, slice) {
802
+ const span = rollSpan(db, slice);
803
+ const src = span ? cellSource(slice, span) : rawCellSource(slice);
804
+ const r = db.prepare(
805
+ `SELECT SUM(input) i, SUM(output) o, SUM(reasoning) r,
806
+ SUM(cache_read) cr, SUM(cache_write) cw, SUM(cost) cost,
807
+ SUM(turns) calls, SUM(tools) tools, SUM(tool_errors) errors,
808
+ COUNT(DISTINCT session_id) sessions,
809
+ SUM(additions) adds, SUM(deletions) dels, SUM(write_lines) writes
810
+ FROM (${src.sql})`
811
+ ).get(...src.args);
812
+ const input = num(r?.i);
813
+ const output = num(r?.o);
814
+ const cacheRead = num(r?.cr);
815
+ const cacheWrite = num(r?.cw);
816
+ return {
817
+ input,
818
+ output,
819
+ reasoning: num(r?.r),
820
+ cacheRead,
821
+ cacheWrite,
822
+ total: input + output + cacheRead + cacheWrite,
823
+ cost: num(r?.cost),
824
+ turns: num(r?.calls),
825
+ tools: num(r?.tools),
826
+ errors: num(r?.errors),
827
+ sessions: num(r?.sessions),
828
+ additions: num(r?.adds),
829
+ deletions: num(r?.dels),
830
+ writeLines: num(r?.writes)
831
+ };
832
+ }
833
+ function percentile(sorted, p) {
834
+ if (sorted.length === 0) return 0;
835
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * p)));
836
+ return sorted[idx];
837
+ }
838
+ function efficiencyOf(db, slice, totals) {
839
+ const w = turnWhere(slice);
840
+ const rows = db.prepare(
841
+ `WITH x AS MATERIALIZED (
842
+ SELECT COALESCE(t.dur_ms,0) dur, COALESCE(t.output,0) out,
843
+ COALESCE(t.fill,0) fill, COALESCE(t.finish,'') finish
844
+ FROM turns t WHERE ${w.sql}
845
+ )
846
+ SELECT 'dur' kind, ${ladder.ms.key("dur")} bin, COUNT(*) n FROM x WHERE dur > 0 GROUP BY 2
847
+ UNION ALL
848
+ SELECT 'rate', ${ladder.rate.key("out * 1000.0 / dur")}, COUNT(*) FROM x WHERE dur > 0 AND out > 0 GROUP BY 2
849
+ UNION ALL
850
+ SELECT 'fill', ${ladder.share.key("fill")}, COUNT(*) FROM x WHERE fill > 0 GROUP BY 2
851
+ UNION ALL
852
+ SELECT 'durmax', 0, MAX(dur) FROM x
853
+ UNION ALL
854
+ SELECT 'peak', 0, MAX(fill) FROM x
855
+ UNION ALL
856
+ SELECT 'stopped', 0, SUM(CASE WHEN finish <> '' AND finish <> 'stop' THEN 1 ELSE 0 END) FROM x`
857
+ ).all(...w.args);
858
+ const channel = (kind) => histogramOf(rows.filter((r) => String(r.kind) === kind));
859
+ const scalar = (kind) => num(rows.find((r) => String(r.kind) === kind)?.n);
860
+ const durs = channel("dur");
861
+ const rates = channel("rate");
862
+ const fills = channel("fill");
863
+ const durMax = scalar("durmax");
864
+ const peak = scalar("peak");
865
+ return {
866
+ outputRate: histMedian(rates, ladder.rate),
867
+ turnMs: {
868
+ p50: quantile(durs, 0.5, ladder.ms, durMax),
869
+ p90: quantile(durs, 0.9, ladder.ms, durMax),
870
+ p99: quantile(durs, 0.99, ladder.ms, durMax)
871
+ },
872
+ cacheHit: totals.cacheRead + totals.input > 0 ? totals.cacheRead / (totals.cacheRead + totals.input) : 0,
873
+ fill: {
874
+ p50: quantile(fills, 0.5, ladder.share, peak),
875
+ p90: quantile(fills, 0.9, ladder.share, peak),
876
+ max: peak
877
+ },
878
+ stopped: scalar("stopped")
879
+ };
880
+ }
881
+ var MODEL_STACK = 8;
882
+ function seriesOf(db, slice, gran) {
883
+ const span = gran === "5m" || gran === "hour" ? null : rollSpan(db, slice);
884
+ const src = span ? cellSource(slice, span) : rawCellSource(slice);
885
+ const hours = span ? hourSource(slice, span) : rawHourSource(slice);
886
+ const starts = bucketStarts(slice.from, slice.to, gran);
887
+ const indexOf = bucketIndexer(gran, starts);
888
+ const key = bucketKeySql(gran);
889
+ const count = starts.length;
890
+ const cells = db.prepare(
891
+ `SELECT ${key} k, model,
892
+ SUM(input) i, SUM(output) o, SUM(reasoning) r,
893
+ SUM(cache_read) cr, SUM(cache_write) cw, SUM(cost) cost,
894
+ SUM(turns) calls, SUM(tools) tools, SUM(tool_errors) errors,
895
+ SUM(additions) adds, SUM(deletions) dels, SUM(write_lines) writes
896
+ FROM (${src.sql}) t
897
+ GROUP BY k, model`
898
+ ).all(...src.args);
899
+ const sessionRows = db.prepare(`SELECT ${key} k, COUNT(DISTINCT session_id) n FROM (${src.sql}) t GROUP BY k`).all(...src.args);
900
+ const heatRows = db.prepare(hours.sql).all(...hours.args);
901
+ const buckets = starts.map((ts) => ({ ts, ...ZERO }));
902
+ const modelTotals = /* @__PURE__ */ new Map();
903
+ const perModel = /* @__PURE__ */ new Map();
904
+ for (const c of cells) {
905
+ const i = indexOf(c.k);
906
+ if (i < 0) continue;
907
+ const b = buckets[i];
908
+ const input = num(c.i);
909
+ const output = num(c.o);
910
+ const cacheRead = num(c.cr);
911
+ const cacheWrite = num(c.cw);
912
+ const total = input + output + cacheRead + cacheWrite;
913
+ b.input += input;
914
+ b.output += output;
915
+ b.reasoning += num(c.r);
916
+ b.cacheRead += cacheRead;
917
+ b.cacheWrite += cacheWrite;
918
+ b.total += total;
919
+ b.cost += num(c.cost);
920
+ b.turns += num(c.calls);
921
+ b.tools += num(c.tools);
922
+ b.errors += num(c.errors);
923
+ b.additions += num(c.adds);
924
+ b.deletions += num(c.dels);
925
+ b.writeLines += num(c.writes);
926
+ const row = perModel.get(c.model) ?? new Array(count).fill(0);
927
+ row[i] += total;
928
+ perModel.set(c.model, row);
929
+ modelTotals.set(c.model, (modelTotals.get(c.model) ?? 0) + total);
930
+ }
931
+ for (const s of sessionRows) {
932
+ const i = indexOf(s.k);
933
+ if (i >= 0) buckets[i].sessions = num(s.n);
934
+ }
935
+ const heat = Array.from({ length: 7 }, () => new Array(24).fill(0));
936
+ const hourly = new Array(24).fill(0);
937
+ for (const r of heatRows) {
938
+ const hour = Math.max(0, Math.min(23, num(r.hour)));
939
+ const weekday = (new Date(num(r.day)).getDay() + 6) % 7;
940
+ heat[weekday][hour] += num(r.n);
941
+ hourly[hour] += num(r.n);
942
+ }
943
+ const ranked = [...modelTotals.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m);
944
+ const models = ranked.slice(0, MODEL_STACK);
945
+ const rest = ranked.slice(MODEL_STACK);
946
+ const modelTokens = models.map((m) => perModel.get(m) ?? new Array(count).fill(0));
947
+ if (rest.length) {
948
+ const other = new Array(count).fill(0);
949
+ for (const m of rest) {
950
+ const row = perModel.get(m);
951
+ for (let i = 0; i < count; i++) other[i] += row[i];
952
+ }
953
+ models.push("other");
954
+ modelTokens.push(other);
955
+ }
956
+ return { gran, buckets, models, modelTokens, heat, hourly };
957
+ }
958
+ function modelsOf(db, slice) {
959
+ const span = rollSpan(db, slice);
960
+ const src = span ? cellSource(slice, span) : rawCellSource(slice);
961
+ const rows = db.prepare(
962
+ `SELECT model id, SUM(turns) calls,
963
+ SUM(input+output+cache_read+cache_write) tokens,
964
+ SUM(input) input, SUM(output) output, SUM(cost) cost,
965
+ COUNT(DISTINCT session_id) sessions
966
+ FROM (${src.sql}) t GROUP BY model ORDER BY tokens DESC`
967
+ ).all(...src.args);
968
+ return rows.map((r) => ({
969
+ id: String(r.id),
970
+ calls: num(r.calls),
971
+ tokens: num(r.tokens),
972
+ input: num(r.input),
973
+ output: num(r.output),
974
+ cost: num(r.cost),
975
+ sessions: num(r.sessions)
976
+ }));
977
+ }
978
+ function projectsOf(db, slice) {
979
+ const span = rollSpan(db, slice);
980
+ const src = span ? cellSource(slice, span) : rawCellSource(slice);
981
+ const rows = db.prepare(
982
+ `SELECT project, COUNT(DISTINCT session_id) sessions, SUM(turns) turns,
983
+ SUM(input+output+cache_read+cache_write) tokens, SUM(cost) cost,
984
+ SUM(tools) tools, SUM(tool_errors) errors
985
+ FROM (${src.sql}) t GROUP BY project ORDER BY tokens DESC`
986
+ ).all(...src.args);
987
+ return rows.map((r) => ({
988
+ project: String(r.project),
989
+ sessions: num(r.sessions),
990
+ turns: num(r.turns),
991
+ tokens: num(r.tokens),
992
+ cost: num(r.cost),
993
+ tools: num(r.tools),
994
+ errors: num(r.errors)
995
+ }));
996
+ }
997
+ function agentsOf(db, slice) {
998
+ const span = rollSpan(db, slice);
999
+ const src = span ? cellSource(slice, span) : rawCellSource(slice);
1000
+ const rows = db.prepare(
1001
+ `SELECT agent, SUM(turns) turns,
1002
+ SUM(input+output+cache_read+cache_write) tokens, SUM(cost) cost,
1003
+ COUNT(DISTINCT session_id) sessions,
1004
+ SUM(tools) tools, SUM(tool_errors) errors
1005
+ FROM (${src.sql}) t GROUP BY agent ORDER BY tokens DESC`
1006
+ ).all(...src.args);
1007
+ return rows.map((r) => ({
1008
+ agent: String(r.agent),
1009
+ turns: num(r.turns),
1010
+ tokens: num(r.tokens),
1011
+ cost: num(r.cost),
1012
+ sessions: num(r.sessions),
1013
+ tools: num(r.tools),
1014
+ errors: num(r.errors)
1015
+ }));
1016
+ }
1017
+ function toolsOf(db, slice) {
1018
+ const span = rollSpan(db, slice);
1019
+ const sums = span ? toolSource(slice, span) : rawToolSource(slice);
1020
+ const bins = span ? toolBins(slice, span) : rawToolBins(slice);
1021
+ const people = db.prepare(
1022
+ `SELECT name, SUM(calls) calls, SUM(errors) errors, SUM(ms) ms, MAX(mx) maxMs,
1023
+ COUNT(DISTINCT session_id) sessions, COUNT(DISTINCT agent) agents
1024
+ FROM (${sums.sql}) GROUP BY name`
1025
+ ).all(...sums.args);
1026
+ const hist = db.prepare(bins.sql).all(...bins.args);
1027
+ const acc = /* @__PURE__ */ new Map();
1028
+ for (const r of people) {
1029
+ const name = String(r.name);
1030
+ acc.set(name, {
1031
+ name,
1032
+ calls: num(r.calls),
1033
+ errors: num(r.errors),
1034
+ totalMs: num(r.ms),
1035
+ maxMs: num(r.maxMs),
1036
+ sessions: num(r.sessions),
1037
+ agents: num(r.agents),
1038
+ hist: /* @__PURE__ */ new Map()
1039
+ });
1040
+ }
1041
+ for (const r of hist) {
1042
+ const a = acc.get(String(r.name));
1043
+ if (!a) continue;
1044
+ const bin = Number(r.bin) || 0;
1045
+ a.hist.set(bin, (a.hist.get(bin) ?? 0) + num(r.n));
1046
+ }
1047
+ return [...acc.values()].map((a) => ({
1048
+ name: a.name,
1049
+ calls: a.calls,
1050
+ errors: a.errors,
1051
+ totalMs: a.totalMs,
1052
+ avgMs: a.calls > 0 ? a.totalMs / a.calls : 0,
1053
+ p50Ms: quantile(a.hist, 0.5, ladder.ms, a.maxMs),
1054
+ p95Ms: quantile(a.hist, 0.95, ladder.ms, a.maxMs),
1055
+ maxMs: a.maxMs,
1056
+ sessions: a.sessions,
1057
+ agents: a.agents
1058
+ })).sort((x, y) => y.calls - x.calls);
1059
+ }
1060
+ function sessionAgg(db, slice) {
1061
+ const span = rollSpan(db, slice);
1062
+ const meta = `SELECT s.id id, s.parent_id parent, COALESCE(s.title,'') title, COALESCE(s.project,'\u2014') project,
1063
+ COALESCE(s.agent,'main') agent, COALESCE(s.model,'') model,
1064
+ COALESCE(s.created,0) created, COALESCE(s.updated,0) updated,
1065
+ COALESCE(s.first_prompt,'') prompt, COALESCE(s.peak_fill,0) peak,
1066
+ a.i i, a.o o, a.cr cr, a.cw cw, a.tokens tokens, a.cost cost, a.turns turns,
1067
+ a.tools tools, a.errors errors, a.span span, a.win win, a.ctx ctx
1068
+ FROM (`;
1069
+ if (span) {
1070
+ const src = cellSource(slice, span);
1071
+ return {
1072
+ args: src.args,
1073
+ sql: `${meta}SELECT session_id id, SUM(input) i, SUM(output) o, SUM(cache_read) cr, SUM(cache_write) cw,
1074
+ SUM(input+output+cache_read+cache_write) tokens, SUM(cost) cost, SUM(turns) turns,
1075
+ SUM(tools) tools, SUM(tool_errors) errors,
1076
+ MAX(ended) - MIN(created) span, MAX(win) win,
1077
+ SUM(input+cache_read+cache_write) * 1.0 /
1078
+ MAX(CASE WHEN win > 0 THEN win ELSE 128000 END) ctx
1079
+ FROM (${src.sql}) GROUP BY session_id) a
1080
+ JOIN sessions s ON s.id = a.id`
1081
+ };
1082
+ }
1083
+ const w = turnWhere(slice);
1084
+ return {
1085
+ args: w.args,
1086
+ sql: `${meta}SELECT t.session_id id, SUM(t.input) i, SUM(t.output) o,
1087
+ SUM(t.cache_read) cr, SUM(t.cache_write) cw,
1088
+ SUM(t.input+t.output+t.cache_read+t.cache_write) tokens,
1089
+ SUM(t.cost) cost, COUNT(*) turns,
1090
+ SUM(COALESCE(t.tool_calls,0)) tools, SUM(COALESCE(t.tool_errors,0)) errors,
1091
+ MAX(COALESCE(t.completed,t.created)) - MIN(t.created) span,
1092
+ MAX(COALESCE(t.context_window,0)) win,
1093
+ SUM(t.input+t.cache_read+t.cache_write) * 1.0 /
1094
+ MAX(CASE WHEN COALESCE(t.context_window,0) > 0 THEN t.context_window ELSE 128000 END) ctx
1095
+ FROM turns t WHERE ${w.sql} GROUP BY t.session_id) a
1096
+ JOIN sessions s ON s.id = a.id`
1097
+ };
1098
+ }
1099
+ function toSessionRows(rows) {
1100
+ const ids = new Set(rows.map((r) => String(r.id)));
1101
+ const childCount = /* @__PURE__ */ new Map();
1102
+ for (const r of rows) {
1103
+ const p = r.parent == null ? "" : String(r.parent);
1104
+ if (p && ids.has(p)) childCount.set(p, (childCount.get(p) ?? 0) + 1);
1105
+ }
1106
+ return rows.map((r) => {
1107
+ const id = String(r.id);
1108
+ const spanMs = num(r.span);
1109
+ const out = num(r.o);
1110
+ const input = num(r.i);
1111
+ const cacheRead = num(r.cr);
1112
+ const cacheWrite = num(r.cw);
1113
+ const window = num(r.win) || 128e3;
1114
+ const model = r.model == null || String(r.model) === "" ? null : String(r.model);
1115
+ return {
1116
+ id,
1117
+ title: String(r.title) || null,
1118
+ project: String(r.project),
1119
+ agent: r.agent == null ? null : String(r.agent),
1120
+ model,
1121
+ parentId: r.parent == null ? null : String(r.parent),
1122
+ created: num(r.created),
1123
+ updated: num(r.updated),
1124
+ tokens: num(r.tokens),
1125
+ input,
1126
+ output: out,
1127
+ cacheRead,
1128
+ cacheWrite,
1129
+ cost: num(r.cost),
1130
+ turns: num(r.turns),
1131
+ tools: num(r.tools),
1132
+ errors: num(r.errors),
1133
+ spanMs,
1134
+ outputRate: spanMs > 0 && out > 0 ? out / spanMs * 1e3 : 0,
1135
+ // Same context convention as the rest of the project: what the provider had to
1136
+ // re-read plus what it wrote, over the model's window.
1137
+ contextPct: (input + cacheRead + cacheWrite) / window,
1138
+ peakFill: num(r.peak),
1139
+ firstPrompt: String(r.prompt) || null,
1140
+ childCount: childCount.get(id) ?? 0
1141
+ };
1142
+ });
1143
+ }
1144
+ var SESSION_SAMPLE = 200;
1145
+ var MAX_PAGE_ROOTS = 500;
1146
+ function sessionsOf(db, slice) {
1147
+ const a = sessionAgg(db, slice);
1148
+ const rows = db.prepare(`${a.sql} ORDER BY tokens DESC, id DESC LIMIT ${SESSION_SAMPLE}`).all(...a.args);
1149
+ return toSessionRows(rows);
1150
+ }
1151
+ var SESSION_ORDER_COLUMN = {
1152
+ updated: "updated",
1153
+ created: "created",
1154
+ tokens: "tokens",
1155
+ cost: "cost",
1156
+ turns: "turns",
1157
+ tools: "tools",
1158
+ errors: "errors",
1159
+ span: "span",
1160
+ ctx: "ctx",
1161
+ title: "title",
1162
+ project: "project",
1163
+ model: "model",
1164
+ agent: "agent"
1165
+ };
1166
+ var SESSION_ORDERS = Object.keys(SESSION_ORDER_COLUMN);
1167
+ var likePattern = (q) => `%${q.replace(/[\%_]/g, (c) => `\${c}`)}%`;
1168
+ var encodeCursor = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
1169
+ var decodeCursor = (raw) => {
1170
+ if (!raw) return null;
1171
+ try {
1172
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
1173
+ if (parsed.id == null) return null;
1174
+ return { v: parsed.v ?? 0, id: String(parsed.id) };
1175
+ } catch {
1176
+ return null;
1177
+ }
1178
+ };
1179
+ function sessionsPage(db, slice, opts = {}) {
1180
+ const agg = sessionAgg(db, slice);
1181
+ const q = (opts.q ?? "").trim();
1182
+ const order = SESSION_ORDER_COLUMN[opts.order ?? "updated"] ?? "updated";
1183
+ const asc = opts.dir === "asc";
1184
+ const limit = Math.min(MAX_PAGE_ROOTS, Math.max(1, Math.round(opts.limit ?? MAX_PAGE_ROOTS)));
1185
+ const hit = q ? `SELECT * FROM agg WHERE title LIKE ? ESCAPE '\\' OR project LIKE ? ESCAPE '\\'
1186
+ OR model LIKE ? ESCAPE '\\' OR agent LIKE ? ESCAPE '\\' OR prompt LIKE ? ESCAPE '\\'` : "SELECT * FROM agg";
1187
+ const hitArgs = q ? Array(5).fill(likePattern(q)) : [];
1188
+ const args = [...agg.args, ...hitArgs];
1189
+ const cursor = decodeCursor(opts.cursor);
1190
+ const cursorSql = cursor ? `WHERE (${order}, id) ${asc ? ">" : "<"} (?, ?)` : "";
1191
+ const cursorArgs = cursor ? [cursor.v, cursor.id] : [];
1192
+ const direction = asc ? "ASC" : "DESC";
1193
+ const prefix = `WITH RECURSIVE
1194
+ agg AS MATERIALIZED (${agg.sql}),
1195
+ hit AS (${hit}),
1196
+ keep(id) AS (
1197
+ SELECT id FROM hit
1198
+ UNION
1199
+ SELECT a.parent FROM agg a JOIN keep k ON a.id = k.id
1200
+ WHERE a.parent IS NOT NULL AND a.parent IN (SELECT id FROM agg)
1201
+ ),
1202
+ roots AS (
1203
+ SELECT * FROM agg WHERE id IN (SELECT id FROM keep)
1204
+ AND (parent IS NULL OR parent NOT IN (SELECT id FROM keep))
1205
+ ),
1206
+ page AS (
1207
+ SELECT * FROM roots ${cursorSql} ORDER BY ${order} ${direction}, id ${direction} LIMIT ?
1208
+ ),
1209
+ down(id) AS (
1210
+ SELECT id FROM page
1211
+ UNION
1212
+ SELECT a.id FROM agg a JOIN down d ON a.parent = d.id WHERE a.id IN (SELECT id FROM keep)
1213
+ ),
1214
+ matched AS (SELECT * FROM agg WHERE id IN (SELECT id FROM keep))`;
1215
+ const rows = db.prepare(
1216
+ `${prefix}
1217
+ SELECT a.*, (a.id IN (SELECT id FROM page)) inpage,
1218
+ (SELECT COUNT(*) FROM matched) matching,
1219
+ (SELECT COUNT(*) FROM roots) roots,
1220
+ (SELECT SUM(tokens) FROM matched) m_tokens,
1221
+ (SELECT SUM(cost) FROM matched) m_cost,
1222
+ (SELECT SUM(turns) FROM matched) m_turns,
1223
+ (SELECT SUM(tools) FROM matched) m_tools,
1224
+ (SELECT SUM(errors) FROM matched) m_errors,
1225
+ (SELECT COUNT(*) FROM matched) m_sessions
1226
+ FROM agg a JOIN down d ON d.id = a.id
1227
+ ORDER BY ${order} ${direction}, a.id ${direction}`
1228
+ ).all(...args, ...cursorArgs, limit + 1);
1229
+ const meta = rows[0];
1230
+ const pageRoots = rows.filter((r) => num(r.inpage) === 1);
1231
+ const cut = pageRoots.length > limit ? String(pageRoots[limit].id) : null;
1232
+ let shipped = rows;
1233
+ if (cut) {
1234
+ const drop = /* @__PURE__ */ new Set([cut]);
1235
+ for (let grew = true; grew; ) {
1236
+ grew = false;
1237
+ for (const r of rows) {
1238
+ const id = String(r.id);
1239
+ const parent = r.parent == null ? null : String(r.parent);
1240
+ if (!drop.has(id) && parent !== null && drop.has(parent)) {
1241
+ drop.add(id);
1242
+ grew = true;
1243
+ }
1244
+ }
1245
+ }
1246
+ shipped = rows.filter((r) => !drop.has(String(r.id)));
1247
+ }
1248
+ const kept = pageRoots.slice(0, limit);
1249
+ const last = kept[kept.length - 1];
1250
+ const next = cut && last ? encodeCursor({ v: last[order] ?? 0, id: String(last.id) }) : null;
1251
+ return {
1252
+ rows: toSessionRows(shipped),
1253
+ matching: num(meta?.matching),
1254
+ roots: num(meta?.roots),
1255
+ next,
1256
+ totals: {
1257
+ sessions: num(meta?.m_sessions),
1258
+ tokens: num(meta?.m_tokens),
1259
+ cost: num(meta?.m_cost),
1260
+ turns: num(meta?.m_turns),
1261
+ tools: num(meta?.m_tools),
1262
+ errors: num(meta?.m_errors)
1263
+ }
1264
+ };
1265
+ }
1266
+ function sessionContext(db, slice, id) {
1267
+ const agg = sessionAgg(db, slice);
1268
+ const rows = db.prepare(
1269
+ `WITH RECURSIVE
1270
+ agg AS (${agg.sql}),
1271
+ focus AS (SELECT parent FROM agg WHERE id = ?),
1272
+ up(id) AS (
1273
+ SELECT parent FROM focus WHERE parent IS NOT NULL
1274
+ UNION
1275
+ SELECT a.parent FROM agg a JOIN up ON a.id = up.id WHERE a.parent IS NOT NULL
1276
+ )
1277
+ SELECT a.* FROM agg a
1278
+ WHERE a.id = ? OR a.id IN (SELECT id FROM up)
1279
+ OR a.parent = ? OR a.parent = (SELECT parent FROM focus)
1280
+ ORDER BY updated DESC`
1281
+ ).all(...agg.args, id, id, id);
1282
+ return toSessionRows(rows);
1283
+ }
1284
+ var kpi = (value, prev, comparable) => ({
1285
+ value,
1286
+ prev,
1287
+ delta: !comparable || prev <= 0 ? comparable && value === 0 ? 0 : null : (value - prev) / prev
1288
+ });
1289
+ function dashboard(db, raw) {
1290
+ const { slice, gran } = resolveSlice(db, raw);
1291
+ const span = slice.to - slice.from;
1292
+ const totals = totalsOf(db, slice);
1293
+ const hasPrev = raw.from > 0 && span > 0;
1294
+ const prev = hasPrev ? totalsOf(db, { ...slice, from: slice.from - span, to: slice.from }) : ZERO;
1295
+ return {
1296
+ generatedAt: Date.now(),
1297
+ slice: { ...slice, gran, granRequested: raw.gran ?? null, resolvedFrom: slice.from, spanMs: span },
1298
+ totals,
1299
+ kpis: {
1300
+ tokens: kpi(totals.total, prev.total, hasPrev),
1301
+ cost: kpi(totals.cost, prev.cost, hasPrev),
1302
+ turns: kpi(totals.turns, prev.turns, hasPrev),
1303
+ tools: kpi(totals.tools, prev.tools, hasPrev),
1304
+ errors: kpi(totals.errors, prev.errors, hasPrev),
1305
+ sessions: kpi(totals.sessions, prev.sessions, hasPrev),
1306
+ output: kpi(totals.output, prev.output, hasPrev),
1307
+ additions: kpi(totals.additions, prev.additions, hasPrev),
1308
+ deletions: kpi(totals.deletions, prev.deletions, hasPrev)
1309
+ },
1310
+ efficiency: efficiencyOf(db, slice, totals),
1311
+ series: seriesOf(db, slice, gran),
1312
+ models: modelsOf(db, slice),
1313
+ projects: projectsOf(db, slice),
1314
+ agents: agentsOf(db, slice),
1315
+ tools: toolsOf(db, slice),
1316
+ sessions: sessionsOf(db, slice)
1317
+ };
1318
+ }
1319
+ function errorKey(message) {
1320
+ return message.split("\n")[0].trim().replace(/https?:\/\/\S+/g, "<url>").replace(/(?<![\w.])\/(?:[\w.@+-]+\/)*[\w.@+-]*/g, "<path>").replace(/\b[0-9a-f]{7,}\b/gi, "<hash>").replace(/\d+(?:[.,:]\d+)*/g, "<n>").slice(0, 160);
1321
+ }
1322
+ var LAT_BINS = [
1323
+ ["0\u201310ms", 0, 10],
1324
+ ["10\u201350ms", 10, 50],
1325
+ ["50\u2013200ms", 50, 200],
1326
+ ["200ms\u20131s", 200, 1e3],
1327
+ ["1\u20135s", 1e3, 5e3],
1328
+ ["5\u201330s", 5e3, 3e4],
1329
+ ["30s+", 3e4, Infinity]
1330
+ ];
1331
+ function toolDetail(db, raw, name) {
1332
+ const { slice, gran } = resolveSlice(db, raw);
1333
+ const w = turnWhere(slice);
1334
+ const args = [...w.args, name];
1335
+ const rows = db.prepare(
1336
+ `SELECT tl.name name, COALESCE(tl.dur_ms,0) ms, tl.status status,
1337
+ COALESCE(tl.started,0) started,
1338
+ COALESCE(tl.error,'') error, COALESCE(tl.session_id,'') session,
1339
+ COALESCE(t.agent,'main') agent, COALESCE(t.project,'\u2014') project,
1340
+ COALESCE(tl.additions,0) adds, COALESCE(tl.deletions,0) dels,
1341
+ COALESCE(tl.files,0) files, COALESCE(tl.output_chars,0) chars
1342
+ FROM tools tl JOIN turns t ON t.id = tl.turn_id
1343
+ WHERE ${w.sql} AND tl.name = ?`
1344
+ ).all(...args);
1345
+ const starts = bucketStarts(slice.from, slice.to, gran);
1346
+ const buckets = starts.map((ts) => ({ ts, calls: 0, errors: 0, avgMs: 0, totalMs: 0 }));
1347
+ const place = bucketIndexer(gran, starts);
1348
+ const idx = (ts) => {
1349
+ const start = bucketStart(ts, gran);
1350
+ const i = place(gran === "month" || gran === "quarter" || gran === "year" ? calendarKey(start, gran) : start);
1351
+ return i < 0 ? 0 : i;
1352
+ };
1353
+ const latency = LAT_BINS.map(([label, lo, hi]) => ({ label, count: 0, upperMs: hi }));
1354
+ const agents = /* @__PURE__ */ new Map();
1355
+ const projects = /* @__PURE__ */ new Map();
1356
+ const signatures = /* @__PURE__ */ new Map();
1357
+ const recentErrors = [];
1358
+ let additions = 0;
1359
+ let deletions = 0;
1360
+ let files = 0;
1361
+ let chars = 0;
1362
+ let failures = 0;
1363
+ for (const r of rows) {
1364
+ const ms2 = num(r.ms);
1365
+ const isError = r.status === "error";
1366
+ const b = buckets[idx(num(r.started) || slice.to - 1)];
1367
+ b.calls++;
1368
+ if (isError) b.errors++;
1369
+ b.totalMs += ms2;
1370
+ if (isError) failures++;
1371
+ additions += num(r.adds);
1372
+ deletions += num(r.dels);
1373
+ files += num(r.files);
1374
+ chars += num(r.chars);
1375
+ for (let k = 0; k < LAT_BINS.length; k++) {
1376
+ if (ms2 >= LAT_BINS[k][1] && ms2 < LAT_BINS[k][2]) {
1377
+ latency[k].count++;
1378
+ break;
1379
+ }
1380
+ }
1381
+ const agent = String(r.agent);
1382
+ const a = agents.get(agent) ?? { agent, calls: 0, errors: 0 };
1383
+ a.calls++;
1384
+ if (isError) a.errors++;
1385
+ agents.set(agent, a);
1386
+ const project = String(r.project);
1387
+ const p = projects.get(project) ?? { project, calls: 0, errors: 0 };
1388
+ p.calls++;
1389
+ if (isError) p.errors++;
1390
+ projects.set(project, p);
1391
+ if (isError) {
1392
+ const body = String(r.error ?? "").trim() || "tool error";
1393
+ const key = errorKey(body);
1394
+ const s = signatures.get(key);
1395
+ if (s) s.count++;
1396
+ else signatures.set(key, { message: body.split("\n")[0].slice(0, 160), count: 1, sample: body.slice(0, 400) });
1397
+ }
1398
+ if (isError && String(r.error) && recentErrors.length < 12) {
1399
+ recentErrors.push({ at: num(r.started), session: String(r.session), error: String(r.error).slice(0, 400) });
1400
+ }
1401
+ }
1402
+ for (const b of buckets) b.avgMs = b.calls > 0 ? b.totalMs / b.calls : 0;
1403
+ const calls = rows.length;
1404
+ const totalMs = rows.reduce((a, r) => a + num(r.ms), 0);
1405
+ const durs = rows.map((r) => num(r.ms)).sort((a, b) => a - b);
1406
+ const sessions = new Set(rows.map((r) => String(r.session))).size;
1407
+ return {
1408
+ name,
1409
+ gran,
1410
+ totals: {
1411
+ name,
1412
+ calls,
1413
+ errors: failures,
1414
+ totalMs,
1415
+ avgMs: calls > 0 ? totalMs / calls : 0,
1416
+ p50Ms: percentile(durs, 0.5),
1417
+ p95Ms: percentile(durs, 0.95),
1418
+ maxMs: durs.length ? durs[durs.length - 1] : 0,
1419
+ sessions,
1420
+ agents: agents.size
1421
+ },
1422
+ buckets,
1423
+ latency,
1424
+ agents: [...agents.values()].sort((a, b) => b.calls - a.calls),
1425
+ projects: [...projects.values()].sort((a, b) => b.calls - a.calls).slice(0, 12),
1426
+ signatures: [...signatures.values()].sort((a, b) => b.count - a.count).slice(0, 8),
1427
+ recentErrors,
1428
+ edited: { additions, deletions, files, chars }
1429
+ };
1430
+ }
1431
+ function facets(db) {
1432
+ const projects = db.prepare(
1433
+ `SELECT COALESCE(s.project,'\u2014') project, COUNT(*) sessions,
1434
+ SUM(COALESCE(s.t_input,0)+COALESCE(s.t_output,0)+COALESCE(s.t_cache_read,0)+COALESCE(s.t_cache_write,0)) tokens,
1435
+ SUM(COALESCE(s.cost,0)) cost, MAX(COALESCE(s.updated,0)) updated
1436
+ FROM sessions s GROUP BY COALESCE(s.project,'\u2014') ORDER BY sessions DESC`
1437
+ ).all();
1438
+ const models = db.prepare(
1439
+ `SELECT COALESCE(t.model,'unknown') id, COUNT(*) calls
1440
+ FROM turns t WHERE t.role='assistant'
1441
+ GROUP BY COALESCE(t.model,'unknown') ORDER BY calls DESC`
1442
+ ).all();
1443
+ const catalogue = /* @__PURE__ */ new Map();
1444
+ for (const m of db.prepare("SELECT id, label FROM models").all()) {
1445
+ const full = String(m.id);
1446
+ const bare = full.includes("/") ? full.slice(full.lastIndexOf("/") + 1) : full;
1447
+ const label = m.label == null ? null : String(m.label);
1448
+ if (!label || label === full) {
1449
+ if (!catalogue.has(bare)) catalogue.set(bare, bare);
1450
+ continue;
1451
+ }
1452
+ catalogue.set(bare, label);
1453
+ }
1454
+ const agents = db.prepare(
1455
+ `SELECT COALESCE(agent,'main') agent, COUNT(*) calls FROM turns WHERE role='assistant'
1456
+ GROUP BY COALESCE(agent,'main') ORDER BY calls DESC`
1457
+ ).all();
1458
+ const extent = db.prepare("SELECT MIN(created) a, MAX(created) b FROM turns WHERE role='assistant'").get();
1459
+ const tools = db.prepare("SELECT name, COUNT(*) calls FROM tools GROUP BY name ORDER BY calls DESC").all();
1460
+ return {
1461
+ projects: projects.map((p) => ({
1462
+ project: String(p.project),
1463
+ sessions: num(p.sessions),
1464
+ tokens: num(p.tokens),
1465
+ cost: num(p.cost),
1466
+ updated: num(p.updated)
1467
+ })),
1468
+ models: models.map((m) => ({
1469
+ id: String(m.id),
1470
+ label: catalogue.get(String(m.id)) ?? String(m.id),
1471
+ calls: num(m.calls)
1472
+ })),
1473
+ agents: agents.map((a) => ({ agent: String(a.agent), calls: num(a.calls) })),
1474
+ tools: tools.map((t) => ({ name: String(t.name), calls: num(t.calls) })),
1475
+ extent: { from: num(extent?.a), to: num(extent?.b) || Date.now() },
1476
+ counts: {
1477
+ sessions: num(db.prepare("SELECT COUNT(*) c FROM sessions").get()?.c),
1478
+ turns: num(db.prepare("SELECT COUNT(*) c FROM turns WHERE role='assistant'").get()?.c),
1479
+ tools: num(db.prepare("SELECT COUNT(*) c FROM tools").get()?.c),
1480
+ parts: num(db.prepare("SELECT COUNT(*) c FROM parts").get()?.c)
1481
+ }
1482
+ };
1483
+ }
1484
+
1485
+ // src/api.ts
1486
+ function createApp(deps) {
1487
+ const app2 = express();
1488
+ app2.use(cors({ exposedHeaders: ["x-osm-source"] }));
1489
+ app2.use(express.json());
1490
+ const kindOf = (req) => {
1491
+ const raw = String(req.get("x-osm-source") ?? req.query.src ?? "").trim().toLowerCase();
1492
+ return raw === "qoder" || raw === "opencode" ? raw : SOURCE_KIND;
1493
+ };
1494
+ const storeOf = (req, res) => {
1495
+ const kind = kindOf(req);
1496
+ if (deps.hasStore(kind)) return deps.db(kind);
1497
+ res.status(503).json({ error: "source not materialized", source: kind });
1498
+ return null;
1499
+ };
1500
+ const q = (db, sql, ...args) => db.prepare(sql).all(...args);
1501
+ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
1502
+ const cutoffOf = (req) => {
1503
+ const raw = Number(req.query.from ?? 0);
1504
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
1505
+ };
1506
+ const projectOf = (req) => typeof req.query.project === "string" && req.query.project ? req.query.project : null;
1507
+ const str = (req, key) => {
1508
+ const raw = req.query[key];
1509
+ return typeof raw === "string" && raw ? raw : null;
1510
+ };
1511
+ const sliceOf = (req) => {
1512
+ const rawTo = Number(req.query.to ?? 0);
1513
+ const gran = str(req, "gran");
1514
+ return {
1515
+ from: cutoffOf(req),
1516
+ to: Number.isFinite(rawTo) && rawTo > 0 ? rawTo : Date.now() + 1,
1517
+ project: projectOf(req),
1518
+ model: str(req, "model"),
1519
+ agent: str(req, "agent"),
1520
+ // An unknown unit is ignored rather than rejected: the axis then picks for the span.
1521
+ gran: isGranularity(gran) ? gran : null
1522
+ };
1523
+ };
1524
+ app2.get("/api/status", (req, res) => {
1525
+ const kind = kindOf(req);
1526
+ if (!deps.hasStore(kind)) {
1527
+ return res.json({
1528
+ ready: false,
1529
+ sourceDb: sourceProbe(kind),
1530
+ sourceSchema: null,
1531
+ sourceKind: kind,
1532
+ unit: null,
1533
+ tokenClasses: false,
1534
+ sourceMaxTime: 0,
1535
+ materializedAt: 0,
1536
+ mode: null,
1537
+ bucketMs: BUCKET_MS,
1538
+ extent: { from: 0, to: Date.now() },
1539
+ counts: { sessions: 0, turns: 0, tools: 0, parts: 0, events: 0, models: 0 }
1540
+ });
1541
+ }
1542
+ const db = deps.db(kind);
1543
+ const count = (t) => one(db, `SELECT COUNT(*) c FROM ${t}`)?.c ?? 0;
1544
+ const extent = one(
1545
+ db,
1546
+ `SELECT MIN(created) AS from_ts, MAX(COALESCE(updated, created)) AS to_ts FROM sessions`
1547
+ ) ?? { from_ts: null, to_ts: null };
1548
+ const tokenClasses = !!one(
1549
+ db,
1550
+ `SELECT 1 AS hit FROM turns
1551
+ WHERE role = 'assistant'
1552
+ AND (output > 0 OR cache_read > 0 OR cache_write > 0 OR reasoning > 0)
1553
+ LIMIT 1`
1554
+ );
1555
+ res.json({
1556
+ ready: true,
1557
+ sourceDb: getMeta(db, "sourceDb"),
1558
+ sourceSchema: getMeta(db, "sourceSchema"),
1559
+ sourceKind: getMeta(db, "sourceKind") ?? kind,
1560
+ unit: getMeta(db, "unit") ?? "usd",
1561
+ tokenClasses,
1562
+ sourceMaxTime: getMetaNumber(db, "sourceMaxTime"),
1563
+ materializedAt: getMetaNumber(db, "materializedAt"),
1564
+ mode: getMeta(db, "mode"),
1565
+ bucketMs: BUCKET_MS,
1566
+ extent: { from: Number(extent.from_ts ?? 0), to: Number(extent.to_ts ?? Date.now()) },
1567
+ counts: {
1568
+ sessions: count("sessions"),
1569
+ turns: count("turns"),
1570
+ tools: count("tools"),
1571
+ parts: count("parts"),
1572
+ events: count("events"),
1573
+ models: count("models")
1574
+ }
1575
+ });
1576
+ });
1577
+ app2.get("/api/sources", (_req, res) => {
1578
+ const describe = (kind) => {
1579
+ const available = sourceAvailable(kind);
1580
+ const ready2 = deps.hasStore(kind);
1581
+ const base = {
1582
+ kind,
1583
+ label: kind,
1584
+ available,
1585
+ ready: ready2,
1586
+ path: sourceProbe(kind),
1587
+ store: metricsDbPath(kind)
1588
+ };
1589
+ if (!ready2) return { ...base, unit: null, tokenClasses: null, sourceSchema: null, materializedAt: 0, counts: null };
1590
+ const db = deps.db(kind);
1591
+ const count = (t) => one(db, `SELECT COUNT(*) c FROM ${t}`)?.c ?? 0;
1592
+ return {
1593
+ ...base,
1594
+ unit: getMeta(db, "unit") ?? "usd",
1595
+ // Same probe as `/api/status`, answered here as well so the shell knows the
1596
+ // store's shape before it draws: the Overview's default stack depends on it,
1597
+ // and the source list is resolved before the first frame.
1598
+ tokenClasses: !!one(
1599
+ db,
1600
+ `SELECT 1 AS hit FROM turns
1601
+ WHERE role = 'assistant'
1602
+ AND (output > 0 OR cache_read > 0 OR cache_write > 0 OR reasoning > 0)
1603
+ LIMIT 1`
1604
+ ),
1605
+ sourceSchema: getMeta(db, "sourceSchema"),
1606
+ materializedAt: getMetaNumber(db, "materializedAt"),
1607
+ counts: { sessions: count("sessions"), turns: count("turns"), tools: count("tools") }
1608
+ };
1609
+ };
1610
+ res.json({ default: SOURCE_KIND, sources: SOURCE_KINDS.map(describe) });
1611
+ });
1612
+ app2.post("/api/refresh", async (req, res) => {
1613
+ try {
1614
+ const full = req.body?.full === true;
1615
+ const kind = kindOf(req);
1616
+ const report = await deps.refresh(kind, full);
1617
+ res.json({ ok: true, full, source: kind, report });
1618
+ } catch (e) {
1619
+ res.status(500).json({ ok: false, error: e.message });
1620
+ }
1621
+ });
1622
+ app2.get("/api/top/session/:id/messages", (req, res) => {
1623
+ const db = storeOf(req, res);
1624
+ if (!db) return;
1625
+ const id = req.params.id;
1626
+ const session = one(
1627
+ db,
1628
+ `SELECT id, parent_id, project, title, agent, model, provider, created, updated,
1629
+ t_input, t_output, t_reasoning, t_cache_read, t_cache_write, cost, turns, first_prompt
1630
+ FROM sessions WHERE id = ?`,
1631
+ id
1632
+ );
1633
+ if (!session) return res.status(404).json({ error: "unknown session" });
1634
+ const turns = q(
1635
+ db,
1636
+ `SELECT id, seq, model, provider, agent, created, completed, input, output, reasoning,
1637
+ cache_read, cache_write, cost, dur_ms, finish, tool_calls, tool_errors,
1638
+ project, occupancy, context_window, fill, fill_after, additions, deletions, write_lines, has_error
1639
+ FROM turns WHERE session_id = ? AND role = 'assistant'
1640
+ ORDER BY created ASC, seq ASC`,
1641
+ id
1642
+ );
1643
+ const prompts = q(
1644
+ db,
1645
+ `SELECT id, created, text FROM turns
1646
+ WHERE session_id = ? AND role = 'user' AND length(trim(coalesce(text, ''))) > 0
1647
+ ORDER BY created ASC, seq ASC`,
1648
+ id
1649
+ );
1650
+ const parts = q(
1651
+ db,
1652
+ `SELECT turn_id, ordinal, kind, text, name, call_id, status, title, input, output,
1653
+ exit_code, truncated, started, ended, chars
1654
+ FROM parts WHERE session_id = ? ORDER BY turn_id, ordinal, id`,
1655
+ id
1656
+ );
1657
+ const byTurn = /* @__PURE__ */ new Map();
1658
+ for (const p of parts) {
1659
+ const list = byTurn.get(p.turn_id) ?? [];
1660
+ list.push({
1661
+ kind: p.kind,
1662
+ ordinal: Number(p.ordinal ?? 0),
1663
+ text: p.text ?? "",
1664
+ name: p.name ?? "",
1665
+ callId: p.call_id ?? "",
1666
+ status: p.status ?? "",
1667
+ title: p.title ?? null,
1668
+ input: p.input ?? "",
1669
+ output: p.output ?? "",
1670
+ exitCode: p.exit_code ?? null,
1671
+ truncated: Number(p.truncated ?? 0) === 1,
1672
+ started: p.started ?? null,
1673
+ ended: p.ended ?? null,
1674
+ chars: Number(p.chars ?? 0)
1675
+ });
1676
+ byTurn.set(p.turn_id, list);
1677
+ }
1678
+ res.json({
1679
+ session: {
1680
+ id: session.id,
1681
+ parentId: session.parent_id ?? null,
1682
+ project: session.project ?? "\u2014",
1683
+ title: session.title ?? null,
1684
+ agent: session.agent ?? null,
1685
+ model: session.model ?? null,
1686
+ provider: session.provider ?? null,
1687
+ created: Number(session.created ?? 0),
1688
+ updated: Number(session.updated ?? 0),
1689
+ cost: Number(session.cost ?? 0),
1690
+ firstPrompt: session.first_prompt ?? null
1691
+ },
1692
+ turns: turns.map((t) => ({
1693
+ id: t.id,
1694
+ model: t.model ?? "",
1695
+ provider: t.provider ?? null,
1696
+ agent: t.agent ?? null,
1697
+ created: Number(t.created ?? 0),
1698
+ completed: t.completed == null ? null : Number(t.completed),
1699
+ tokens: {
1700
+ input: Number(t.input ?? 0),
1701
+ output: Number(t.output ?? 0),
1702
+ reasoning: Number(t.reasoning ?? 0),
1703
+ cacheRead: Number(t.cache_read ?? 0),
1704
+ cacheWrite: Number(t.cache_write ?? 0)
1705
+ },
1706
+ cost: Number(t.cost ?? 0),
1707
+ durMs: Number(t.dur_ms ?? 0),
1708
+ finish: t.finish ?? null,
1709
+ tools: Number(t.tool_calls ?? 0),
1710
+ errors: Number(t.tool_errors ?? 0),
1711
+ // Turn-level context readings: the transcript timeline plots these, and the
1712
+ // Stats panel needs the model window the fill ratio was measured against.
1713
+ project: t.project ?? "\u2014",
1714
+ occupancy: Number(t.occupancy ?? 0),
1715
+ contextWindow: Number(t.context_window ?? 0),
1716
+ fill: Number(t.fill ?? 0),
1717
+ fillAfter: Number(t.fill_after ?? 0),
1718
+ additions: Number(t.additions ?? 0),
1719
+ deletions: Number(t.deletions ?? 0),
1720
+ writeLines: Number(t.write_lines ?? 0),
1721
+ hasError: Number(t.has_error ?? 0) === 1,
1722
+ parts: byTurn.get(t.id) ?? []
1723
+ })),
1724
+ prompts: prompts.map((p) => ({ id: p.id, created: Number(p.created ?? 0), text: p.text ?? "" }))
1725
+ });
1726
+ });
1727
+ app2.get("/api/top/facets", (req, res) => {
1728
+ const db = storeOf(req, res);
1729
+ if (!db) return;
1730
+ res.json(facets(db));
1731
+ });
1732
+ app2.get("/api/top/analytics", (req, res) => {
1733
+ const db = storeOf(req, res);
1734
+ if (!db) return;
1735
+ res.json(dashboard(db, sliceOf(req)));
1736
+ });
1737
+ app2.get("/api/top/sessions", (req, res) => {
1738
+ const db = storeOf(req, res);
1739
+ if (!db) return;
1740
+ const focus = str(req, "focus");
1741
+ const slice = sliceOf(req);
1742
+ if (focus) {
1743
+ const rows = sessionContext(db, slice, focus);
1744
+ if (!rows.length) return res.status(404).json({ error: "session not in this window" });
1745
+ return res.json({ rows, matching: rows.length, roots: 0, next: null, totals: null });
1746
+ }
1747
+ const order = str(req, "order");
1748
+ const limit = Number(req.query.limit ?? MAX_PAGE_ROOTS);
1749
+ res.json(
1750
+ sessionsPage(db, slice, {
1751
+ q: str(req, "q"),
1752
+ order: SESSION_ORDERS.includes(order ?? "") ? order : "updated",
1753
+ dir: req.query.dir === "asc" ? "asc" : "desc",
1754
+ limit: Number.isFinite(limit) ? limit : MAX_PAGE_ROOTS,
1755
+ cursor: str(req, "cursor")
1756
+ })
1757
+ );
1758
+ });
1759
+ app2.get("/api/top/tool", (req, res) => {
1760
+ const db = storeOf(req, res);
1761
+ if (!db) return;
1762
+ const name = str(req, "name");
1763
+ if (!name) return res.status(400).json({ error: "name is required" });
1764
+ const detail = toolDetail(db, sliceOf(req), name);
1765
+ if (!detail.totals || detail.totals.calls === 0) return res.status(404).json({ error: "no calls for that tool in this window" });
1766
+ res.json(detail);
1767
+ });
1768
+ app2.get("/api/top/search", (req, res) => {
1769
+ const db = storeOf(req, res);
1770
+ if (!db) return;
1771
+ const raw = typeof req.query.q === "string" ? req.query.q.trim() : "";
1772
+ if (raw.length < 2) return res.json({ query: raw, hits: [] });
1773
+ const like = `%${raw.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
1774
+ const rows = q(
1775
+ db,
1776
+ `SELECT s.id, s.title, s.project, s.updated,
1777
+ (SELECT COUNT(*) FROM parts p WHERE p.session_id = s.id
1778
+ AND (p.text LIKE ? ESCAPE '\\' OR p.name LIKE ? ESCAPE '\\'
1779
+ OR p.title LIKE ? ESCAPE '\\' OR p.input LIKE ? ESCAPE '\\'
1780
+ OR p.output LIKE ? ESCAPE '\\')) hits
1781
+ FROM sessions s
1782
+ WHERE s.id IN (SELECT session_id FROM parts WHERE text LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\'
1783
+ OR title LIKE ? ESCAPE '\\' OR input LIKE ? ESCAPE '\\' OR output LIKE ? ESCAPE '\\')
1784
+ ORDER BY s.updated DESC LIMIT 40`,
1785
+ like,
1786
+ like,
1787
+ like,
1788
+ like,
1789
+ like,
1790
+ like,
1791
+ like,
1792
+ like,
1793
+ like,
1794
+ like
1795
+ );
1796
+ res.json({
1797
+ query: raw,
1798
+ hits: rows.map((r) => ({
1799
+ id: r.id,
1800
+ title: r.title ?? null,
1801
+ project: r.project ?? "\u2014",
1802
+ updated: Number(r.updated ?? 0),
1803
+ hits: Number(r.hits ?? 0)
1804
+ }))
1805
+ });
1806
+ });
1807
+ return app2;
1808
+ }
1809
+
1810
+ // src/metrics/store.ts
1811
+ import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
1812
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, renameSync, rmSync, statSync as statSync2 } from "node:fs";
1813
+
1814
+ // src/source.ts
1815
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1816
+ var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
1817
+ var PART_CAP = { text: 2e3, reasoning: 2e3, input: 1500, output: 3e3 };
1818
+ function bodyPart(kind, body) {
1819
+ const trimmed = body.trim();
1820
+ return {
1821
+ kind,
1822
+ text: trimmed.slice(0, kind === "reasoning" ? PART_CAP.reasoning : PART_CAP.text),
1823
+ name: "",
1824
+ callId: "",
1825
+ status: "",
1826
+ title: null,
1827
+ input: "",
1828
+ output: "",
1829
+ exitCode: null,
1830
+ truncated: trimmed.length > (kind === "reasoning" ? PART_CAP.reasoning : PART_CAP.text),
1831
+ started: null,
1832
+ ended: null,
1833
+ chars: trimmed.length
1834
+ };
1835
+ }
1836
+ function parse(json) {
1837
+ try {
1838
+ return JSON.parse(json);
1839
+ } catch {
1840
+ return null;
1841
+ }
1842
+ }
1843
+ function projectName(directory) {
1844
+ if (!directory) return "unknown";
1845
+ const trimmed = directory.replace(/[\\/]+$/, "");
1846
+ if (!trimmed || trimmed === "/") return "/";
1847
+ const parts = trimmed.split(/[\\/]/);
1848
+ return parts[parts.length - 1] || trimmed;
1849
+ }
1850
+ function pathsOf(input, files) {
1851
+ const out = [];
1852
+ const push = (v) => {
1853
+ if (typeof v === "string" && v.startsWith("/") && v.length > 1) out.push(v);
1854
+ };
1855
+ push(input?.path);
1856
+ push(input?.filePath);
1857
+ push(input?.file_path);
1858
+ push(input?.notebookPath);
1859
+ push(input?.notebook_path);
1860
+ if (Array.isArray(input?.paths)) input.paths.forEach(push);
1861
+ if (Array.isArray(files)) for (const f of files) push(typeof f === "string" ? f : f?.file ?? f?.path);
1862
+ const cmd = input?.command;
1863
+ if (typeof cmd === "string") {
1864
+ for (const m of cmd.matchAll(/\/(?:home|Users|mnt|srv|opt|root|var|workspace)\/[\w.+\-@/]{2,}/g)) out.push(m[0]);
1865
+ }
1866
+ return out;
1867
+ }
1868
+ function fileStats(files) {
1869
+ if (!Array.isArray(files)) return { additions: 0, deletions: 0, files: 0 };
1870
+ let a = 0;
1871
+ let d = 0;
1872
+ for (const f of files) {
1873
+ a += num2(f?.additions);
1874
+ d += num2(f?.deletions);
1875
+ }
1876
+ return { additions: a, deletions: d, files: files.length };
1877
+ }
1878
+ function diffStats(patch) {
1879
+ if (typeof patch !== "string" || !patch) return { additions: 0, deletions: 0 };
1880
+ let additions = 0;
1881
+ let deletions = 0;
1882
+ let inHunk = false;
1883
+ for (const line of patch.split("\n")) {
1884
+ if (line.startsWith("@@")) {
1885
+ inHunk = true;
1886
+ continue;
1887
+ }
1888
+ if (!inHunk) continue;
1889
+ if (line.startsWith("+")) additions++;
1890
+ else if (line.startsWith("-")) deletions++;
1891
+ }
1892
+ return { additions, deletions };
1893
+ }
1894
+ function contentLines(content) {
1895
+ if (typeof content !== "string" || !content) return 0;
1896
+ return content.split("\n").length;
1897
+ }
1898
+ function patchOf(metadata) {
1899
+ return metadata?.filediff?.patch ?? metadata?.diff ?? null;
1900
+ }
1901
+ function touchedFile(metadata, input) {
1902
+ const f = metadata?.filediff?.file;
1903
+ if (typeof f === "string" && f) return f;
1904
+ const g = input?.filePath ?? input?.file_path ?? input?.path;
1905
+ return typeof g === "string" && g ? g : null;
1906
+ }
1907
+ function hashSig(name, input) {
1908
+ const raw = JSON.stringify(input ?? {});
1909
+ let h = 2166136261;
1910
+ const key = name + "|" + raw.slice(0, 400);
1911
+ for (let i = 0; i < key.length; i++) {
1912
+ h ^= key.charCodeAt(i);
1913
+ h = Math.imul(h, 16777619);
1914
+ }
1915
+ return `${name}:${(h >>> 0).toString(36)}`;
1916
+ }
1917
+ var SESSION_ID_RE = /ses_[A-Za-z0-9]{4,}/;
1918
+ var SourceReader = class {
1919
+ kind = "opencode";
1920
+ unit = "usd";
1921
+ path;
1922
+ db;
1923
+ schema;
1924
+ constructor(dbPath) {
1925
+ this.path = dbPath;
1926
+ this.db = new DatabaseSync2(dbPath, { readOnly: true });
1927
+ const hasV2 = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_v2'").get();
1928
+ const rows = hasV2 ? this.db.prepare("SELECT COUNT(*) AS c FROM session_v2").get().c : 0;
1929
+ this.schema = rows > 0 ? "v2" : "v1";
1930
+ }
1931
+ close() {
1932
+ this.db.close();
1933
+ }
1934
+ /**
1935
+ * Watermark: the newest source timestamp, covering both message writes and
1936
+ * session-row updates. Only when *both* are below it is the metrics db fresh —
1937
+ * otherwise a session whose row was touched without a new message would be
1938
+ * re-materialized on every tick, forever.
1939
+ */
1940
+ maxMessageTime() {
1941
+ const msgTable = this.schema === "v2" ? "session_message" : "message";
1942
+ const msgCol = this.schema === "v2" ? "time_updated" : "time_created";
1943
+ const msgMax = this.db.prepare(`SELECT MAX(${msgCol}) AS m FROM ${msgTable}`).get()?.m ?? 0;
1944
+ let sessMax = 0;
1945
+ try {
1946
+ sessMax = this.db.prepare("SELECT MAX(time_updated) AS m FROM session_v2").get()?.m ?? 0;
1947
+ } catch {
1948
+ }
1949
+ return Math.max(msgMax, sessMax);
1950
+ }
1951
+ /**
1952
+ * Sessions touched after `since` (message writes + session row updates).
1953
+ * Used to re-materialize only what changed instead of reparsing 500 MB.
1954
+ */
1955
+ changedSessions(since) {
1956
+ const out = /* @__PURE__ */ new Set();
1957
+ if (since <= 0) return out;
1958
+ const msgTable = this.schema === "v2" ? "session_message" : "message";
1959
+ const msgCol = this.schema === "v2" ? "time_updated" : "time_created";
1960
+ for (const r of this.db.prepare(`SELECT DISTINCT session_id FROM ${msgTable} WHERE ${msgCol} > ?`).all(since)) {
1961
+ if (r.session_id) out.add(String(r.session_id));
1962
+ }
1963
+ const sessTable = this.schema === "v2" ? "session_v2" : "session";
1964
+ try {
1965
+ for (const r of this.db.prepare(`SELECT id FROM ${sessTable} WHERE time_updated > ?`).all(since)) {
1966
+ if (r.id) out.add(String(r.id));
1967
+ }
1968
+ } catch {
1969
+ }
1970
+ return out;
1971
+ }
1972
+ sessions() {
1973
+ const table = this.schema === "v2" ? "session_v2" : "session";
1974
+ const rows = this.db.prepare(
1975
+ `SELECT s.id, s.parent_id, s.project_id, s.title, s.directory, s.agent, s.model,
1976
+ s.time_created, s.time_updated, s.cost,
1977
+ p.name AS project_name, p.worktree AS project_worktree
1978
+ FROM ${table} s LEFT JOIN project p ON p.id = s.project_id`
1979
+ ).all();
1980
+ const agg = /* @__PURE__ */ new Map();
1981
+ if (this.schema === "v2") {
1982
+ for (const r of this.db.prepare(
1983
+ `SELECT id AS sid, tokens_input AS i, tokens_output AS o, tokens_reasoning AS r,
1984
+ tokens_cache_read AS cr, tokens_cache_write AS cw,
1985
+ summary_additions AS a, summary_deletions AS d
1986
+ FROM session_v2`
1987
+ ).all()) {
1988
+ agg.set(r.sid, { input: num2(r.i), output: num2(r.o), reasoning: num2(r.r), cr: num2(r.cr), cw: num2(r.cw), a: num2(r.a), d: num2(r.d) });
1989
+ }
1990
+ }
1991
+ return rows.map((r) => {
1992
+ const model = this.schema === "v2" ? parse(r.model) : null;
1993
+ const a = agg.get(r.id);
1994
+ const directory = r.directory ?? r.project_worktree ?? "/";
1995
+ return {
1996
+ id: r.id,
1997
+ parentId: r.parent_id ?? null,
1998
+ project: r.project_name?.trim() || projectName(directory),
1999
+ directory,
2000
+ title: r.title ?? null,
2001
+ agent: r.agent ?? null,
2002
+ provider: model?.providerID ?? null,
2003
+ model: model?.id ?? (typeof r.model === "string" ? r.model : null),
2004
+ variant: model?.variant ?? null,
2005
+ created: num2(r.time_created),
2006
+ updated: num2(r.time_updated),
2007
+ cost: num2(r.cost),
2008
+ tInput: a?.input ?? 0,
2009
+ tOutput: a?.output ?? 0,
2010
+ tReasoning: a?.reasoning ?? 0,
2011
+ tCacheRead: a?.cr ?? 0,
2012
+ tCacheWrite: a?.cw ?? 0,
2013
+ additions: a?.a ?? 0,
2014
+ deletions: a?.d ?? 0,
2015
+ files: 0
2016
+ };
2017
+ });
2018
+ }
2019
+ turns(sessionIds) {
2020
+ return this.schema === "v2" ? this.turnsV2(sessionIds) : this.turnsV1(sessionIds);
2021
+ }
2022
+ /**
2023
+ * Files touched per session, recovered from v1 `part` patch records.
2024
+ * The v2 message stream has no patch parts, but the legacy table is still
2025
+ * populated for sessions that started life on v1 — worth reading because it is
2026
+ * the only diff signal for ~46 sessions.
2027
+ */
2028
+ patchFileCounts(sessionIds) {
2029
+ const out = /* @__PURE__ */ new Map();
2030
+ const has = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='part'").get();
2031
+ if (!has) return out;
2032
+ let rows = [];
2033
+ try {
2034
+ const where = sessionIds ? `WHERE session_id IN (${[...sessionIds].map(() => "?").join(",")}) AND ` : "WHERE ";
2035
+ const args = sessionIds ? [...sessionIds] : [];
2036
+ rows = this.db.prepare(`${where}data LIKE '%"patch"%'`).all(...args);
2037
+ } catch {
2038
+ return out;
2039
+ }
2040
+ for (const row of rows) {
2041
+ const d = parse(row.data);
2042
+ if (!d || d.type !== "patch") continue;
2043
+ const files = Array.isArray(d.files) ? d.files.map((f) => String(f)) : [];
2044
+ if (!files.length) continue;
2045
+ const cur = out.get(row.session_id) ?? { files: 0, paths: [] };
2046
+ for (const f of files) if (!cur.paths.includes(f)) cur.paths.push(f);
2047
+ cur.files = cur.paths.length;
2048
+ out.set(row.session_id, cur);
2049
+ }
2050
+ return out;
2051
+ }
2052
+ turnsV2(sessionIds) {
2053
+ const where = sessionIds ? `WHERE session_id IN (${[...sessionIds].map(() => "?").join(",")})` : "";
2054
+ const args = sessionIds ? [...sessionIds] : [];
2055
+ const rows = this.db.prepare(`SELECT id, session_id, seq, type, time_created, time_updated, data FROM session_message ${where} ORDER BY time_created ASC, seq ASC`).all(...args);
2056
+ const out = [];
2057
+ for (const row of rows) {
2058
+ const d = parse(row.data);
2059
+ if (!d) continue;
2060
+ const type = row.type;
2061
+ if (type === "compaction" || type === "idle" || type === "model-switched" || type === "agent-switched") continue;
2062
+ const isUser = type === "user";
2063
+ const content = Array.isArray(d.content) ? d.content : [];
2064
+ const tools = [];
2065
+ const parts = [];
2066
+ let text = "";
2067
+ let additions = 0;
2068
+ let deletions = 0;
2069
+ let files = 0;
2070
+ let writeLines = 0;
2071
+ for (const item of content) {
2072
+ if (item?.type === "text") {
2073
+ const body = item.text ?? "";
2074
+ text += body;
2075
+ if (!isUser && body.trim()) parts.push(bodyPart("text", body));
2076
+ continue;
2077
+ }
2078
+ if (item?.type === "reasoning") {
2079
+ const body = item.text ?? "";
2080
+ if (!isUser && body.trim()) parts.push(bodyPart("reasoning", body));
2081
+ continue;
2082
+ }
2083
+ if (item?.type === "tool") {
2084
+ const st = item.state ?? {};
2085
+ const name = item.name ?? "unknown";
2086
+ const legacy = fileStats(st.metadata?.files);
2087
+ const diff = diffStats(patchOf(st.metadata));
2088
+ const touched = touchedFile(st.metadata, st.input);
2089
+ const toolAdd = Math.max(diff.additions, legacy.additions);
2090
+ const toolDel = Math.max(diff.deletions, legacy.deletions);
2091
+ const toolFiles = Math.max(legacy.files, touched ? 1 : 0);
2092
+ const toolWrite = name === "write" ? contentLines(st.input?.content) : 0;
2093
+ additions += toolAdd;
2094
+ deletions += toolDel;
2095
+ files += toolFiles;
2096
+ writeLines += toolWrite;
2097
+ const outputText = st.error?.message ?? st.metadata?.output ?? st.output ?? (st.content ?? []).map((c) => c?.text ?? "").join("\n");
2098
+ const started = item.time?.created ?? st.time?.start ?? st.time?.created ?? null;
2099
+ const ended = item.time?.completed ?? st.time?.end ?? st.time?.completed ?? null;
2100
+ const inputPreview = st.input ? JSON.stringify(st.input).slice(0, 400) : null;
2101
+ tools.push({
2102
+ turnId: row.id,
2103
+ sessionId: row.session_id,
2104
+ callId: item.id ?? "",
2105
+ name,
2106
+ status: st.status ?? "pending",
2107
+ started,
2108
+ ended,
2109
+ title: st.title ?? null,
2110
+ outputChars: typeof outputText === "string" ? outputText.length : 0,
2111
+ additions: toolAdd,
2112
+ deletions: toolDel,
2113
+ files: toolFiles,
2114
+ writeLines: toolWrite,
2115
+ error: st.status === "error" ? st.error?.message ?? "tool error" : null,
2116
+ sig: hashSig(name, st.input),
2117
+ inputPreview,
2118
+ paths: pathsOf(st.input, st.metadata?.files),
2119
+ targetSession: inputPreview ? SESSION_ID_RE.exec(inputPreview)?.[0] ?? null : null
2120
+ });
2121
+ if (!isUser) {
2122
+ parts.push({
2123
+ kind: "tool",
2124
+ text: "",
2125
+ name,
2126
+ callId: item.id ?? "",
2127
+ status: st.status ?? "pending",
2128
+ title: st.title ?? null,
2129
+ input: (st.input ? JSON.stringify(st.input) : "").slice(0, PART_CAP.input),
2130
+ output: (typeof outputText === "string" ? outputText : "").slice(0, PART_CAP.output),
2131
+ exitCode: st.metadata?.exit ?? st.metadata?.exitCode ?? null,
2132
+ truncated: st.metadata?.truncated ?? false,
2133
+ started,
2134
+ ended,
2135
+ chars: typeof outputText === "string" ? outputText.length : 0
2136
+ });
2137
+ }
2138
+ }
2139
+ }
2140
+ const tk = d.tokens ?? {};
2141
+ const created = d.time?.created ?? row.time_created;
2142
+ const completed = d.time?.completed ?? d.time?.streamed ?? (type === "assistant" ? null : created);
2143
+ if (isUser) {
2144
+ out.push({
2145
+ id: row.id,
2146
+ sessionId: row.session_id,
2147
+ seq: num2(row.seq),
2148
+ created,
2149
+ completed,
2150
+ role: "user",
2151
+ provider: null,
2152
+ model: d.model?.id ?? null,
2153
+ variant: null,
2154
+ agent: d.agent ?? null,
2155
+ input: 0,
2156
+ output: 0,
2157
+ reasoning: 0,
2158
+ cacheRead: 0,
2159
+ cacheWrite: 0,
2160
+ cost: 0,
2161
+ finish: null,
2162
+ text: d.text ?? text,
2163
+ tools: [],
2164
+ parts: [],
2165
+ prompt: (d.text ?? text).slice(0, 400)
2166
+ });
2167
+ continue;
2168
+ }
2169
+ if (type !== "assistant") continue;
2170
+ out.push({
2171
+ id: row.id,
2172
+ sessionId: row.session_id,
2173
+ seq: num2(row.seq),
2174
+ created,
2175
+ completed,
2176
+ role: "assistant",
2177
+ provider: d.model?.providerID ?? null,
2178
+ model: d.model?.id ?? null,
2179
+ variant: d.model?.variant ?? null,
2180
+ agent: d.agent ?? null,
2181
+ input: num2(tk.input),
2182
+ output: num2(tk.output),
2183
+ reasoning: num2(tk.reasoning),
2184
+ cacheRead: num2(tk.cache?.read),
2185
+ cacheWrite: num2(tk.cache?.write),
2186
+ cost: num2(d.cost),
2187
+ finish: d.finish ?? d.rawFinish ?? null,
2188
+ text,
2189
+ tools,
2190
+ parts,
2191
+ prompt: null,
2192
+ _add: additions,
2193
+ _del: deletions,
2194
+ _files: files,
2195
+ _write: writeLines
2196
+ });
2197
+ }
2198
+ return out;
2199
+ }
2200
+ turnsV1(sessionIds) {
2201
+ const where = sessionIds ? `WHERE m.session_id IN (${[...sessionIds].map(() => "?").join(",")})` : "";
2202
+ const args = sessionIds ? [...sessionIds] : [];
2203
+ const msgs = this.db.prepare(`SELECT m.id, m.session_id, m.data, m.time_created FROM message m ${where} ORDER BY m.time_created ASC`).all(...args);
2204
+ const partsByMsg = /* @__PURE__ */ new Map();
2205
+ if (msgs.length) {
2206
+ const ids = msgs.map((m) => m.id);
2207
+ const ph = ids.map(() => "?").join(",");
2208
+ for (const p of this.db.prepare(`SELECT message_id, data FROM part WHERE message_id IN (${ph}) ORDER BY rowid ASC`).all(...ids)) {
2209
+ const d = parse(p.data);
2210
+ if (!d) continue;
2211
+ if (!partsByMsg.has(p.message_id)) partsByMsg.set(p.message_id, []);
2212
+ partsByMsg.get(p.message_id).push(d);
2213
+ }
2214
+ }
2215
+ const out = [];
2216
+ for (const row of msgs) {
2217
+ const d = parse(row.data);
2218
+ if (!d) continue;
2219
+ const rawParts = partsByMsg.get(row.id) ?? [];
2220
+ const role = d.role ?? "assistant";
2221
+ const isAssistant = role === "assistant";
2222
+ const tools = [];
2223
+ const parts = [];
2224
+ let text = "";
2225
+ let files = 0;
2226
+ let writeLines = 0;
2227
+ for (const p of rawParts) {
2228
+ if (p.type === "text") {
2229
+ const body = p.text ?? "";
2230
+ text += body;
2231
+ if (isAssistant && body.trim()) parts.push(bodyPart("text", body));
2232
+ continue;
2233
+ }
2234
+ if (p.type === "reasoning") {
2235
+ const body = p.text ?? "";
2236
+ if (isAssistant && body.trim()) parts.push(bodyPart("reasoning", body));
2237
+ continue;
2238
+ }
2239
+ if (p.type === "tool") {
2240
+ const st = p.state ?? {};
2241
+ const name = p.tool ?? "unknown";
2242
+ const diff = diffStats(patchOf(st.metadata));
2243
+ const touched = touchedFile(st.metadata, st.input);
2244
+ const started = st.time?.start ?? p.time?.start ?? null;
2245
+ const ended = st.time?.end ?? p.time?.end ?? null;
2246
+ const inputPreview = st.input ? JSON.stringify(st.input).slice(0, 400) : null;
2247
+ const toolFiles = Math.max(Array.isArray(p.files) ? p.files.length : 0, touched ? 1 : 0);
2248
+ const toolWrite = name === "write" ? contentLines(st.input?.content) : 0;
2249
+ files += toolFiles;
2250
+ writeLines += toolWrite;
2251
+ tools.push({
2252
+ turnId: row.id,
2253
+ sessionId: row.session_id,
2254
+ callId: p.callID ?? "",
2255
+ name,
2256
+ status: st.status ?? "pending",
2257
+ started,
2258
+ ended,
2259
+ title: st.title ?? null,
2260
+ outputChars: (st.output ?? "").length,
2261
+ additions: diff.additions,
2262
+ deletions: diff.deletions,
2263
+ files: toolFiles,
2264
+ writeLines: toolWrite,
2265
+ error: st.status === "error" ? st.error ?? st.output ?? "tool error" : null,
2266
+ sig: hashSig(name, st.input),
2267
+ inputPreview,
2268
+ paths: pathsOf(st.input, p.files),
2269
+ targetSession: inputPreview ? SESSION_ID_RE.exec(inputPreview)?.[0] ?? null : null
2270
+ });
2271
+ if (isAssistant) {
2272
+ const outText = typeof st.output === "string" ? st.output : "";
2273
+ parts.push({
2274
+ kind: "tool",
2275
+ text: "",
2276
+ name,
2277
+ callId: p.callID ?? "",
2278
+ status: st.status ?? "pending",
2279
+ title: st.title ?? null,
2280
+ input: (st.input ? JSON.stringify(st.input) : "").slice(0, PART_CAP.input),
2281
+ output: outText.slice(0, PART_CAP.output),
2282
+ exitCode: st.metadata?.exit ?? st.metadata?.exitCode ?? null,
2283
+ truncated: st.metadata?.truncated ?? false,
2284
+ started,
2285
+ ended,
2286
+ chars: outText.length
2287
+ });
2288
+ }
2289
+ }
2290
+ }
2291
+ const tk = d.tokens ?? {};
2292
+ out.push({
2293
+ id: row.id,
2294
+ sessionId: row.session_id,
2295
+ seq: out.length,
2296
+ created: d.time?.created ?? row.time_created,
2297
+ completed: d.time?.completed ?? null,
2298
+ role: role === "user" ? "user" : role === "assistant" ? "assistant" : "other",
2299
+ provider: d.providerID ?? null,
2300
+ model: d.modelID ?? d.model ?? null,
2301
+ variant: null,
2302
+ agent: d.agent ?? null,
2303
+ input: tk.input ?? d.usage?.input_tokens ?? 0,
2304
+ output: tk.output ?? d.usage?.output_tokens ?? 0,
2305
+ reasoning: tk.reasoning ?? 0,
2306
+ cacheRead: tk.cache?.read ?? d.usage?.cache_read_input_tokens ?? 0,
2307
+ cacheWrite: tk.cache?.write ?? d.usage?.cache_write_input_tokens ?? 0,
2308
+ cost: num2(d.cost),
2309
+ finish: d.finish ?? d.stop_reason ?? null,
2310
+ text,
2311
+ tools,
2312
+ parts: isAssistant ? parts : [],
2313
+ prompt: role === "user" ? text.slice(0, 400) : null,
2314
+ _add: tools.reduce((a, t) => a + t.additions, 0),
2315
+ _del: tools.reduce((a, t) => a + t.deletions, 0),
2316
+ _files: files,
2317
+ _write: writeLines
2318
+ });
2319
+ }
2320
+ return out;
2321
+ }
2322
+ events(sessionIds) {
2323
+ const out = [];
2324
+ if (this.schema === "v2") {
2325
+ const where = sessionIds ? `WHERE session_id IN (${[...sessionIds].map(() => "?").join(",")}) AND` : "WHERE";
2326
+ const args = sessionIds ? [...sessionIds] : [];
2327
+ const rows = this.db.prepare(
2328
+ `SELECT id, session_id, seq, type, time_created, data FROM session_message
2329
+ ${where} type IN ('compaction','idle','model-switched','agent-switched') ORDER BY time_created ASC`
2330
+ ).all(...args);
2331
+ for (const r of rows) {
2332
+ const d = parse(r.data) ?? {};
2333
+ const type = r.type === "compaction" ? "compaction" : r.type === "idle" ? d.outcome === "failed" ? "error" : "idle" : "switch";
2334
+ out.push({
2335
+ sessionId: r.session_id,
2336
+ turnId: null,
2337
+ type,
2338
+ at: d.time?.created ?? r.time_created,
2339
+ reason: d.reason ?? d.outcome ?? d.error?.type ?? null,
2340
+ detail: r.type === "compaction" ? (d.summary ?? "").slice(0, 600) : d.error?.message ?? d.outcome ?? null,
2341
+ tokensBefore: num2(d.tokens?.input) || num2(d.tokens?.cache?.read) || null,
2342
+ model: d.model?.id ?? null
2343
+ });
2344
+ }
2345
+ } else {
2346
+ const rows = this.db.prepare(`SELECT session_id, message_id, data FROM part WHERE data LIKE '%"compaction"%' LIMIT 20000`).all();
2347
+ for (const r of rows) {
2348
+ const d = parse(r.data);
2349
+ if (d?.type !== "compaction") continue;
2350
+ if (sessionIds && !sessionIds.has(r.session_id)) continue;
2351
+ out.push({ sessionId: r.session_id, turnId: r.message_id, type: "compaction", at: d.time?.created ?? 0, reason: d.reason ?? null, detail: (d.text ?? "").slice(0, 600) });
2352
+ }
2353
+ }
2354
+ return out;
2355
+ }
2356
+ };
2357
+
2358
+ // src/source-qoder.ts
2359
+ import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs";
2360
+ import path2 from "node:path";
2361
+ var QODER_MODEL_WINDOW = {
2362
+ qfmodel: 18e4,
2363
+ dfmodel: 1e6
2364
+ };
2365
+ var QODER_DEFAULT_WINDOW = 2e5;
2366
+ var COMPACTION_DROP_TOKENS = 5e4;
2367
+ var INTERNAL_PATH = /(^|\/)\.(qoder|opencode|osm)\//;
2368
+ var NOT_A_MODEL = /* @__PURE__ */ new Set(["", "<synthetic>", "unknown"]);
2369
+ var QODER_MODELS = {
2370
+ qfmodel: "Qwen3.8-Flash",
2371
+ dfmodel: "DeepSeek-Flash"
2372
+ };
2373
+ function modelWindow(model) {
2374
+ if (!model) return QODER_DEFAULT_WINDOW;
2375
+ const alias = QODER_MODEL_WINDOW[model] !== void 0 ? model : Object.keys(QODER_MODELS).find((a) => QODER_MODELS[a] === model);
2376
+ return QODER_MODEL_WINDOW[alias ?? model] ?? QODER_DEFAULT_WINDOW;
2377
+ }
2378
+ var num3 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
2379
+ function ms(value) {
2380
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2381
+ if (typeof value === "string") {
2382
+ const t = Date.parse(value);
2383
+ return Number.isFinite(t) ? t : 0;
2384
+ }
2385
+ return 0;
2386
+ }
2387
+ var micro = (credits) => Math.round(credits * 1e6);
2388
+ function lineCount(body) {
2389
+ if (typeof body !== "string" || !body) return 0;
2390
+ return body.split("\n").length;
2391
+ }
2392
+ function resultText(content) {
2393
+ if (typeof content === "string") return content;
2394
+ if (Array.isArray(content)) {
2395
+ return content.map((c) => typeof c === "string" ? c : c?.type === "text" ? String(c.text ?? "") : "").join("\n");
2396
+ }
2397
+ return "";
2398
+ }
2399
+ function toolTitle(name, input) {
2400
+ if (!input || typeof input !== "object") return null;
2401
+ const raw = input.description ?? input.file_path ?? input.path ?? input.toolName ?? (typeof input.command === "string" ? input.command.split("\n")[0] : null) ?? (typeof input.query === "string" ? input.query : null) ?? (typeof input.subject === "string" ? input.subject : null);
2402
+ if (typeof raw !== "string" || !raw.trim()) return null;
2403
+ return raw.trim().slice(0, 160);
2404
+ }
2405
+ var QoderSource = class {
2406
+ kind = "qoder";
2407
+ unit = "credit";
2408
+ schema = "qoder-jsonl";
2409
+ path;
2410
+ cache = /* @__PURE__ */ new Map();
2411
+ evidence = /* @__PURE__ */ new Map();
2412
+ logDirs = null;
2413
+ constructor(root = QODER_PROJECTS) {
2414
+ this.path = root;
2415
+ }
2416
+ close() {
2417
+ this.cache.clear();
2418
+ this.evidence.clear();
2419
+ }
2420
+ /**
2421
+ * Session id → its transcript files.
2422
+ *
2423
+ * A session owns `<root>/<encoded-cwd>/<sessionId>.jsonl` and, when it spawned
2424
+ * subagents, `<root>/<encoded-cwd>/<sessionId>/subagents/agent-*.jsonl`. Those
2425
+ * subagent rows carry the parent id in `sessionId` and bill to it, so they are
2426
+ * merged into the parent session rather than counted as sessions of their own.
2427
+ * In the corpus at hand that is 8 extra files, 131 turns and 8.7 credits — 1.3%
2428
+ * of the total, which silently missing would understate every session that
2429
+ * ever delegated work.
2430
+ */
2431
+ index() {
2432
+ const out = /* @__PURE__ */ new Map();
2433
+ if (!existsSync2(this.path)) return out;
2434
+ const add = (id, file) => {
2435
+ const list = out.get(id);
2436
+ if (list) list.push(file);
2437
+ else out.set(id, [file]);
2438
+ };
2439
+ for (const dir of readdirSync(this.path, { withFileTypes: true })) {
2440
+ if (!dir.isDirectory()) continue;
2441
+ const abs = path2.join(this.path, dir.name);
2442
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
2443
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
2444
+ add(entry.name.slice(0, -".jsonl".length), path2.join(abs, entry.name));
2445
+ continue;
2446
+ }
2447
+ if (!entry.isDirectory()) continue;
2448
+ const subs = path2.join(abs, entry.name, "subagents");
2449
+ if (!existsSync2(subs)) continue;
2450
+ for (const sub of readdirSync(subs)) if (sub.endsWith(".jsonl")) add(entry.name, path2.join(subs, sub));
2451
+ }
2452
+ }
2453
+ return out;
2454
+ }
2455
+ stamp(file) {
2456
+ try {
2457
+ const st = statSync(file);
2458
+ return { mtime: st.mtimeMs, size: st.size };
2459
+ } catch {
2460
+ return null;
2461
+ }
2462
+ }
2463
+ maxMessageTime() {
2464
+ let max = 0;
2465
+ for (const files of this.index().values())
2466
+ for (const f of files) max = Math.max(max, this.stamp(f)?.mtime ?? 0);
2467
+ return max;
2468
+ }
2469
+ changedSessions(since) {
2470
+ const out = /* @__PURE__ */ new Set();
2471
+ if (since <= 0) return out;
2472
+ for (const [id, files] of this.index()) {
2473
+ for (const f of files) {
2474
+ const st = this.stamp(f);
2475
+ if (st && st.mtime > since) {
2476
+ out.add(id);
2477
+ break;
2478
+ }
2479
+ }
2480
+ }
2481
+ return out;
2482
+ }
2483
+ all(sessionIds) {
2484
+ const out = [];
2485
+ for (const [id, files] of this.index()) {
2486
+ if (sessionIds && !sessionIds.has(id)) continue;
2487
+ const p = this.load(id, files);
2488
+ if (p) out.push(p);
2489
+ }
2490
+ return out;
2491
+ }
2492
+ sessions() {
2493
+ return this.all().map((p) => this.sessionOf(p));
2494
+ }
2495
+ turns(sessionIds) {
2496
+ return this.all(sessionIds).flatMap((p) => p.turns);
2497
+ }
2498
+ events(sessionIds) {
2499
+ return this.all(sessionIds).flatMap((p) => p.compactions);
2500
+ }
2501
+ /** Files touched by edit/write calls; `build` uses these to infer the project lane. */
2502
+ patchFileCounts(sessionIds) {
2503
+ const out = /* @__PURE__ */ new Map();
2504
+ for (const p of this.all(sessionIds)) {
2505
+ if (p.paths.length) out.set(p.id, { files: p.paths.length, paths: p.paths });
2506
+ }
2507
+ return out;
2508
+ }
2509
+ sessionOf(p) {
2510
+ const model = this.displayModel(p.id, p.models);
2511
+ return {
2512
+ id: p.id,
2513
+ parentId: null,
2514
+ project: projectName(p.directory),
2515
+ directory: p.directory,
2516
+ title: p.title,
2517
+ agent: null,
2518
+ provider: "qoder",
2519
+ model,
2520
+ variant: null,
2521
+ created: p.created,
2522
+ updated: p.updated,
2523
+ cost: p.credits,
2524
+ tInput: p.tInput,
2525
+ tOutput: 0,
2526
+ tReasoning: 0,
2527
+ tCacheRead: 0,
2528
+ tCacheWrite: 0,
2529
+ additions: p.additions,
2530
+ deletions: p.deletions,
2531
+ files: p.files,
2532
+ creditsMicro: p.creditsMicro
2533
+ };
2534
+ }
2535
+ /**
2536
+ * Session-level model naming. The alias and the shipped name never appear in the
2537
+ * same telemetry event, so this is inference:
2538
+ * - one alias + one real name → the real name;
2539
+ * - one alias + no evidence → the global map;
2540
+ * - several aliases (a mixed session) → all aliases, never a guess.
2541
+ */
2542
+ displayModel(id, models) {
2543
+ const aliases = [...new Set(models.filter((m) => !!m && !NOT_A_MODEL.has(m)))];
2544
+ if (aliases.length === 0) return "unknown";
2545
+ if (aliases.length > 1) return aliases.sort().join(" + ");
2546
+ const alias = aliases[0];
2547
+ const real = this.realModels(id);
2548
+ if (real.size === 1) return [...real][0];
2549
+ if (real.size === 0 && QODER_MODELS[alias]) return QODER_MODELS[alias];
2550
+ return alias;
2551
+ }
2552
+ /** Shipped names reported by hooks for this session (`hook_input.model`). */
2553
+ realModels(sessionId) {
2554
+ const hit = this.evidence.get(sessionId);
2555
+ if (hit) return hit;
2556
+ const out = /* @__PURE__ */ new Set();
2557
+ for (const enc of this.telemetryDirs()) {
2558
+ const dir = path2.join(QODER_LOGS, enc, sessionId, "segments");
2559
+ if (!existsSync2(dir)) continue;
2560
+ for (const f of readdirSync(dir)) {
2561
+ if (!f.endsWith(".jsonl")) continue;
2562
+ let body = "";
2563
+ try {
2564
+ body = readFileSync(path2.join(dir, f), "utf8");
2565
+ } catch {
2566
+ continue;
2567
+ }
2568
+ for (const line of body.split("\n")) {
2569
+ if (!line.includes("hook_input")) continue;
2570
+ try {
2571
+ const e = JSON.parse(line);
2572
+ const name = e?.data?.hook_input?.model;
2573
+ if (typeof name === "string" && name && name !== "unknown") out.add(name);
2574
+ } catch {
2575
+ }
2576
+ }
2577
+ }
2578
+ }
2579
+ this.evidence.set(sessionId, out);
2580
+ return out;
2581
+ }
2582
+ telemetryDirs() {
2583
+ if (this.logDirs) return this.logDirs;
2584
+ this.logDirs = existsSync2(QODER_LOGS) ? readdirSync(QODER_LOGS, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : [];
2585
+ return this.logDirs;
2586
+ }
2587
+ /** Parse every file of a session and merge them; cached by the files' stamps. */
2588
+ load(id, files) {
2589
+ const stamped = files.map((f) => ({ f, st: this.stamp(f) })).filter((x) => !!x.st).sort((a, b) => a.f.localeCompare(b.f));
2590
+ if (!stamped.length) return null;
2591
+ const sig = stamped.map((x) => `${x.f}:${x.st.mtime}:${x.st.size}`).join("|");
2592
+ const hit = this.cache.get(id);
2593
+ if (hit && hit.sig === sig) return hit.parsed;
2594
+ const parts = [];
2595
+ for (const { f, st } of stamped) {
2596
+ let body = "";
2597
+ try {
2598
+ body = readFileSync(f, "utf8");
2599
+ } catch {
2600
+ continue;
2601
+ }
2602
+ parts.push(this.parse(id, f, st.mtime, body));
2603
+ }
2604
+ if (!parts.length) return null;
2605
+ const parsed = parts.length === 1 ? parts[0] : this.merge(id, parts);
2606
+ const name = this.displayModel(id, parsed.models);
2607
+ if (name !== "unknown" && !name.includes(" + ")) {
2608
+ for (const t of parsed.turns) if (t.role === "assistant" && t.model) t.model = name;
2609
+ }
2610
+ if (this.cache.size > 64) this.cache.clear();
2611
+ this.cache.set(id, { sig, parsed });
2612
+ return parsed;
2613
+ }
2614
+ /** A parent transcript plus its subagent transcripts, as one session. */
2615
+ merge(id, parts) {
2616
+ const seen = /* @__PURE__ */ new Set();
2617
+ const turns = parts.flatMap((p) => p.turns).sort((a, b) => a.created - b.created).filter((t) => seen.has(t.id) ? false : (seen.add(t.id), true)).map((t, i) => ({ ...t, seq: i }));
2618
+ const paths = new Set(parts.flatMap((p) => p.paths));
2619
+ const sum = (pick) => parts.reduce((a, p) => a + pick(p), 0);
2620
+ const created = parts.map((p) => p.created).filter((t) => t > 0);
2621
+ return {
2622
+ id,
2623
+ file: parts[0].file,
2624
+ mtime: Math.max(...parts.map((p) => p.mtime)),
2625
+ directory: parts.find((p) => p.directory !== "/")?.directory ?? "/",
2626
+ title: parts.find((p) => p.title)?.title ?? null,
2627
+ created: created.length ? Math.min(...created) : 0,
2628
+ updated: Math.max(...parts.map((p) => p.updated)),
2629
+ turns,
2630
+ compactions: parts.flatMap((p) => p.compactions).sort((a, b) => a.at - b.at),
2631
+ paths: [...paths],
2632
+ models: parts.flatMap((p) => p.models),
2633
+ credits: sum((p) => p.credits),
2634
+ creditsMicro: sum((p) => p.creditsMicro),
2635
+ tInput: sum((p) => p.tInput),
2636
+ additions: sum((p) => p.additions),
2637
+ deletions: sum((p) => p.deletions),
2638
+ files: sum((p) => p.files)
2639
+ };
2640
+ }
2641
+ parse(id, file, mtime, body) {
2642
+ const order = [];
2643
+ const byId = /* @__PURE__ */ new Map();
2644
+ const prompts = [];
2645
+ const results = /* @__PURE__ */ new Map();
2646
+ const paths = /* @__PURE__ */ new Set();
2647
+ let title = null;
2648
+ let lastPrompt = null;
2649
+ let directory = "";
2650
+ let explicit = 0;
2651
+ let created = 0;
2652
+ let updated = 0;
2653
+ const touch = (ts) => {
2654
+ if (!ts) return;
2655
+ if (!created || ts < created) created = ts;
2656
+ if (ts > updated) updated = ts;
2657
+ };
2658
+ for (const line of body.split("\n")) {
2659
+ if (!line.trim()) continue;
2660
+ let e;
2661
+ try {
2662
+ e = JSON.parse(line);
2663
+ } catch {
2664
+ continue;
2665
+ }
2666
+ const type = e?.type;
2667
+ if (typeof e?.cwd === "string" && e.cwd) directory = e.cwd;
2668
+ touch(ms(e?.timestamp));
2669
+ if (type === "assistant") {
2670
+ const m = e.message ?? {};
2671
+ const mid = typeof m.id === "string" && m.id ? m.id : e.uuid;
2672
+ if (!mid) continue;
2673
+ let entry = byId.get(mid);
2674
+ if (!entry) {
2675
+ entry = {
2676
+ id: mid,
2677
+ ts: ms(e.timestamp),
2678
+ model: m.model ?? null,
2679
+ stop: m.stop_reason ?? null,
2680
+ window: explicit,
2681
+ blocks: [],
2682
+ usage: null
2683
+ };
2684
+ byId.set(mid, entry);
2685
+ order.push(entry);
2686
+ }
2687
+ if (m.stop_reason) entry.stop = m.stop_reason;
2688
+ if (m.model) entry.model = m.model;
2689
+ if (Array.isArray(m.content)) {
2690
+ for (const b of m.content) if (b && typeof b === "object" && b.type) entry.blocks.push(b);
2691
+ }
2692
+ if (m.usage && typeof m.usage === "object") {
2693
+ entry.usage = {
2694
+ credits: num3(m.usage.credits),
2695
+ original: num3(m.usage.original_credits ?? m.usage.credits),
2696
+ ratio: typeof m.usage.context_usage_ratio === "number" ? m.usage.context_usage_ratio : null,
2697
+ request: typeof m.usage.request_id === "string" ? m.usage.request_id : null
2698
+ };
2699
+ }
2700
+ continue;
2701
+ }
2702
+ if (type === "user") {
2703
+ const m = e.message ?? {};
2704
+ const content = m.content;
2705
+ const ts = ms(e.timestamp);
2706
+ if (typeof content === "string") {
2707
+ if (content.trim()) prompts.push({ id: String(e.uuid ?? `u${prompts.length}`), ts, text: content });
2708
+ continue;
2709
+ }
2710
+ if (!Array.isArray(content)) continue;
2711
+ let text = "";
2712
+ for (const b of content) {
2713
+ if (!b || typeof b !== "object") continue;
2714
+ if (b.type === "text" && typeof b.text === "string") text += b.text;
2715
+ if (b.type === "tool_result" && typeof b.tool_use_id === "string") {
2716
+ results.set(b.tool_use_id, {
2717
+ text: resultText(b.content),
2718
+ error: b.is_error === true,
2719
+ ts
2720
+ });
2721
+ }
2722
+ }
2723
+ if (text.trim()) prompts.push({ id: String(e.uuid ?? `u${prompts.length}`), ts, text });
2724
+ continue;
2725
+ }
2726
+ if (type === "runtime-config") {
2727
+ explicit = typeof e.contextWindow === "number" && e.contextWindow > 0 ? e.contextWindow : 0;
2728
+ continue;
2729
+ }
2730
+ if (type === "ai-title" && typeof e.aiTitle === "string") {
2731
+ title = e.aiTitle;
2732
+ continue;
2733
+ }
2734
+ if (type === "workspace-directories" && Array.isArray(e.directories) && e.directories[0]) {
2735
+ directory = String(e.directories[0]);
2736
+ continue;
2737
+ }
2738
+ if (type === "last-prompt" && typeof e.lastPrompt === "string") lastPrompt = e.lastPrompt;
2739
+ }
2740
+ const items = [];
2741
+ const models = [];
2742
+ let credits = 0;
2743
+ let tInput = 0;
2744
+ let additions = 0;
2745
+ let deletions = 0;
2746
+ let files = 0;
2747
+ const ratios = [];
2748
+ for (const entry of order) {
2749
+ const usage = entry.usage;
2750
+ const ratio = usage?.ratio ?? null;
2751
+ const contextWindow = entry.window || modelWindow(entry.model);
2752
+ const input = ratio == null ? 0 : Math.round(ratio * contextWindow);
2753
+ const toolCalls = [];
2754
+ const parts = [];
2755
+ const seenCalls = /* @__PURE__ */ new Set();
2756
+ let text = "";
2757
+ let add = 0;
2758
+ let del = 0;
2759
+ let fileCount = 0;
2760
+ let writeLines = 0;
2761
+ for (const b of entry.blocks) {
2762
+ if (b.type === "text") {
2763
+ const value = String(b.text ?? "");
2764
+ text += value;
2765
+ if (value.trim()) parts.push(bodyPart("text", value));
2766
+ continue;
2767
+ }
2768
+ if (b.type === "thinking") {
2769
+ const value = String(b.thinking ?? b.text ?? "");
2770
+ if (value.trim()) parts.push(bodyPart("reasoning", value));
2771
+ continue;
2772
+ }
2773
+ if (b.type !== "tool_use") continue;
2774
+ const callId = String(b.id ?? `${entry.id}:${toolCalls.length}`);
2775
+ if (seenCalls.has(callId)) continue;
2776
+ seenCalls.add(callId);
2777
+ const name = String(b.name ?? "unknown");
2778
+ const inputJson = b.input ?? {};
2779
+ const result = results.get(callId);
2780
+ const status = result ? result.error ? "error" : "completed" : "pending";
2781
+ const filePath = typeof inputJson?.file_path === "string" ? inputJson.file_path : null;
2782
+ const touched = pathsOf(inputJson, null);
2783
+ for (const p of touched) if (!INTERNAL_PATH.test(p)) paths.add(p);
2784
+ const toolAdd = name === "Edit" ? lineCount(inputJson?.new_string) : 0;
2785
+ const toolDel = name === "Edit" ? lineCount(inputJson?.old_string) : 0;
2786
+ const write = name === "Write" ? lineCount(inputJson?.content) : 0;
2787
+ add += toolAdd;
2788
+ del += toolDel;
2789
+ writeLines += write;
2790
+ if (filePath) fileCount += 1;
2791
+ toolCalls.push({
2792
+ turnId: entry.id,
2793
+ sessionId: id,
2794
+ callId,
2795
+ name,
2796
+ status,
2797
+ started: entry.ts,
2798
+ ended: result?.ts ?? null,
2799
+ title: toolTitle(name, inputJson),
2800
+ outputChars: result?.text.length ?? 0,
2801
+ additions: toolAdd,
2802
+ deletions: toolDel,
2803
+ files: filePath ? 1 : 0,
2804
+ writeLines: write,
2805
+ error: result?.error ? result.text.slice(0, 500) || "tool error" : null,
2806
+ sig: hashSig(name, inputJson),
2807
+ inputPreview: inputJson ? JSON.stringify(inputJson).slice(0, 400) : null,
2808
+ targetSession: null,
2809
+ paths: touched.filter((p) => !INTERNAL_PATH.test(p))
2810
+ });
2811
+ parts.push({
2812
+ kind: "tool",
2813
+ text: "",
2814
+ name,
2815
+ callId,
2816
+ status,
2817
+ title: toolTitle(name, inputJson),
2818
+ input: (inputJson ? JSON.stringify(inputJson) : "").slice(0, PART_CAP.input),
2819
+ output: (result?.text ?? "").slice(0, PART_CAP.output),
2820
+ exitCode: null,
2821
+ truncated: (result?.text.length ?? 0) > PART_CAP.output,
2822
+ started: entry.ts,
2823
+ ended: result?.ts ?? null,
2824
+ chars: result?.text.length ?? 0
2825
+ });
2826
+ }
2827
+ const turnCredits = usage?.credits ?? 0;
2828
+ const synthetic = entry.model === "<synthetic>";
2829
+ const turn = {
2830
+ id: entry.id,
2831
+ sessionId: id,
2832
+ seq: 0,
2833
+ created: entry.ts,
2834
+ completed: entry.ts,
2835
+ role: synthetic ? "system" : "assistant",
2836
+ provider: "qoder",
2837
+ model: entry.model && !NOT_A_MODEL.has(entry.model) ? entry.model : null,
2838
+ variant: null,
2839
+ agent: null,
2840
+ input,
2841
+ output: 0,
2842
+ reasoning: 0,
2843
+ cacheRead: 0,
2844
+ cacheWrite: 0,
2845
+ cost: turnCredits,
2846
+ finish: entry.stop,
2847
+ text,
2848
+ tools: toolCalls,
2849
+ parts,
2850
+ prompt: null,
2851
+ creditsMicro: micro(turnCredits),
2852
+ contextWindow,
2853
+ _add: add,
2854
+ _del: del,
2855
+ _files: fileCount,
2856
+ _write: writeLines
2857
+ };
2858
+ items.push({ ts: entry.ts, turn });
2859
+ if (entry.model) models.push(entry.model);
2860
+ credits += turnCredits;
2861
+ tInput += input;
2862
+ additions += add;
2863
+ deletions += del;
2864
+ files += fileCount;
2865
+ if (ratio != null) ratios.push({ ts: entry.ts, tokens: input });
2866
+ }
2867
+ for (const p of prompts) {
2868
+ if (!p.ts) continue;
2869
+ items.push({
2870
+ ts: p.ts,
2871
+ turn: {
2872
+ id: p.id,
2873
+ sessionId: id,
2874
+ seq: 0,
2875
+ created: p.ts,
2876
+ completed: p.ts,
2877
+ role: "user",
2878
+ provider: null,
2879
+ model: null,
2880
+ variant: null,
2881
+ agent: null,
2882
+ input: 0,
2883
+ output: 0,
2884
+ reasoning: 0,
2885
+ cacheRead: 0,
2886
+ cacheWrite: 0,
2887
+ cost: 0,
2888
+ finish: null,
2889
+ text: p.text,
2890
+ tools: [],
2891
+ parts: [],
2892
+ prompt: p.text.slice(0, 400),
2893
+ creditsMicro: 0
2894
+ }
2895
+ });
2896
+ }
2897
+ items.sort((a, b) => a.ts - b.ts);
2898
+ const turns = items.map((it, i) => ({ ...it.turn, seq: i }));
2899
+ const compactions = [];
2900
+ let prev = null;
2901
+ for (const r of ratios.sort((a, b) => a.ts - b.ts)) {
2902
+ if (prev && r.tokens < prev.tokens - COMPACTION_DROP_TOKENS) {
2903
+ compactions.push({
2904
+ sessionId: id,
2905
+ turnId: null,
2906
+ type: "compaction",
2907
+ // Sit between the two turns: `build` derives before/after from the turns
2908
+ // around the event, and the drop itself happened in that gap.
2909
+ at: Math.round((prev.ts + r.ts) / 2),
2910
+ reason: "auto",
2911
+ detail: "context reset (context drop)",
2912
+ tokensBefore: prev.tokens,
2913
+ tokensAfter: r.tokens,
2914
+ model: null
2915
+ });
2916
+ }
2917
+ prev = r;
2918
+ }
2919
+ return {
2920
+ id,
2921
+ file,
2922
+ mtime,
2923
+ directory: directory || "/",
2924
+ title: title ?? (lastPrompt ? lastPrompt.split("\n")[0].slice(0, 120) : null),
2925
+ created,
2926
+ updated,
2927
+ turns,
2928
+ compactions,
2929
+ paths: [...paths],
2930
+ models,
2931
+ credits,
2932
+ creditsMicro: micro(credits),
2933
+ tInput,
2934
+ additions,
2935
+ deletions,
2936
+ files
2937
+ };
2938
+ }
2939
+ };
2940
+
2941
+ // src/source-select.ts
2942
+ function openSource(kind = SOURCE_KIND) {
2943
+ return kind === "qoder" ? new QoderSource(QODER_PROJECTS) : new SourceReader(SOURCE_DB);
2944
+ }
2945
+
2946
+ // src/metrics/catalog.ts
2947
+ import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync3 } from "node:fs";
2948
+ var FALLBACK_CONTEXT = 128e3;
2949
+ var CATALOG_TTL_MS = 7 * 864e5;
2950
+ var catalogKey = (provider, model) => `${provider ?? "unknown"}/${model ?? "unknown"}`;
2951
+ function fromModelsDev(api, wanted) {
2952
+ const out = {};
2953
+ for (const [providerID, provider] of Object.entries(api ?? {})) {
2954
+ for (const [modelID, m] of Object.entries(provider?.models ?? {})) {
2955
+ const key = `${providerID}/${modelID}`;
2956
+ if (!wanted.has(key)) continue;
2957
+ out[key] = { ...normalize(modelID, providerID, m), source: "models.dev" };
2958
+ }
2959
+ }
2960
+ return out;
2961
+ }
2962
+ function normalize(modelID, providerID, m) {
2963
+ const cost = m?.cost ?? {};
2964
+ const cache = cost.cache ?? {};
2965
+ return {
2966
+ id: modelID,
2967
+ provider: providerID,
2968
+ label: m?.name ?? modelID,
2969
+ context: Number(m?.limit?.context) || FALLBACK_CONTEXT,
2970
+ costInput: Number(cost.input) || 0,
2971
+ costOutput: Number(cost.output) || 0,
2972
+ costCacheRead: Number(cache.read) || 0,
2973
+ costCacheWrite: Number(cache.write) || 0,
2974
+ source: "cache"
2975
+ };
2976
+ }
2977
+ function readCache() {
2978
+ if (!existsSync3(MODEL_CATALOG)) return { fetchedAt: 0, models: {} };
2979
+ try {
2980
+ const parsed = JSON.parse(readFileSync2(MODEL_CATALOG, "utf8"));
2981
+ if (parsed && typeof parsed === "object" && "models" in parsed && parsed.models) {
2982
+ return { fetchedAt: Number(parsed.fetchedAt ?? 0), models: parsed.models };
2983
+ }
2984
+ return { fetchedAt: 0, models: parsed };
2985
+ } catch {
2986
+ return { fetchedAt: 0, models: {} };
2987
+ }
2988
+ }
2989
+ function readOverrides() {
2990
+ if (!existsSync3(MODEL_OVERRIDES)) return {};
2991
+ try {
2992
+ return JSON.parse(readFileSync2(MODEL_OVERRIDES, "utf8"));
2993
+ } catch {
2994
+ return {};
2995
+ }
2996
+ }
2997
+ function fallbackEntry(key) {
2998
+ const provider = key.split("/")[0] ?? "unknown";
2999
+ const model = key.split("/")[1] ?? key;
3000
+ return { ...normalize(model, provider, null), source: "fallback" };
3001
+ }
3002
+ async function loadCatalog(wanted) {
3003
+ const cache = readCache();
3004
+ const overrides = readOverrides();
3005
+ const stale = Date.now() - cache.fetchedAt > CATALOG_TTL_MS;
3006
+ const missing = [...wanted].filter((k) => !cache.models[k]);
3007
+ let fresh = {};
3008
+ const offline = process.env.OSM_OFFLINE === "1";
3009
+ if ((missing.length > 0 || stale) && !offline) {
3010
+ try {
3011
+ const res = await fetch("https://models.dev/api.json", { signal: AbortSignal.timeout(2e4) });
3012
+ if (!res.ok) throw new Error(`models.dev HTTP ${res.status}`);
3013
+ fresh = fromModelsDev(await res.json(), wanted);
3014
+ writeFileSync(MODEL_CATALOG, JSON.stringify({ fetchedAt: Date.now(), models: { ...cache.models, ...fresh } }));
3015
+ } catch (err) {
3016
+ if (!cache.fetchedAt) {
3017
+ writeFileSync(MODEL_CATALOG, JSON.stringify({ fetchedAt: Date.now(), models: cache.models }));
3018
+ }
3019
+ console.warn(`[catalog] models.dev unavailable (${err.message}); using cache/fallback`);
3020
+ }
3021
+ }
3022
+ const merged = {};
3023
+ for (const key of wanted) {
3024
+ const cached = cache.models[key];
3025
+ merged[key] = fresh[key] ?? (cached ? { ...fallbackEntry(key), ...cached, source: cached.source ?? "cache" } : fallbackEntry(key));
3026
+ }
3027
+ for (const [key, patch] of Object.entries(overrides)) {
3028
+ if (!wanted.has(key)) continue;
3029
+ merged[key] = { ...merged[key], ...patch, source: "override" };
3030
+ }
3031
+ return merged;
3032
+ }
3033
+
3034
+ // src/metrics/build.ts
3035
+ var RETRY_WINDOW_MS = 15 * 60 * 1e3;
3036
+ var LOOP_MIN_REPEATS = 3;
3037
+ var localBucket = (ts) => Math.floor(ts / BUCKET_MS) * BUCKET_MS;
3038
+ var localHour = (ts) => new Date(ts).getHours();
3039
+ var localDayStart = (ts) => {
3040
+ const d = new Date(ts);
3041
+ d.setHours(0, 0, 0, 0);
3042
+ return d.getTime();
3043
+ };
3044
+ var CONTAINERS = /\/(?:projects|repos|plugins|workspace|Code)\/([^/.][^/]*)\//;
3045
+ var HOME_LEVEL = /^\/(?:home|Users)\/([^/]+)\/([^/.][^/]*)\//;
3046
+ var ROOT_LEVEL = /^\/(?:tmp|srv|opt|data|work)\/([^/.][^/]*)\//;
3047
+ var IGNORED = /* @__PURE__ */ new Set([
3048
+ "home",
3049
+ "root",
3050
+ "users",
3051
+ "usr",
3052
+ "tmp",
3053
+ "var",
3054
+ "srv",
3055
+ "opt",
3056
+ "bin",
3057
+ "lib",
3058
+ "src",
3059
+ "dist",
3060
+ "data",
3061
+ "log",
3062
+ "logs",
3063
+ "cache",
3064
+ "config",
3065
+ "appdata",
3066
+ "desktop",
3067
+ "documents",
3068
+ "downloads",
3069
+ "program",
3070
+ "programs",
3071
+ "programdata",
3072
+ "node_modules",
3073
+ "versions",
3074
+ "share",
3075
+ "code"
3076
+ ]);
3077
+ var isJunkLane = (project, accounts) => !project || project === "/" || project === "unknown" || accounts.has(project);
3078
+ function inferProject(paths, fallback, accounts) {
3079
+ const votes = /* @__PURE__ */ new Map();
3080
+ const vote = (label, weight) => {
3081
+ if (!label) return;
3082
+ const l = label.toLowerCase();
3083
+ if (l.startsWith(".") || l.length < 3 || IGNORED.has(l)) return;
3084
+ votes.set(label, (votes.get(label) ?? 0) + weight);
3085
+ };
3086
+ for (const raw of paths) {
3087
+ const p = raw.replace(/^\/mnt\/[a-zA-Z]\//, "/");
3088
+ const container = p.match(CONTAINERS);
3089
+ if (container) vote(container[1], 4);
3090
+ else {
3091
+ const home = p.match(HOME_LEVEL);
3092
+ if (home) vote(home[2], 2);
3093
+ else vote(p.match(ROOT_LEVEL)?.[1], 1);
3094
+ }
3095
+ }
3096
+ const junk = isJunkLane(fallback, accounts);
3097
+ let bestV = junk ? 0 : 2;
3098
+ let best = junk ? "unknown" : fallback;
3099
+ for (const [label, v] of votes) if (v > bestV) bestV = v, best = label;
3100
+ return best;
3101
+ }
3102
+ function accountOf(path4) {
3103
+ return (path4 ?? "").replace(/^\/mnt\/[a-zA-Z]\//, "/").match(/^\/(?:home|Users)\/([^/]+)$/)?.[1] ?? null;
3104
+ }
3105
+ async function build(db, opts = {}) {
3106
+ const t0 = Date.now();
3107
+ const mode = opts.mode ?? "full";
3108
+ const src = openSource(opts.kind);
3109
+ try {
3110
+ initSchema(db, { drop: mode === "full" });
3111
+ ensureRollups(db);
3112
+ const allSessions = src.sessions();
3113
+ const allById = new Map(allSessions.map((s) => [s.id, s]));
3114
+ const targetIds = opts.sessionIds ?? new Set(allSessions.map((s) => s.id));
3115
+ const sessionById = new Map(
3116
+ allSessions.filter((s) => targetIds.has(s.id)).map((s) => [s.id, s])
3117
+ );
3118
+ const wanted = /* @__PURE__ */ new Set();
3119
+ for (const s of sessionById.values()) if (s.model) wanted.add(catalogKey(s.provider, s.model));
3120
+ const turnsRaw = src.turns(targetIds);
3121
+ for (const t of turnsRaw) if (t.role === "assistant" && t.model) wanted.add(catalogKey(t.provider, t.model));
3122
+ const catalog = await loadCatalog(wanted);
3123
+ const patchFiles = src.patchFileCounts(targetIds);
3124
+ const pathsBySession = /* @__PURE__ */ new Map();
3125
+ for (const t of turnsRaw) {
3126
+ if (!t.tools.length) continue;
3127
+ const arr = pathsBySession.get(t.sessionId) ?? [];
3128
+ for (const tl of t.tools) arr.push(...tl.paths);
3129
+ pathsBySession.set(t.sessionId, arr);
3130
+ }
3131
+ for (const [sid, info2] of patchFiles) {
3132
+ const arr = pathsBySession.get(sid) ?? [];
3133
+ arr.push(...info2.paths);
3134
+ pathsBySession.set(sid, arr);
3135
+ }
3136
+ const accounts = new Set(
3137
+ allSessions.map((s) => accountOf(s.directory)).filter((a) => !!a)
3138
+ );
3139
+ for (const s of sessionById.values()) {
3140
+ s.project = inferProject(pathsBySession.get(s.id) ?? [], s.project, accounts);
3141
+ }
3142
+ for (let pass = 0; pass < 3; pass++) {
3143
+ let changed = false;
3144
+ for (const s of sessionById.values()) {
3145
+ if (!isJunkLane(s.project, accounts)) continue;
3146
+ const parent = s.parentId ? allById.get(s.parentId) : null;
3147
+ if (parent && !isJunkLane(parent.project, accounts)) s.project = parent.project, changed = true;
3148
+ }
3149
+ if (!changed) break;
3150
+ }
3151
+ for (const s of sessionById.values()) if (isJunkLane(s.project, accounts)) s.project = "scratch";
3152
+ const info = (provider, model) => catalog[catalogKey(provider, model)] ?? catalog[catalogKey("opencode", model)] ?? null;
3153
+ const turns = turnsRaw.map((t) => {
3154
+ const bucket = localBucket(t.created);
3155
+ const day = localDayStart(t.created);
3156
+ const hour = localHour(t.created);
3157
+ if (t.role !== "assistant") {
3158
+ return { ...t, promptTotal: 0, occupancy: 0, contextWindow: 0, fill: 0, fillAfter: 0, costComputed: 0, bucket, day, hour };
3159
+ }
3160
+ const m = info(t.provider, t.model);
3161
+ const promptTotal = t.input + t.cacheRead + t.cacheWrite;
3162
+ const occupancy = promptTotal + t.output + t.reasoning;
3163
+ const contextWindow = t.contextWindow ?? m?.context ?? 128e3;
3164
+ const costComputed = m ? (t.input * m.costInput + t.output * m.costOutput + t.reasoning * m.costOutput + t.cacheRead * m.costCacheRead + t.cacheWrite * m.costCacheWrite) / 1e6 : 0;
3165
+ return {
3166
+ ...t,
3167
+ promptTotal,
3168
+ occupancy,
3169
+ contextWindow,
3170
+ fill: contextWindow > 0 ? Math.min(1, promptTotal / contextWindow) : 0,
3171
+ fillAfter: contextWindow > 0 ? Math.min(1, occupancy / contextWindow) : 0,
3172
+ costComputed: Math.max(t.cost, costComputed),
3173
+ bucket,
3174
+ day,
3175
+ hour
3176
+ };
3177
+ });
3178
+ const assistantBySession = /* @__PURE__ */ new Map();
3179
+ for (const t of turns) {
3180
+ if (t.role !== "assistant") continue;
3181
+ if (t.occupancy <= 0 && t.tools.length === 0) continue;
3182
+ const list = assistantBySession.get(t.sessionId) ?? [];
3183
+ list.push(t);
3184
+ assistantBySession.set(t.sessionId, list);
3185
+ }
3186
+ for (const list of assistantBySession.values()) list.sort((a, b) => a.created - b.created || a.seq - b.seq);
3187
+ const firstPromptBySession = /* @__PURE__ */ new Map();
3188
+ for (const t of turns) {
3189
+ if (t.role !== "user" || !t.text || firstPromptBySession.has(t.sessionId)) continue;
3190
+ firstPromptBySession.set(t.sessionId, t.text.replace(/\s+/g, " ").trim().slice(0, 300));
3191
+ }
3192
+ const eventsBySession = buildEventProbes(src.events(targetIds), assistantBySession);
3193
+ const affectedBuckets = /* @__PURE__ */ new Set();
3194
+ const affectedDays = /* @__PURE__ */ new Set();
3195
+ const idList = [...targetIds];
3196
+ if (idList.length) {
3197
+ const ph = idList.map(() => "?").join(",");
3198
+ const oldCells = db.prepare(`SELECT DISTINCT bucket, day FROM turns WHERE session_id IN (${ph})`).all(...idList);
3199
+ for (const r of oldCells) {
3200
+ if (r.bucket != null) affectedBuckets.add(Number(r.bucket));
3201
+ if (r.day != null) affectedDays.add(Number(r.day));
3202
+ }
3203
+ }
3204
+ let turnCount = 0;
3205
+ let toolCount = 0;
3206
+ let partCount = 0;
3207
+ let eventCount = 0;
3208
+ let sessionCount = 0;
3209
+ db.exec("BEGIN");
3210
+ try {
3211
+ if (idList.length) {
3212
+ const ph = idList.map(() => "?").join(",");
3213
+ db.prepare(`DELETE FROM tools WHERE session_id IN (${ph})`).run(...idList);
3214
+ db.prepare(`DELETE FROM parts WHERE session_id IN (${ph})`).run(...idList);
3215
+ db.prepare(`DELETE FROM events WHERE session_id IN (${ph})`).run(...idList);
3216
+ db.prepare(`DELETE FROM turns WHERE session_id IN (${ph})`).run(...idList);
3217
+ db.prepare(`DELETE FROM sessions WHERE id IN (${ph})`).run(...idList);
3218
+ }
3219
+ const turnCols = [
3220
+ "id",
3221
+ "session_id",
3222
+ "project",
3223
+ "seq",
3224
+ "created",
3225
+ "completed",
3226
+ "dur_ms",
3227
+ "role",
3228
+ "provider",
3229
+ "model",
3230
+ "variant",
3231
+ "agent",
3232
+ "input",
3233
+ "output",
3234
+ "reasoning",
3235
+ "cache_read",
3236
+ "cache_write",
3237
+ "cost",
3238
+ "credits_micro",
3239
+ "prompt_total",
3240
+ "occupancy",
3241
+ "context_window",
3242
+ "fill",
3243
+ "fill_after",
3244
+ "bucket",
3245
+ "day",
3246
+ "hour",
3247
+ "finish",
3248
+ "has_error",
3249
+ "tool_calls",
3250
+ "tool_errors",
3251
+ "additions",
3252
+ "deletions",
3253
+ "write_lines",
3254
+ "text"
3255
+ ];
3256
+ const insTurn = db.prepare(`INSERT INTO turns(${turnCols.join(",")}) VALUES(${turnCols.map(() => "?").join(",")})`);
3257
+ const insTool = db.prepare(
3258
+ `INSERT INTO tools(session_id,turn_id,call_id,name,status,started,ended,dur_ms,title,output_chars,additions,deletions,files,write_lines,error,sig,input_preview,target_session)
3259
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3260
+ );
3261
+ const insPart = db.prepare(
3262
+ `INSERT INTO parts(session_id,turn_id,ordinal,kind,text,name,call_id,status,title,input,output,exit_code,truncated,started,ended,chars)
3263
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3264
+ );
3265
+ for (const t of turns) {
3266
+ const s = sessionById.get(t.sessionId);
3267
+ if (!s) continue;
3268
+ insTurn.run(
3269
+ t.id,
3270
+ t.sessionId,
3271
+ s.project,
3272
+ t.seq,
3273
+ t.created,
3274
+ t.completed,
3275
+ t.created && t.completed ? Math.max(0, t.completed - t.created) : null,
3276
+ t.role,
3277
+ t.provider,
3278
+ t.model,
3279
+ t.variant,
3280
+ t.agent,
3281
+ t.input,
3282
+ t.output,
3283
+ t.reasoning,
3284
+ t.cacheRead,
3285
+ t.cacheWrite,
3286
+ t.costComputed,
3287
+ t.creditsMicro ?? 0,
3288
+ t.promptTotal,
3289
+ t.occupancy,
3290
+ t.contextWindow,
3291
+ t.fill,
3292
+ t.fillAfter,
3293
+ t.bucket,
3294
+ t.day,
3295
+ t.hour,
3296
+ t.finish,
3297
+ t.tools.some((x) => x.status === "error") ? 1 : 0,
3298
+ t.tools.length,
3299
+ t.tools.filter((x) => x.status === "error").length,
3300
+ t._add ?? 0,
3301
+ t._del ?? 0,
3302
+ t._write ?? 0,
3303
+ (t.text ?? "").slice(0, 2e3)
3304
+ );
3305
+ turnCount++;
3306
+ affectedBuckets.add(t.bucket);
3307
+ affectedDays.add(t.day);
3308
+ for (const tool of t.tools) {
3309
+ insTool.run(
3310
+ tool.sessionId,
3311
+ t.id,
3312
+ tool.callId,
3313
+ tool.name,
3314
+ tool.status,
3315
+ tool.started,
3316
+ tool.ended,
3317
+ tool.started && tool.ended ? Math.max(0, tool.ended - tool.started) : null,
3318
+ tool.title,
3319
+ tool.outputChars,
3320
+ tool.additions,
3321
+ tool.deletions,
3322
+ tool.files,
3323
+ tool.writeLines,
3324
+ tool.error ? tool.error.slice(0, 2e3) : null,
3325
+ tool.sig,
3326
+ tool.inputPreview,
3327
+ tool.targetSession
3328
+ );
3329
+ toolCount++;
3330
+ if (tool.status === "error") {
3331
+ insertEvent(db, {
3332
+ sessionId: tool.sessionId,
3333
+ turnId: t.id,
3334
+ type: "failure",
3335
+ at: tool.started ?? t.created,
3336
+ endAt: tool.ended ?? tool.started ?? t.created,
3337
+ count: 1,
3338
+ fill: t.fill,
3339
+ detail: { tool: tool.name, error: (tool.error ?? "").slice(0, 300), title: tool.title }
3340
+ });
3341
+ eventCount++;
3342
+ }
3343
+ }
3344
+ for (let i = 0; i < t.parts.length; i++) {
3345
+ const p = t.parts[i];
3346
+ insPart.run(
3347
+ t.sessionId,
3348
+ t.id,
3349
+ i,
3350
+ p.kind,
3351
+ p.text || null,
3352
+ p.name || null,
3353
+ p.callId || null,
3354
+ p.status || null,
3355
+ p.title,
3356
+ p.input || null,
3357
+ p.output || null,
3358
+ p.exitCode,
3359
+ p.truncated ? 1 : 0,
3360
+ p.started,
3361
+ p.ended,
3362
+ p.chars
3363
+ );
3364
+ partCount++;
3365
+ }
3366
+ }
3367
+ for (const [sid, list] of eventsBySession) {
3368
+ const ts = assistantBySession.get(sid) ?? [];
3369
+ for (const e of list) {
3370
+ let fill = 0;
3371
+ for (const t of ts) {
3372
+ if (t.created <= e.at) fill = t.fill;
3373
+ else break;
3374
+ }
3375
+ insertEvent(db, { sessionId: sid, turnId: e.turnId, type: e.type, at: e.at, endAt: e.endAt, count: e.count, fill, detail: e.detail });
3376
+ eventCount++;
3377
+ }
3378
+ }
3379
+ const sessionCols = [
3380
+ "id",
3381
+ "project",
3382
+ "directory",
3383
+ "title",
3384
+ "agent",
3385
+ "provider",
3386
+ "model",
3387
+ "variant",
3388
+ "parent_id",
3389
+ "depth",
3390
+ "created",
3391
+ "updated",
3392
+ "t_input",
3393
+ "t_output",
3394
+ "t_reasoning",
3395
+ "t_cache_read",
3396
+ "t_cache_write",
3397
+ "cost",
3398
+ "credits_micro",
3399
+ "turns",
3400
+ "tool_calls",
3401
+ "tool_errors",
3402
+ "compactions",
3403
+ "retries",
3404
+ "loops",
3405
+ "additions",
3406
+ "deletions",
3407
+ "files_changed",
3408
+ "peak_fill",
3409
+ "avg_fill",
3410
+ "eff_score",
3411
+ "tokens_per_line",
3412
+ "first_prompt",
3413
+ "duration_ms",
3414
+ "dominant_model",
3415
+ "output_rate"
3416
+ ];
3417
+ const insSession = db.prepare(`INSERT INTO sessions(${sessionCols.join(",")}) VALUES(${sessionCols.map(() => "?").join(",")})`);
3418
+ for (const s of sessionById.values()) {
3419
+ const ts = assistantBySession.get(s.id) ?? [];
3420
+ const sum = (f) => ts.reduce((a, t) => f(t) + a, 0);
3421
+ const tInput = s.tInput || sum((t) => t.input);
3422
+ const tOutput = s.tOutput || sum((t) => t.output);
3423
+ const tReasoning = s.tReasoning || sum((t) => t.reasoning);
3424
+ const tCacheRead = s.tCacheRead || sum((t) => t.cacheRead);
3425
+ const tCacheWrite = s.tCacheWrite || sum((t) => t.cacheWrite);
3426
+ const promptTotal = tInput + tCacheRead + tCacheWrite;
3427
+ const turnAdd = sum((t) => t._add ?? 0);
3428
+ const turnDel = sum((t) => t._del ?? 0);
3429
+ const additions = turnAdd || s.additions;
3430
+ const deletions = turnDel || s.deletions;
3431
+ const toolCalls = ts.reduce((a, t) => a + t.tools.length, 0);
3432
+ const toolErrors = ts.reduce((a, t) => a + t.tools.filter((x) => x.status === "error").length, 0);
3433
+ const patches = patchFiles.get(s.id);
3434
+ const filesChanged = Math.max(sum((t) => t._files ?? 0), patches?.files ?? 0);
3435
+ const peak = ts.reduce((a, t) => Math.max(a, t.fill), 0);
3436
+ const avg = ts.length ? ts.reduce((a, t) => a + t.fill, 0) / ts.length : 0;
3437
+ const eff = promptTotal > 0 ? (tOutput + tReasoning) / promptTotal * 100 : 0;
3438
+ const lines = additions + deletions;
3439
+ const totalTokens = tInput + tOutput + tReasoning + tCacheRead + tCacheWrite;
3440
+ const byModel = /* @__PURE__ */ new Map();
3441
+ for (const t of ts) {
3442
+ const k = t.model ?? "unknown";
3443
+ byModel.set(k, (byModel.get(k) ?? 0) + t.occupancy);
3444
+ }
3445
+ let dominant = s.model ?? "unknown";
3446
+ let bestV = -1;
3447
+ for (const [k, v] of byModel) if (v > bestV) bestV = v, dominant = k;
3448
+ const rates = ts.map((t) => t.output > 0 && t.completed && t.created && t.completed > t.created ? t.output / ((t.completed - t.created) / 1e3) : 0).filter((r) => r > 0).sort((a, b) => a - b);
3449
+ const mid = Math.floor(rates.length / 2);
3450
+ const outputRate = rates.length === 0 ? 0 : rates.length % 2 !== 0 ? rates[mid] : (rates[mid - 1] + rates[mid]) / 2;
3451
+ const sessionEvents = eventsBySession.get(s.id) ?? [];
3452
+ const compactions = sessionEvents.filter((e) => e.type === "compaction").length;
3453
+ const retries = sessionEvents.filter((e) => e.type === "retry").length;
3454
+ const loops = sessionEvents.filter((e) => e.type === "loop").length;
3455
+ const creditsMicro = s.creditsMicro || ts.reduce((a, t) => a + (t.creditsMicro ?? 0), 0);
3456
+ insSession.run(
3457
+ s.id,
3458
+ s.project,
3459
+ s.directory,
3460
+ s.title,
3461
+ s.agent,
3462
+ s.provider,
3463
+ s.model,
3464
+ s.variant,
3465
+ s.parentId,
3466
+ depthOf(s, allById),
3467
+ s.created,
3468
+ s.updated,
3469
+ tInput,
3470
+ tOutput,
3471
+ tReasoning,
3472
+ tCacheRead,
3473
+ tCacheWrite,
3474
+ Math.max(s.cost, ts.reduce((a, t) => a + t.costComputed, 0)),
3475
+ creditsMicro,
3476
+ ts.length,
3477
+ toolCalls,
3478
+ toolErrors,
3479
+ compactions,
3480
+ retries,
3481
+ loops,
3482
+ additions,
3483
+ deletions,
3484
+ filesChanged,
3485
+ peak,
3486
+ avg,
3487
+ eff,
3488
+ lines > 0 ? totalTokens / lines : null,
3489
+ firstPromptBySession.get(s.id) ?? null,
3490
+ s.updated - s.created,
3491
+ dominant,
3492
+ outputRate
3493
+ );
3494
+ sessionCount++;
3495
+ }
3496
+ const upModel = db.prepare(
3497
+ `INSERT INTO models(id,provider,label,context,cost_input,cost_output,cost_cache_read,cost_cache_write,source)
3498
+ VALUES(?,?,?,?,?,?,?,?,?)
3499
+ ON CONFLICT(id) DO UPDATE SET label=excluded.label, context=excluded.context,
3500
+ cost_input=excluded.cost_input, cost_output=excluded.cost_output,
3501
+ cost_cache_read=excluded.cost_cache_read, cost_cache_write=excluded.cost_cache_write, source=excluded.source`
3502
+ );
3503
+ for (const [key, m] of Object.entries(catalog)) {
3504
+ upModel.run(key, m.provider, m.label, m.context, m.costInput, m.costOutput, m.costCacheRead, m.costCacheWrite, m.source);
3505
+ }
3506
+ refreshRollups(db, idList, [...affectedDays], mode === "full");
3507
+ setMeta(db, "sourceDb", src.path);
3508
+ setMeta(db, "sourceSchema", src.schema);
3509
+ setMeta(db, "sourceKind", src.kind);
3510
+ setMeta(db, "unit", src.unit);
3511
+ setMeta(db, "sourceMaxTime", String(src.maxMessageTime()));
3512
+ setMeta(db, "materializedAt", String(Date.now()));
3513
+ setMeta(db, "bucketMs", String(BUCKET_MS));
3514
+ setMeta(db, "mode", mode);
3515
+ db.exec("COMMIT");
3516
+ } catch (e) {
3517
+ try {
3518
+ db.exec("ROLLBACK");
3519
+ } catch {
3520
+ }
3521
+ throw e;
3522
+ }
3523
+ const flowRows = recomputeFlow(db, affectedBuckets);
3524
+ const heatRows = recomputeHeat(db, affectedDays);
3525
+ return {
3526
+ mode,
3527
+ schema: src.schema,
3528
+ sessions: sessionCount,
3529
+ turns: turnCount,
3530
+ tools: toolCount,
3531
+ parts: partCount,
3532
+ events: eventCount,
3533
+ flow: flowRows,
3534
+ heat: heatRows,
3535
+ models: Object.keys(catalog).length,
3536
+ ms: Date.now() - t0
3537
+ };
3538
+ } finally {
3539
+ src.close();
3540
+ }
3541
+ }
3542
+ function depthOf(s, byId) {
3543
+ let d = 0;
3544
+ let cur = s.parentId ? byId.get(s.parentId) : null;
3545
+ while (cur && d < 10) {
3546
+ d++;
3547
+ cur = cur.parentId ? byId.get(cur.parentId) : null;
3548
+ }
3549
+ return d;
3550
+ }
3551
+ var insEventStmt = /* @__PURE__ */ new WeakMap();
3552
+ function insertEvent(db, e) {
3553
+ let st = insEventStmt.get(db);
3554
+ if (!st) {
3555
+ st = db.prepare("INSERT INTO events(session_id,turn_id,type,at,end_at,count,fill,detail) VALUES(?,?,?,?,?,?,?,?)");
3556
+ insEventStmt.set(db, st);
3557
+ }
3558
+ st.run(e.sessionId, e.turnId, e.type, Math.round(e.at), Math.round(e.endAt), e.count, e.fill, JSON.stringify(e.detail));
3559
+ }
3560
+ function buildEventProbes(sourceEvents, assistantBySession, opts = {}) {
3561
+ const out = /* @__PURE__ */ new Map();
3562
+ for (const e of sourceEvents) {
3563
+ const list = out.get(e.sessionId) ?? [];
3564
+ const detail = { reason: e.reason };
3565
+ if (e.detail) detail.summary = e.detail;
3566
+ if (e.tokensBefore != null) detail.sourceTokens = e.tokensBefore;
3567
+ if (e.model) detail.model = e.model;
3568
+ list.push({ type: e.type, at: e.at, endAt: e.at, count: 1, turnId: e.turnId, detail });
3569
+ out.set(e.sessionId, list);
3570
+ }
3571
+ if (opts.enrichCompaction !== false) {
3572
+ for (const [sid, list] of out) {
3573
+ const ts = assistantBySession.get(sid);
3574
+ if (!ts) continue;
3575
+ for (const e of list) {
3576
+ if (e.type !== "compaction") continue;
3577
+ let idx = -1;
3578
+ for (let i = 0; i < ts.length; i++) {
3579
+ if (ts[i].created <= e.at) idx = i;
3580
+ else break;
3581
+ }
3582
+ const before = idx >= 0 ? ts[idx] : null;
3583
+ const after = idx + 1 < ts.length ? ts[idx + 1] : null;
3584
+ if (before) {
3585
+ e.detail.before = before.promptTotal;
3586
+ e.detail.model = e.detail.model ?? before.model;
3587
+ }
3588
+ if (after) e.detail.after = after.promptTotal;
3589
+ if (before && after) e.detail.delta = after.promptTotal - before.promptTotal;
3590
+ }
3591
+ }
3592
+ }
3593
+ for (const [sid, ts] of assistantBySession) {
3594
+ const lastSeen = /* @__PURE__ */ new Map();
3595
+ const chains = /* @__PURE__ */ new Map();
3596
+ const probes = [];
3597
+ for (const t of ts) {
3598
+ for (const tool of t.tools) {
3599
+ const at = tool.started ?? t.created;
3600
+ const ok = tool.status !== "error";
3601
+ const prev = lastSeen.get(tool.sig);
3602
+ const dt = prev ? at - prev.at : null;
3603
+ const within = dt != null && dt >= 0 && dt <= RETRY_WINDOW_MS;
3604
+ if (prev && within && (!prev.ok || !ok)) {
3605
+ probes.push({
3606
+ type: "retry",
3607
+ at: prev.at,
3608
+ endAt: at,
3609
+ count: 1,
3610
+ turnId: t.id,
3611
+ detail: { tool: tool.name, title: tool.title, sig: tool.sig, first_failed: !prev.ok, span_ms: at - prev.at }
3612
+ });
3613
+ }
3614
+ let chain = chains.get(tool.sig);
3615
+ if (within && chain) {
3616
+ chain.count++;
3617
+ chain.to = at;
3618
+ } else {
3619
+ chain = { count: 1, from: at, to: at };
3620
+ chains.set(tool.sig, chain);
3621
+ }
3622
+ lastSeen.set(tool.sig, { at, ok });
3623
+ }
3624
+ }
3625
+ for (const [sig, chain] of chains) {
3626
+ if (chain.count >= LOOP_MIN_REPEATS) {
3627
+ probes.push({
3628
+ type: "loop",
3629
+ at: chain.from,
3630
+ endAt: chain.to,
3631
+ count: chain.count,
3632
+ turnId: null,
3633
+ detail: { sig, repeats: chain.count, span_ms: chain.to - chain.from }
3634
+ });
3635
+ }
3636
+ }
3637
+ if (probes.length) {
3638
+ const list = out.get(sid) ?? [];
3639
+ out.set(sid, [...list, ...probes]);
3640
+ }
3641
+ }
3642
+ for (const list of out.values()) list.sort((a, b) => a.at - b.at);
3643
+ return out;
3644
+ }
3645
+ function recomputeFlow(db, buckets) {
3646
+ if (!buckets.size) return 0;
3647
+ const list = [...buckets];
3648
+ const ph = list.map(() => "?").join(",");
3649
+ const rows = db.prepare(
3650
+ `SELECT project, bucket, session_id, model, input, output, reasoning, cache_read, cost, fill
3651
+ FROM turns WHERE role='assistant' AND bucket IN (${ph})`
3652
+ ).all(...list);
3653
+ const cells = /* @__PURE__ */ new Map();
3654
+ for (const r of rows) {
3655
+ const key = `${r.project}|${r.bucket}`;
3656
+ let cell = cells.get(key);
3657
+ if (!cell) {
3658
+ cell = { project: r.project, bucket: Number(r.bucket), gen: 0, cacheRead: 0, cost: 0, sessions: /* @__PURE__ */ new Set(), models: /* @__PURE__ */ new Map(), peak: 0 };
3659
+ cells.set(key, cell);
3660
+ }
3661
+ const gen = Number(r.input) + Number(r.output) + Number(r.reasoning);
3662
+ cell.gen += gen;
3663
+ cell.cacheRead += Number(r.cache_read ?? 0);
3664
+ cell.cost += Number(r.cost ?? 0);
3665
+ cell.sessions.add(String(r.session_id));
3666
+ cell.peak = Math.max(cell.peak, Number(r.fill ?? 0));
3667
+ const m = r.model ?? "unknown";
3668
+ cell.models.set(m, (cell.models.get(m) ?? 0) + gen);
3669
+ }
3670
+ db.exec("BEGIN");
3671
+ try {
3672
+ db.prepare(`DELETE FROM flow WHERE bucket IN (${ph})`).run(...list);
3673
+ const ins = db.prepare(
3674
+ "INSERT INTO flow(project,bucket,tokens_gen,tokens_cache_read,cost,sessions,dominant_model,peak_fill) VALUES(?,?,?,?,?,?,?,?)"
3675
+ );
3676
+ for (const c of cells.values()) {
3677
+ let dominant = "unknown";
3678
+ let best = -1;
3679
+ for (const [k, v] of c.models) if (v > best) best = v, dominant = k;
3680
+ ins.run(c.project, c.bucket, Math.round(c.gen), Math.round(c.cacheRead), c.cost, c.sessions.size, dominant, c.peak);
3681
+ }
3682
+ db.exec("COMMIT");
3683
+ } catch (e) {
3684
+ try {
3685
+ db.exec("ROLLBACK");
3686
+ } catch {
3687
+ }
3688
+ throw e;
3689
+ }
3690
+ return cells.size;
3691
+ }
3692
+ function recomputeHeat(db, days) {
3693
+ if (!days.size) return 0;
3694
+ const list = [...days];
3695
+ const ph = list.map(() => "?").join(",");
3696
+ const rows = db.prepare(
3697
+ `SELECT project, day, hour, input, output, reasoning, cache_read, cost
3698
+ FROM turns WHERE role='assistant' AND day IN (${ph})`
3699
+ ).all(...list);
3700
+ const cells = /* @__PURE__ */ new Map();
3701
+ for (const r of rows) {
3702
+ const key = `${r.project}|${r.day}|${r.hour}`;
3703
+ let c = cells.get(key);
3704
+ if (!c) {
3705
+ c = { project: r.project, day: Number(r.day), hour: Number(r.hour), gen: 0, cacheRead: 0, cost: 0, turns: 0 };
3706
+ cells.set(key, c);
3707
+ }
3708
+ c.gen += Number(r.input) + Number(r.output) + Number(r.reasoning);
3709
+ c.cacheRead += Number(r.cache_read ?? 0);
3710
+ c.cost += Number(r.cost ?? 0);
3711
+ c.turns++;
3712
+ }
3713
+ db.exec("BEGIN");
3714
+ try {
3715
+ db.prepare(`DELETE FROM heat WHERE day IN (${ph})`).run(...list);
3716
+ const ins = db.prepare("INSERT INTO heat(project,day,hour,tokens_gen,tokens_cache_read,cost,turns) VALUES(?,?,?,?,?,?,?)");
3717
+ for (const c of cells.values()) ins.run(c.project, c.day, c.hour, Math.round(c.gen), Math.round(c.cacheRead), c.cost, c.turns);
3718
+ db.exec("COMMIT");
3719
+ } catch (e) {
3720
+ try {
3721
+ db.exec("ROLLBACK");
3722
+ } catch {
3723
+ }
3724
+ throw e;
3725
+ }
3726
+ return cells.size;
3727
+ }
3728
+
3729
+ // src/metrics/store.ts
3730
+ var slots = /* @__PURE__ */ new Map();
3731
+ function slotOf(kind) {
3732
+ let slot = slots.get(kind);
3733
+ if (!slot) {
3734
+ slot = { handle: null, stat: null };
3735
+ slots.set(kind, slot);
3736
+ }
3737
+ return slot;
3738
+ }
3739
+ function fileIdentity(kind) {
3740
+ try {
3741
+ const st = statSync2(metricsDbPath(kind));
3742
+ return `${st.dev}:${st.ino}`;
3743
+ } catch {
3744
+ return "missing";
3745
+ }
3746
+ }
3747
+ var hasStore = (kind = SOURCE_KIND) => existsSync4(metricsDbPath(kind));
3748
+ function current(kind = SOURCE_KIND) {
3749
+ mkdirSync2(DATA_DIR, { recursive: true });
3750
+ const slot = slotOf(kind);
3751
+ const id = fileIdentity(kind);
3752
+ if (slot.handle && slot.stat !== id) {
3753
+ closeReader(kind);
3754
+ }
3755
+ if (!slot.handle) {
3756
+ slot.handle = openMetrics(metricsDbPath(kind));
3757
+ slot.stat = id;
3758
+ }
3759
+ return slot.handle;
3760
+ }
3761
+ function closeReader(kind) {
3762
+ const kinds = kind ? [kind] : [...slots.keys()];
3763
+ for (const k of kinds) {
3764
+ const slot = slotOf(k);
3765
+ try {
3766
+ slot.handle?.close();
3767
+ } catch {
3768
+ }
3769
+ slot.handle = null;
3770
+ slot.stat = null;
3771
+ }
3772
+ }
3773
+ function sourceChanged(kind = SOURCE_KIND) {
3774
+ if (!hasStore(kind)) return true;
3775
+ const prev = getMetaNumber(current(kind), "sourceMaxTime");
3776
+ if (prev <= 0) return true;
3777
+ const src = openSource(kind);
3778
+ try {
3779
+ return src.maxMessageTime() > prev;
3780
+ } catch {
3781
+ return false;
3782
+ } finally {
3783
+ src.close();
3784
+ }
3785
+ }
3786
+ async function rebuild(kind = SOURCE_KIND, opts = {}) {
3787
+ mkdirSync2(DATA_DIR, { recursive: true });
3788
+ if (opts.full === true || !hasStore(kind) || schemaStale(kind)) return fullRebuild(kind);
3789
+ return incrementalRebuild(kind);
3790
+ }
3791
+ function schemaStale(kind) {
3792
+ try {
3793
+ return getMeta(current(kind), "schemaSig") !== SCHEMA_SIG;
3794
+ } catch {
3795
+ return true;
3796
+ }
3797
+ }
3798
+ var nextFile = (kind) => `${metricsDbPath(kind)}.next`;
3799
+ async function fullRebuild(kind) {
3800
+ const next = nextFile(kind);
3801
+ for (const suffix of ["", "-wal", "-shm"]) if (existsSync4(next + suffix)) rmSync(next + suffix, { force: true });
3802
+ const writer = new DatabaseSync3(next);
3803
+ writer.exec("PRAGMA journal_mode = WAL");
3804
+ let report;
3805
+ try {
3806
+ report = await build(writer, { mode: "full", kind });
3807
+ } finally {
3808
+ writer.close();
3809
+ }
3810
+ closeReader(kind);
3811
+ const target = metricsDbPath(kind);
3812
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(target + suffix, { force: true });
3813
+ renameSync(next, target);
3814
+ return report;
3815
+ }
3816
+ async function incrementalRebuild(kind) {
3817
+ const db = current(kind);
3818
+ const prevWatermark = getMetaNumber(db, "sourceMaxTime");
3819
+ const src = openSource(kind);
3820
+ let changed;
3821
+ try {
3822
+ changed = prevWatermark > 0 ? src.changedSessions(prevWatermark) : /* @__PURE__ */ new Set();
3823
+ } finally {
3824
+ src.close();
3825
+ }
3826
+ if (changed.size === 0) {
3827
+ setMeta(db, "materializedAt", String(Date.now()));
3828
+ return {
3829
+ mode: "incremental",
3830
+ schema: getMeta(db, "sourceSchema") ?? "v2",
3831
+ sessions: 0,
3832
+ turns: 0,
3833
+ tools: 0,
3834
+ parts: 0,
3835
+ events: 0,
3836
+ flow: 0,
3837
+ heat: 0,
3838
+ models: 0,
3839
+ ms: 0
3840
+ };
3841
+ }
3842
+ return build(db, { mode: "incremental", sessionIds: changed, kind });
3843
+ }
3844
+
3845
+ // src/index.ts
3846
+ var REFRESH_MS = 12e4;
3847
+ var queue = Promise.resolve();
3848
+ function materialize(kind, opts = {}) {
3849
+ const run = queue.then(() => doMaterialize(kind, opts), () => doMaterialize(kind, opts));
3850
+ queue = run.catch(() => void 0);
3851
+ return run;
3852
+ }
3853
+ async function doMaterialize(kind, { force = false, full = false }) {
3854
+ if (!hasStore(kind) || full) {
3855
+ console.log(`[osm] ${kind}: ${full ? "full rebuild requested" : "no store yet"} \u2014 materializing\u2026`);
3856
+ const r2 = await rebuild(kind, { full: true });
3857
+ console.log(
3858
+ `[osm] ${kind}: built ${r2.sessions} sessions / ${r2.turns} turns / ${r2.tools} tools / ${r2.parts} parts in ${r2.ms}ms`
3859
+ );
3860
+ return r2;
3861
+ }
3862
+ if (!force && !sourceChanged(kind)) return void 0;
3863
+ const r = await rebuild(kind);
3864
+ console.log(
3865
+ `[osm] ${kind}: ${r.mode} sync ${r.sessions} sessions / ${r.turns} turns / ${r.parts} parts / ${r.events} events in ${r.ms}ms`
3866
+ );
3867
+ return r;
3868
+ }
3869
+ var app = createApp({
3870
+ db: (kind) => current(kind),
3871
+ hasStore: (kind) => hasStore(kind),
3872
+ refresh: (kind, full) => materialize(kind, { force: true, full })
3873
+ });
3874
+ var webDist = path3.join(ROOT, "web", "dist");
3875
+ if (existsSync5(webDist)) {
3876
+ app.use(express2.static(webDist));
3877
+ app.get("*splat", (req, res) => {
3878
+ if (path3.extname(req.path)) return res.status(404).type("text/plain").send("not found");
3879
+ res.sendFile(path3.join(webDist, "index.html"));
3880
+ });
3881
+ console.log(`[osm] serving built web from ${webDist}`);
3882
+ }
3883
+ app.listen(PORT, HOST, async () => {
3884
+ console.log(`[osm] api on http://${HOST}:${PORT} (default source: ${SOURCE_KIND})`);
3885
+ await materialize(SOURCE_KIND);
3886
+ setInterval(() => {
3887
+ for (const kind of SOURCE_KINDS) {
3888
+ if (!hasStore(kind)) continue;
3889
+ void materialize(kind).catch((e) => console.error(`[osm] ${kind} refresh failed:`, e.message));
3890
+ }
3891
+ }, REFRESH_MS);
3892
+ });