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