@kitsunemori96/opencode-session-metrics 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  // src/index.ts
2
2
  import express2 from "express";
3
- import { existsSync as existsSync3 } from "node:fs";
4
- import path2 from "node:path";
3
+ import { existsSync as existsSync5 } from "node:fs";
4
+ import path3 from "node:path";
5
5
 
6
6
  // src/config.ts
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
+ import { existsSync } from "node:fs";
9
10
  import { fileURLToPath } from "node:url";
10
11
  var ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
11
12
  var INSTALLED = `${path.sep}node_modules${path.sep}`;
@@ -14,8 +15,28 @@ var USER_DATA_DIR = path.join(
14
15
  "opencode-session-metrics"
15
16
  );
16
17
  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
18
  var SOURCE_DB = process.env.OSM_SOURCE_DB ?? path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
19
+ var QODER_HOME = process.env.OSM_QODER_HOME ?? path.join(os.homedir(), ".qoder");
20
+ var QODER_PROJECTS = path.join(QODER_HOME, "projects");
21
+ var QODER_LOGS = path.join(QODER_HOME, "logs", "sessions");
22
+ var SOURCE_KINDS = ["opencode", "qoder"];
23
+ function resolveSourceKind() {
24
+ const explicit = process.env.OSM_SOURCE?.trim();
25
+ if (explicit === "qoder" || explicit === "opencode") return explicit;
26
+ if (process.env.OSM_SOURCE_DB) return "opencode";
27
+ if (existsSync(SOURCE_DB)) return "opencode";
28
+ if (existsSync(QODER_PROJECTS)) return "qoder";
29
+ return "opencode";
30
+ }
31
+ var SOURCE_KIND = resolveSourceKind();
32
+ var METRICS_DB_BY_KIND = {
33
+ opencode: path.join(DATA_DIR, "metrics.db"),
34
+ qoder: path.join(DATA_DIR, "metrics-qoder.db")
35
+ };
36
+ var metricsDbPath = (kind) => METRICS_DB_BY_KIND[kind];
37
+ var METRICS_DB = METRICS_DB_BY_KIND.opencode;
38
+ var sourceProbe = (kind) => kind === "qoder" ? QODER_PROJECTS : SOURCE_DB;
39
+ var sourceAvailable = (kind) => existsSync(sourceProbe(kind));
19
40
  var MODEL_CATALOG = path.join(DATA_DIR, "model-catalog.json");
20
41
  var MODEL_OVERRIDES = path.join(DATA_DIR, "model-overrides.json");
21
42
  var HOST = process.env.OSM_HOST ?? "127.0.0.1";
@@ -420,9 +441,9 @@ function rawHourSource(slice) {
420
441
 
421
442
  // src/metrics/schema.ts
422
443
  import { mkdirSync } from "node:fs";
423
- function openMetrics() {
444
+ function openMetrics(file = METRICS_DB) {
424
445
  mkdirSync(DATA_DIR, { recursive: true });
425
- const db = new DatabaseSync(METRICS_DB);
446
+ const db = new DatabaseSync(file);
426
447
  db.exec("PRAGMA journal_mode = WAL");
427
448
  db.exec("PRAGMA foreign_keys = OFF");
428
449
  ensureIndexes(db);
@@ -447,6 +468,7 @@ CREATE TABLE IF NOT EXISTS turns (
447
468
  role TEXT, provider TEXT, model TEXT, variant TEXT, agent TEXT,
448
469
  input INTEGER, output INTEGER, reasoning INTEGER,
449
470
  cache_read INTEGER, cache_write INTEGER, cost REAL,
471
+ credits_micro INTEGER DEFAULT 0,
450
472
  prompt_total INTEGER, occupancy INTEGER, context_window INTEGER,
451
473
  fill REAL, fill_after REAL,
452
474
  bucket INTEGER, day INTEGER, hour INTEGER,
@@ -498,7 +520,7 @@ CREATE TABLE IF NOT EXISTS sessions (
498
520
  agent TEXT, provider TEXT, model TEXT, variant TEXT, parent_id TEXT, depth INTEGER,
499
521
  created INTEGER, updated INTEGER,
500
522
  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,
523
+ cost REAL, credits_micro INTEGER DEFAULT 0, turns INTEGER, tool_calls INTEGER, tool_errors INTEGER,
502
524
  compactions INTEGER, retries INTEGER, loops INTEGER,
503
525
  additions INTEGER, deletions INTEGER, files_changed INTEGER,
504
526
  peak_fill REAL, avg_fill REAL, eff_score REAL, tokens_per_line REAL,
@@ -1339,19 +1361,19 @@ function toolDetail(db, raw, name) {
1339
1361
  let chars = 0;
1340
1362
  let failures = 0;
1341
1363
  for (const r of rows) {
1342
- const ms = num(r.ms);
1364
+ const ms2 = num(r.ms);
1343
1365
  const isError = r.status === "error";
1344
1366
  const b = buckets[idx(num(r.started) || slice.to - 1)];
1345
1367
  b.calls++;
1346
1368
  if (isError) b.errors++;
1347
- b.totalMs += ms;
1369
+ b.totalMs += ms2;
1348
1370
  if (isError) failures++;
1349
1371
  additions += num(r.adds);
1350
1372
  deletions += num(r.dels);
1351
1373
  files += num(r.files);
1352
1374
  chars += num(r.chars);
1353
1375
  for (let k = 0; k < LAT_BINS.length; k++) {
1354
- if (ms >= LAT_BINS[k][1] && ms < LAT_BINS[k][2]) {
1376
+ if (ms2 >= LAT_BINS[k][1] && ms2 < LAT_BINS[k][2]) {
1355
1377
  latency[k].count++;
1356
1378
  break;
1357
1379
  }
@@ -1463,10 +1485,20 @@ function facets(db) {
1463
1485
  // src/api.ts
1464
1486
  function createApp(deps) {
1465
1487
  const app2 = express();
1466
- app2.use(cors());
1488
+ app2.use(cors({ exposedHeaders: ["x-osm-source"] }));
1467
1489
  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);
1490
+ const kindOf = (req) => {
1491
+ const raw = String(req.get("x-osm-source") ?? req.query.src ?? "").trim().toLowerCase();
1492
+ return raw === "qoder" || raw === "opencode" ? raw : SOURCE_KIND;
1493
+ };
1494
+ const storeOf = (req, res) => {
1495
+ const kind = kindOf(req);
1496
+ if (deps.hasStore(kind)) return deps.db(kind);
1497
+ res.status(503).json({ error: "source not materialized", source: kind });
1498
+ return null;
1499
+ };
1500
+ const q = (db, sql, ...args) => db.prepare(sql).all(...args);
1501
+ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
1470
1502
  const cutoffOf = (req) => {
1471
1503
  const raw = Number(req.query.from ?? 0);
1472
1504
  return Number.isFinite(raw) && raw > 0 ? raw : 0;
@@ -1489,15 +1521,44 @@ function createApp(deps) {
1489
1521
  gran: isGranularity(gran) ? gran : null
1490
1522
  };
1491
1523
  };
1492
- app2.get("/api/status", (_req, res) => {
1493
- const db = deps.db();
1494
- const count = (t) => one(`SELECT COUNT(*) c FROM ${t}`)?.c ?? 0;
1524
+ app2.get("/api/status", (req, res) => {
1525
+ const kind = kindOf(req);
1526
+ if (!deps.hasStore(kind)) {
1527
+ return res.json({
1528
+ ready: false,
1529
+ sourceDb: sourceProbe(kind),
1530
+ sourceSchema: null,
1531
+ sourceKind: kind,
1532
+ unit: null,
1533
+ tokenClasses: false,
1534
+ sourceMaxTime: 0,
1535
+ materializedAt: 0,
1536
+ mode: null,
1537
+ bucketMs: BUCKET_MS,
1538
+ extent: { from: 0, to: Date.now() },
1539
+ counts: { sessions: 0, turns: 0, tools: 0, parts: 0, events: 0, models: 0 }
1540
+ });
1541
+ }
1542
+ const db = deps.db(kind);
1543
+ const count = (t) => one(db, `SELECT COUNT(*) c FROM ${t}`)?.c ?? 0;
1495
1544
  const extent = one(
1545
+ db,
1496
1546
  `SELECT MIN(created) AS from_ts, MAX(COALESCE(updated, created)) AS to_ts FROM sessions`
1497
1547
  ) ?? { from_ts: null, to_ts: null };
1548
+ const tokenClasses = !!one(
1549
+ db,
1550
+ `SELECT 1 AS hit FROM turns
1551
+ WHERE role = 'assistant'
1552
+ AND (output > 0 OR cache_read > 0 OR cache_write > 0 OR reasoning > 0)
1553
+ LIMIT 1`
1554
+ );
1498
1555
  res.json({
1556
+ ready: true,
1499
1557
  sourceDb: getMeta(db, "sourceDb"),
1500
1558
  sourceSchema: getMeta(db, "sourceSchema"),
1559
+ sourceKind: getMeta(db, "sourceKind") ?? kind,
1560
+ unit: getMeta(db, "unit") ?? "usd",
1561
+ tokenClasses,
1501
1562
  sourceMaxTime: getMetaNumber(db, "sourceMaxTime"),
1502
1563
  materializedAt: getMetaNumber(db, "materializedAt"),
1503
1564
  mode: getMeta(db, "mode"),
@@ -1513,18 +1574,57 @@ function createApp(deps) {
1513
1574
  }
1514
1575
  });
1515
1576
  });
1577
+ app2.get("/api/sources", (_req, res) => {
1578
+ const describe = (kind) => {
1579
+ const available = sourceAvailable(kind);
1580
+ const ready2 = deps.hasStore(kind);
1581
+ const base = {
1582
+ kind,
1583
+ label: kind,
1584
+ available,
1585
+ ready: ready2,
1586
+ path: sourceProbe(kind),
1587
+ store: metricsDbPath(kind)
1588
+ };
1589
+ if (!ready2) return { ...base, unit: null, tokenClasses: null, sourceSchema: null, materializedAt: 0, counts: null };
1590
+ const db = deps.db(kind);
1591
+ const count = (t) => one(db, `SELECT COUNT(*) c FROM ${t}`)?.c ?? 0;
1592
+ return {
1593
+ ...base,
1594
+ unit: getMeta(db, "unit") ?? "usd",
1595
+ // Same probe as `/api/status`, answered here as well so the shell knows the
1596
+ // store's shape before it draws: the Overview's default stack depends on it,
1597
+ // and the source list is resolved before the first frame.
1598
+ tokenClasses: !!one(
1599
+ db,
1600
+ `SELECT 1 AS hit FROM turns
1601
+ WHERE role = 'assistant'
1602
+ AND (output > 0 OR cache_read > 0 OR cache_write > 0 OR reasoning > 0)
1603
+ LIMIT 1`
1604
+ ),
1605
+ sourceSchema: getMeta(db, "sourceSchema"),
1606
+ materializedAt: getMetaNumber(db, "materializedAt"),
1607
+ counts: { sessions: count("sessions"), turns: count("turns"), tools: count("tools") }
1608
+ };
1609
+ };
1610
+ res.json({ default: SOURCE_KIND, sources: SOURCE_KINDS.map(describe) });
1611
+ });
1516
1612
  app2.post("/api/refresh", async (req, res) => {
1517
1613
  try {
1518
1614
  const full = req.body?.full === true;
1519
- const report = await deps.refresh(full);
1520
- res.json({ ok: true, full, report });
1615
+ const kind = kindOf(req);
1616
+ const report = await deps.refresh(kind, full);
1617
+ res.json({ ok: true, full, source: kind, report });
1521
1618
  } catch (e) {
1522
1619
  res.status(500).json({ ok: false, error: e.message });
1523
1620
  }
1524
1621
  });
1525
1622
  app2.get("/api/top/session/:id/messages", (req, res) => {
1623
+ const db = storeOf(req, res);
1624
+ if (!db) return;
1526
1625
  const id = req.params.id;
1527
1626
  const session = one(
1627
+ db,
1528
1628
  `SELECT id, parent_id, project, title, agent, model, provider, created, updated,
1529
1629
  t_input, t_output, t_reasoning, t_cache_read, t_cache_write, cost, turns, first_prompt
1530
1630
  FROM sessions WHERE id = ?`,
@@ -1532,6 +1632,7 @@ function createApp(deps) {
1532
1632
  );
1533
1633
  if (!session) return res.status(404).json({ error: "unknown session" });
1534
1634
  const turns = q(
1635
+ db,
1535
1636
  `SELECT id, seq, model, provider, agent, created, completed, input, output, reasoning,
1536
1637
  cache_read, cache_write, cost, dur_ms, finish, tool_calls, tool_errors,
1537
1638
  project, occupancy, context_window, fill, fill_after, additions, deletions, write_lines, has_error
@@ -1540,12 +1641,14 @@ function createApp(deps) {
1540
1641
  id
1541
1642
  );
1542
1643
  const prompts = q(
1644
+ db,
1543
1645
  `SELECT id, created, text FROM turns
1544
1646
  WHERE session_id = ? AND role = 'user' AND length(trim(coalesce(text, ''))) > 0
1545
1647
  ORDER BY created ASC, seq ASC`,
1546
1648
  id
1547
1649
  );
1548
1650
  const parts = q(
1651
+ db,
1549
1652
  `SELECT turn_id, ordinal, kind, text, name, call_id, status, title, input, output,
1550
1653
  exit_code, truncated, started, ended, chars
1551
1654
  FROM parts WHERE session_id = ? ORDER BY turn_id, ordinal, id`,
@@ -1621,24 +1724,30 @@ function createApp(deps) {
1621
1724
  prompts: prompts.map((p) => ({ id: p.id, created: Number(p.created ?? 0), text: p.text ?? "" }))
1622
1725
  });
1623
1726
  });
1624
- app2.get("/api/top/facets", (_req, res) => {
1625
- res.json(facets(deps.db()));
1727
+ app2.get("/api/top/facets", (req, res) => {
1728
+ const db = storeOf(req, res);
1729
+ if (!db) return;
1730
+ res.json(facets(db));
1626
1731
  });
1627
1732
  app2.get("/api/top/analytics", (req, res) => {
1628
- res.json(dashboard(deps.db(), sliceOf(req)));
1733
+ const db = storeOf(req, res);
1734
+ if (!db) return;
1735
+ res.json(dashboard(db, sliceOf(req)));
1629
1736
  });
1630
1737
  app2.get("/api/top/sessions", (req, res) => {
1738
+ const db = storeOf(req, res);
1739
+ if (!db) return;
1631
1740
  const focus = str(req, "focus");
1632
1741
  const slice = sliceOf(req);
1633
1742
  if (focus) {
1634
- const rows = sessionContext(deps.db(), slice, focus);
1743
+ const rows = sessionContext(db, slice, focus);
1635
1744
  if (!rows.length) return res.status(404).json({ error: "session not in this window" });
1636
1745
  return res.json({ rows, matching: rows.length, roots: 0, next: null, totals: null });
1637
1746
  }
1638
1747
  const order = str(req, "order");
1639
1748
  const limit = Number(req.query.limit ?? MAX_PAGE_ROOTS);
1640
1749
  res.json(
1641
- sessionsPage(deps.db(), slice, {
1750
+ sessionsPage(db, slice, {
1642
1751
  q: str(req, "q"),
1643
1752
  order: SESSION_ORDERS.includes(order ?? "") ? order : "updated",
1644
1753
  dir: req.query.dir === "asc" ? "asc" : "desc",
@@ -1648,17 +1757,22 @@ function createApp(deps) {
1648
1757
  );
1649
1758
  });
1650
1759
  app2.get("/api/top/tool", (req, res) => {
1760
+ const db = storeOf(req, res);
1761
+ if (!db) return;
1651
1762
  const name = str(req, "name");
1652
1763
  if (!name) return res.status(400).json({ error: "name is required" });
1653
- const detail = toolDetail(deps.db(), sliceOf(req), name);
1764
+ const detail = toolDetail(db, sliceOf(req), name);
1654
1765
  if (!detail.totals || detail.totals.calls === 0) return res.status(404).json({ error: "no calls for that tool in this window" });
1655
1766
  res.json(detail);
1656
1767
  });
1657
1768
  app2.get("/api/top/search", (req, res) => {
1769
+ const db = storeOf(req, res);
1770
+ if (!db) return;
1658
1771
  const raw = typeof req.query.q === "string" ? req.query.q.trim() : "";
1659
1772
  if (raw.length < 2) return res.json({ query: raw, hits: [] });
1660
1773
  const like = `%${raw.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
1661
1774
  const rows = q(
1775
+ db,
1662
1776
  `SELECT s.id, s.title, s.project, s.updated,
1663
1777
  (SELECT COUNT(*) FROM parts p WHERE p.session_id = s.id
1664
1778
  AND (p.text LIKE ? ESCAPE '\\' OR p.name LIKE ? ESCAPE '\\'
@@ -1695,7 +1809,7 @@ function createApp(deps) {
1695
1809
 
1696
1810
  // src/metrics/store.ts
1697
1811
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
1698
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync, rmSync, statSync } from "node:fs";
1812
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, renameSync, rmSync, statSync as statSync2 } from "node:fs";
1699
1813
 
1700
1814
  // src/source.ts
1701
1815
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
@@ -1802,9 +1916,13 @@ function hashSig(name, input) {
1802
1916
  }
1803
1917
  var SESSION_ID_RE = /ses_[A-Za-z0-9]{4,}/;
1804
1918
  var SourceReader = class {
1919
+ kind = "opencode";
1920
+ unit = "usd";
1921
+ path;
1805
1922
  db;
1806
1923
  schema;
1807
1924
  constructor(dbPath) {
1925
+ this.path = dbPath;
1808
1926
  this.db = new DatabaseSync2(dbPath, { readOnly: true });
1809
1927
  const hasV2 = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_v2'").get();
1810
1928
  const rows = hasV2 ? this.db.prepare("SELECT COUNT(*) AS c FROM session_v2").get().c : 0;
@@ -2237,8 +2355,596 @@ var SourceReader = class {
2237
2355
  }
2238
2356
  };
2239
2357
 
2358
+ // src/source-qoder.ts
2359
+ import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs";
2360
+ import path2 from "node:path";
2361
+ var QODER_MODEL_WINDOW = {
2362
+ qfmodel: 18e4,
2363
+ dfmodel: 1e6
2364
+ };
2365
+ var QODER_DEFAULT_WINDOW = 2e5;
2366
+ var COMPACTION_DROP_TOKENS = 5e4;
2367
+ var INTERNAL_PATH = /(^|\/)\.(qoder|opencode|osm)\//;
2368
+ var NOT_A_MODEL = /* @__PURE__ */ new Set(["", "<synthetic>", "unknown"]);
2369
+ var QODER_MODELS = {
2370
+ qfmodel: "Qwen3.8-Flash",
2371
+ dfmodel: "DeepSeek-Flash"
2372
+ };
2373
+ function modelWindow(model) {
2374
+ if (!model) return QODER_DEFAULT_WINDOW;
2375
+ const alias = QODER_MODEL_WINDOW[model] !== void 0 ? model : Object.keys(QODER_MODELS).find((a) => QODER_MODELS[a] === model);
2376
+ return QODER_MODEL_WINDOW[alias ?? model] ?? QODER_DEFAULT_WINDOW;
2377
+ }
2378
+ var num3 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
2379
+ function ms(value) {
2380
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2381
+ if (typeof value === "string") {
2382
+ const t = Date.parse(value);
2383
+ return Number.isFinite(t) ? t : 0;
2384
+ }
2385
+ return 0;
2386
+ }
2387
+ var micro = (credits) => Math.round(credits * 1e6);
2388
+ function lineCount(body) {
2389
+ if (typeof body !== "string" || !body) return 0;
2390
+ return body.split("\n").length;
2391
+ }
2392
+ function resultText(content) {
2393
+ if (typeof content === "string") return content;
2394
+ if (Array.isArray(content)) {
2395
+ return content.map((c) => typeof c === "string" ? c : c?.type === "text" ? String(c.text ?? "") : "").join("\n");
2396
+ }
2397
+ return "";
2398
+ }
2399
+ function toolTitle(name, input) {
2400
+ if (!input || typeof input !== "object") return null;
2401
+ const raw = input.description ?? input.file_path ?? input.path ?? input.toolName ?? (typeof input.command === "string" ? input.command.split("\n")[0] : null) ?? (typeof input.query === "string" ? input.query : null) ?? (typeof input.subject === "string" ? input.subject : null);
2402
+ if (typeof raw !== "string" || !raw.trim()) return null;
2403
+ return raw.trim().slice(0, 160);
2404
+ }
2405
+ var QoderSource = class {
2406
+ kind = "qoder";
2407
+ unit = "credit";
2408
+ schema = "qoder-jsonl";
2409
+ path;
2410
+ cache = /* @__PURE__ */ new Map();
2411
+ evidence = /* @__PURE__ */ new Map();
2412
+ logDirs = null;
2413
+ constructor(root = QODER_PROJECTS) {
2414
+ this.path = root;
2415
+ }
2416
+ close() {
2417
+ this.cache.clear();
2418
+ this.evidence.clear();
2419
+ }
2420
+ /**
2421
+ * Session id → its transcript files.
2422
+ *
2423
+ * A session owns `<root>/<encoded-cwd>/<sessionId>.jsonl` and, when it spawned
2424
+ * subagents, `<root>/<encoded-cwd>/<sessionId>/subagents/agent-*.jsonl`. Those
2425
+ * subagent rows carry the parent id in `sessionId` and bill to it, so they are
2426
+ * merged into the parent session rather than counted as sessions of their own.
2427
+ * In the corpus at hand that is 8 extra files, 131 turns and 8.7 credits — 1.3%
2428
+ * of the total, which silently missing would understate every session that
2429
+ * ever delegated work.
2430
+ */
2431
+ index() {
2432
+ const out = /* @__PURE__ */ new Map();
2433
+ if (!existsSync2(this.path)) return out;
2434
+ const add = (id, file) => {
2435
+ const list = out.get(id);
2436
+ if (list) list.push(file);
2437
+ else out.set(id, [file]);
2438
+ };
2439
+ for (const dir of readdirSync(this.path, { withFileTypes: true })) {
2440
+ if (!dir.isDirectory()) continue;
2441
+ const abs = path2.join(this.path, dir.name);
2442
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
2443
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
2444
+ add(entry.name.slice(0, -".jsonl".length), path2.join(abs, entry.name));
2445
+ continue;
2446
+ }
2447
+ if (!entry.isDirectory()) continue;
2448
+ const subs = path2.join(abs, entry.name, "subagents");
2449
+ if (!existsSync2(subs)) continue;
2450
+ for (const sub of readdirSync(subs)) if (sub.endsWith(".jsonl")) add(entry.name, path2.join(subs, sub));
2451
+ }
2452
+ }
2453
+ return out;
2454
+ }
2455
+ stamp(file) {
2456
+ try {
2457
+ const st = statSync(file);
2458
+ return { mtime: st.mtimeMs, size: st.size };
2459
+ } catch {
2460
+ return null;
2461
+ }
2462
+ }
2463
+ maxMessageTime() {
2464
+ let max = 0;
2465
+ for (const files of this.index().values())
2466
+ for (const f of files) max = Math.max(max, this.stamp(f)?.mtime ?? 0);
2467
+ return max;
2468
+ }
2469
+ changedSessions(since) {
2470
+ const out = /* @__PURE__ */ new Set();
2471
+ if (since <= 0) return out;
2472
+ for (const [id, files] of this.index()) {
2473
+ for (const f of files) {
2474
+ const st = this.stamp(f);
2475
+ if (st && st.mtime > since) {
2476
+ out.add(id);
2477
+ break;
2478
+ }
2479
+ }
2480
+ }
2481
+ return out;
2482
+ }
2483
+ all(sessionIds) {
2484
+ const out = [];
2485
+ for (const [id, files] of this.index()) {
2486
+ if (sessionIds && !sessionIds.has(id)) continue;
2487
+ const p = this.load(id, files);
2488
+ if (p) out.push(p);
2489
+ }
2490
+ return out;
2491
+ }
2492
+ sessions() {
2493
+ return this.all().map((p) => this.sessionOf(p));
2494
+ }
2495
+ turns(sessionIds) {
2496
+ return this.all(sessionIds).flatMap((p) => p.turns);
2497
+ }
2498
+ events(sessionIds) {
2499
+ return this.all(sessionIds).flatMap((p) => p.compactions);
2500
+ }
2501
+ /** Files touched by edit/write calls; `build` uses these to infer the project lane. */
2502
+ patchFileCounts(sessionIds) {
2503
+ const out = /* @__PURE__ */ new Map();
2504
+ for (const p of this.all(sessionIds)) {
2505
+ if (p.paths.length) out.set(p.id, { files: p.paths.length, paths: p.paths });
2506
+ }
2507
+ return out;
2508
+ }
2509
+ sessionOf(p) {
2510
+ const model = this.displayModel(p.id, p.models);
2511
+ return {
2512
+ id: p.id,
2513
+ parentId: null,
2514
+ project: projectName(p.directory),
2515
+ directory: p.directory,
2516
+ title: p.title,
2517
+ agent: null,
2518
+ provider: "qoder",
2519
+ model,
2520
+ variant: null,
2521
+ created: p.created,
2522
+ updated: p.updated,
2523
+ cost: p.credits,
2524
+ tInput: p.tInput,
2525
+ tOutput: 0,
2526
+ tReasoning: 0,
2527
+ tCacheRead: 0,
2528
+ tCacheWrite: 0,
2529
+ additions: p.additions,
2530
+ deletions: p.deletions,
2531
+ files: p.files,
2532
+ creditsMicro: p.creditsMicro
2533
+ };
2534
+ }
2535
+ /**
2536
+ * Session-level model naming. The alias and the shipped name never appear in the
2537
+ * same telemetry event, so this is inference:
2538
+ * - one alias + one real name → the real name;
2539
+ * - one alias + no evidence → the global map;
2540
+ * - several aliases (a mixed session) → all aliases, never a guess.
2541
+ */
2542
+ displayModel(id, models) {
2543
+ const aliases = [...new Set(models.filter((m) => !!m && !NOT_A_MODEL.has(m)))];
2544
+ if (aliases.length === 0) return "unknown";
2545
+ if (aliases.length > 1) return aliases.sort().join(" + ");
2546
+ const alias = aliases[0];
2547
+ const real = this.realModels(id);
2548
+ if (real.size === 1) return [...real][0];
2549
+ if (real.size === 0 && QODER_MODELS[alias]) return QODER_MODELS[alias];
2550
+ return alias;
2551
+ }
2552
+ /** Shipped names reported by hooks for this session (`hook_input.model`). */
2553
+ realModels(sessionId) {
2554
+ const hit = this.evidence.get(sessionId);
2555
+ if (hit) return hit;
2556
+ const out = /* @__PURE__ */ new Set();
2557
+ for (const enc of this.telemetryDirs()) {
2558
+ const dir = path2.join(QODER_LOGS, enc, sessionId, "segments");
2559
+ if (!existsSync2(dir)) continue;
2560
+ for (const f of readdirSync(dir)) {
2561
+ if (!f.endsWith(".jsonl")) continue;
2562
+ let body = "";
2563
+ try {
2564
+ body = readFileSync(path2.join(dir, f), "utf8");
2565
+ } catch {
2566
+ continue;
2567
+ }
2568
+ for (const line of body.split("\n")) {
2569
+ if (!line.includes("hook_input")) continue;
2570
+ try {
2571
+ const e = JSON.parse(line);
2572
+ const name = e?.data?.hook_input?.model;
2573
+ if (typeof name === "string" && name && name !== "unknown") out.add(name);
2574
+ } catch {
2575
+ }
2576
+ }
2577
+ }
2578
+ }
2579
+ this.evidence.set(sessionId, out);
2580
+ return out;
2581
+ }
2582
+ telemetryDirs() {
2583
+ if (this.logDirs) return this.logDirs;
2584
+ this.logDirs = existsSync2(QODER_LOGS) ? readdirSync(QODER_LOGS, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : [];
2585
+ return this.logDirs;
2586
+ }
2587
+ /** Parse every file of a session and merge them; cached by the files' stamps. */
2588
+ load(id, files) {
2589
+ const stamped = files.map((f) => ({ f, st: this.stamp(f) })).filter((x) => !!x.st).sort((a, b) => a.f.localeCompare(b.f));
2590
+ if (!stamped.length) return null;
2591
+ const sig = stamped.map((x) => `${x.f}:${x.st.mtime}:${x.st.size}`).join("|");
2592
+ const hit = this.cache.get(id);
2593
+ if (hit && hit.sig === sig) return hit.parsed;
2594
+ const parts = [];
2595
+ for (const { f, st } of stamped) {
2596
+ let body = "";
2597
+ try {
2598
+ body = readFileSync(f, "utf8");
2599
+ } catch {
2600
+ continue;
2601
+ }
2602
+ parts.push(this.parse(id, f, st.mtime, body));
2603
+ }
2604
+ if (!parts.length) return null;
2605
+ const parsed = parts.length === 1 ? parts[0] : this.merge(id, parts);
2606
+ const name = this.displayModel(id, parsed.models);
2607
+ if (name !== "unknown" && !name.includes(" + ")) {
2608
+ for (const t of parsed.turns) if (t.role === "assistant" && t.model) t.model = name;
2609
+ }
2610
+ if (this.cache.size > 64) this.cache.clear();
2611
+ this.cache.set(id, { sig, parsed });
2612
+ return parsed;
2613
+ }
2614
+ /** A parent transcript plus its subagent transcripts, as one session. */
2615
+ merge(id, parts) {
2616
+ const seen = /* @__PURE__ */ new Set();
2617
+ const turns = parts.flatMap((p) => p.turns).sort((a, b) => a.created - b.created).filter((t) => seen.has(t.id) ? false : (seen.add(t.id), true)).map((t, i) => ({ ...t, seq: i }));
2618
+ const paths = new Set(parts.flatMap((p) => p.paths));
2619
+ const sum = (pick) => parts.reduce((a, p) => a + pick(p), 0);
2620
+ const created = parts.map((p) => p.created).filter((t) => t > 0);
2621
+ return {
2622
+ id,
2623
+ file: parts[0].file,
2624
+ mtime: Math.max(...parts.map((p) => p.mtime)),
2625
+ directory: parts.find((p) => p.directory !== "/")?.directory ?? "/",
2626
+ title: parts.find((p) => p.title)?.title ?? null,
2627
+ created: created.length ? Math.min(...created) : 0,
2628
+ updated: Math.max(...parts.map((p) => p.updated)),
2629
+ turns,
2630
+ compactions: parts.flatMap((p) => p.compactions).sort((a, b) => a.at - b.at),
2631
+ paths: [...paths],
2632
+ models: parts.flatMap((p) => p.models),
2633
+ credits: sum((p) => p.credits),
2634
+ creditsMicro: sum((p) => p.creditsMicro),
2635
+ tInput: sum((p) => p.tInput),
2636
+ additions: sum((p) => p.additions),
2637
+ deletions: sum((p) => p.deletions),
2638
+ files: sum((p) => p.files)
2639
+ };
2640
+ }
2641
+ parse(id, file, mtime, body) {
2642
+ const order = [];
2643
+ const byId = /* @__PURE__ */ new Map();
2644
+ const prompts = [];
2645
+ const results = /* @__PURE__ */ new Map();
2646
+ const paths = /* @__PURE__ */ new Set();
2647
+ let title = null;
2648
+ let lastPrompt = null;
2649
+ let directory = "";
2650
+ let explicit = 0;
2651
+ let created = 0;
2652
+ let updated = 0;
2653
+ const touch = (ts) => {
2654
+ if (!ts) return;
2655
+ if (!created || ts < created) created = ts;
2656
+ if (ts > updated) updated = ts;
2657
+ };
2658
+ for (const line of body.split("\n")) {
2659
+ if (!line.trim()) continue;
2660
+ let e;
2661
+ try {
2662
+ e = JSON.parse(line);
2663
+ } catch {
2664
+ continue;
2665
+ }
2666
+ const type = e?.type;
2667
+ if (typeof e?.cwd === "string" && e.cwd) directory = e.cwd;
2668
+ touch(ms(e?.timestamp));
2669
+ if (type === "assistant") {
2670
+ const m = e.message ?? {};
2671
+ const mid = typeof m.id === "string" && m.id ? m.id : e.uuid;
2672
+ if (!mid) continue;
2673
+ let entry = byId.get(mid);
2674
+ if (!entry) {
2675
+ entry = {
2676
+ id: mid,
2677
+ ts: ms(e.timestamp),
2678
+ model: m.model ?? null,
2679
+ stop: m.stop_reason ?? null,
2680
+ window: explicit,
2681
+ blocks: [],
2682
+ usage: null
2683
+ };
2684
+ byId.set(mid, entry);
2685
+ order.push(entry);
2686
+ }
2687
+ if (m.stop_reason) entry.stop = m.stop_reason;
2688
+ if (m.model) entry.model = m.model;
2689
+ if (Array.isArray(m.content)) {
2690
+ for (const b of m.content) if (b && typeof b === "object" && b.type) entry.blocks.push(b);
2691
+ }
2692
+ if (m.usage && typeof m.usage === "object") {
2693
+ entry.usage = {
2694
+ credits: num3(m.usage.credits),
2695
+ original: num3(m.usage.original_credits ?? m.usage.credits),
2696
+ ratio: typeof m.usage.context_usage_ratio === "number" ? m.usage.context_usage_ratio : null,
2697
+ request: typeof m.usage.request_id === "string" ? m.usage.request_id : null
2698
+ };
2699
+ }
2700
+ continue;
2701
+ }
2702
+ if (type === "user") {
2703
+ const m = e.message ?? {};
2704
+ const content = m.content;
2705
+ const ts = ms(e.timestamp);
2706
+ if (typeof content === "string") {
2707
+ if (content.trim()) prompts.push({ id: String(e.uuid ?? `u${prompts.length}`), ts, text: content });
2708
+ continue;
2709
+ }
2710
+ if (!Array.isArray(content)) continue;
2711
+ let text = "";
2712
+ for (const b of content) {
2713
+ if (!b || typeof b !== "object") continue;
2714
+ if (b.type === "text" && typeof b.text === "string") text += b.text;
2715
+ if (b.type === "tool_result" && typeof b.tool_use_id === "string") {
2716
+ results.set(b.tool_use_id, {
2717
+ text: resultText(b.content),
2718
+ error: b.is_error === true,
2719
+ ts
2720
+ });
2721
+ }
2722
+ }
2723
+ if (text.trim()) prompts.push({ id: String(e.uuid ?? `u${prompts.length}`), ts, text });
2724
+ continue;
2725
+ }
2726
+ if (type === "runtime-config") {
2727
+ explicit = typeof e.contextWindow === "number" && e.contextWindow > 0 ? e.contextWindow : 0;
2728
+ continue;
2729
+ }
2730
+ if (type === "ai-title" && typeof e.aiTitle === "string") {
2731
+ title = e.aiTitle;
2732
+ continue;
2733
+ }
2734
+ if (type === "workspace-directories" && Array.isArray(e.directories) && e.directories[0]) {
2735
+ directory = String(e.directories[0]);
2736
+ continue;
2737
+ }
2738
+ if (type === "last-prompt" && typeof e.lastPrompt === "string") lastPrompt = e.lastPrompt;
2739
+ }
2740
+ const items = [];
2741
+ const models = [];
2742
+ let credits = 0;
2743
+ let tInput = 0;
2744
+ let additions = 0;
2745
+ let deletions = 0;
2746
+ let files = 0;
2747
+ const ratios = [];
2748
+ for (const entry of order) {
2749
+ const usage = entry.usage;
2750
+ const ratio = usage?.ratio ?? null;
2751
+ const contextWindow = entry.window || modelWindow(entry.model);
2752
+ const input = ratio == null ? 0 : Math.round(ratio * contextWindow);
2753
+ const toolCalls = [];
2754
+ const parts = [];
2755
+ const seenCalls = /* @__PURE__ */ new Set();
2756
+ let text = "";
2757
+ let add = 0;
2758
+ let del = 0;
2759
+ let fileCount = 0;
2760
+ let writeLines = 0;
2761
+ for (const b of entry.blocks) {
2762
+ if (b.type === "text") {
2763
+ const value = String(b.text ?? "");
2764
+ text += value;
2765
+ if (value.trim()) parts.push(bodyPart("text", value));
2766
+ continue;
2767
+ }
2768
+ if (b.type === "thinking") {
2769
+ const value = String(b.thinking ?? b.text ?? "");
2770
+ if (value.trim()) parts.push(bodyPart("reasoning", value));
2771
+ continue;
2772
+ }
2773
+ if (b.type !== "tool_use") continue;
2774
+ const callId = String(b.id ?? `${entry.id}:${toolCalls.length}`);
2775
+ if (seenCalls.has(callId)) continue;
2776
+ seenCalls.add(callId);
2777
+ const name = String(b.name ?? "unknown");
2778
+ const inputJson = b.input ?? {};
2779
+ const result = results.get(callId);
2780
+ const status = result ? result.error ? "error" : "completed" : "pending";
2781
+ const filePath = typeof inputJson?.file_path === "string" ? inputJson.file_path : null;
2782
+ const touched = pathsOf(inputJson, null);
2783
+ for (const p of touched) if (!INTERNAL_PATH.test(p)) paths.add(p);
2784
+ const toolAdd = name === "Edit" ? lineCount(inputJson?.new_string) : 0;
2785
+ const toolDel = name === "Edit" ? lineCount(inputJson?.old_string) : 0;
2786
+ const write = name === "Write" ? lineCount(inputJson?.content) : 0;
2787
+ add += toolAdd;
2788
+ del += toolDel;
2789
+ writeLines += write;
2790
+ if (filePath) fileCount += 1;
2791
+ toolCalls.push({
2792
+ turnId: entry.id,
2793
+ sessionId: id,
2794
+ callId,
2795
+ name,
2796
+ status,
2797
+ started: entry.ts,
2798
+ ended: result?.ts ?? null,
2799
+ title: toolTitle(name, inputJson),
2800
+ outputChars: result?.text.length ?? 0,
2801
+ additions: toolAdd,
2802
+ deletions: toolDel,
2803
+ files: filePath ? 1 : 0,
2804
+ writeLines: write,
2805
+ error: result?.error ? result.text.slice(0, 500) || "tool error" : null,
2806
+ sig: hashSig(name, inputJson),
2807
+ inputPreview: inputJson ? JSON.stringify(inputJson).slice(0, 400) : null,
2808
+ targetSession: null,
2809
+ paths: touched.filter((p) => !INTERNAL_PATH.test(p))
2810
+ });
2811
+ parts.push({
2812
+ kind: "tool",
2813
+ text: "",
2814
+ name,
2815
+ callId,
2816
+ status,
2817
+ title: toolTitle(name, inputJson),
2818
+ input: (inputJson ? JSON.stringify(inputJson) : "").slice(0, PART_CAP.input),
2819
+ output: (result?.text ?? "").slice(0, PART_CAP.output),
2820
+ exitCode: null,
2821
+ truncated: (result?.text.length ?? 0) > PART_CAP.output,
2822
+ started: entry.ts,
2823
+ ended: result?.ts ?? null,
2824
+ chars: result?.text.length ?? 0
2825
+ });
2826
+ }
2827
+ const turnCredits = usage?.credits ?? 0;
2828
+ const synthetic = entry.model === "<synthetic>";
2829
+ const turn = {
2830
+ id: entry.id,
2831
+ sessionId: id,
2832
+ seq: 0,
2833
+ created: entry.ts,
2834
+ completed: entry.ts,
2835
+ role: synthetic ? "system" : "assistant",
2836
+ provider: "qoder",
2837
+ model: entry.model && !NOT_A_MODEL.has(entry.model) ? entry.model : null,
2838
+ variant: null,
2839
+ agent: null,
2840
+ input,
2841
+ output: 0,
2842
+ reasoning: 0,
2843
+ cacheRead: 0,
2844
+ cacheWrite: 0,
2845
+ cost: turnCredits,
2846
+ finish: entry.stop,
2847
+ text,
2848
+ tools: toolCalls,
2849
+ parts,
2850
+ prompt: null,
2851
+ creditsMicro: micro(turnCredits),
2852
+ contextWindow,
2853
+ _add: add,
2854
+ _del: del,
2855
+ _files: fileCount,
2856
+ _write: writeLines
2857
+ };
2858
+ items.push({ ts: entry.ts, turn });
2859
+ if (entry.model) models.push(entry.model);
2860
+ credits += turnCredits;
2861
+ tInput += input;
2862
+ additions += add;
2863
+ deletions += del;
2864
+ files += fileCount;
2865
+ if (ratio != null) ratios.push({ ts: entry.ts, tokens: input });
2866
+ }
2867
+ for (const p of prompts) {
2868
+ if (!p.ts) continue;
2869
+ items.push({
2870
+ ts: p.ts,
2871
+ turn: {
2872
+ id: p.id,
2873
+ sessionId: id,
2874
+ seq: 0,
2875
+ created: p.ts,
2876
+ completed: p.ts,
2877
+ role: "user",
2878
+ provider: null,
2879
+ model: null,
2880
+ variant: null,
2881
+ agent: null,
2882
+ input: 0,
2883
+ output: 0,
2884
+ reasoning: 0,
2885
+ cacheRead: 0,
2886
+ cacheWrite: 0,
2887
+ cost: 0,
2888
+ finish: null,
2889
+ text: p.text,
2890
+ tools: [],
2891
+ parts: [],
2892
+ prompt: p.text.slice(0, 400),
2893
+ creditsMicro: 0
2894
+ }
2895
+ });
2896
+ }
2897
+ items.sort((a, b) => a.ts - b.ts);
2898
+ const turns = items.map((it, i) => ({ ...it.turn, seq: i }));
2899
+ const compactions = [];
2900
+ let prev = null;
2901
+ for (const r of ratios.sort((a, b) => a.ts - b.ts)) {
2902
+ if (prev && r.tokens < prev.tokens - COMPACTION_DROP_TOKENS) {
2903
+ compactions.push({
2904
+ sessionId: id,
2905
+ turnId: null,
2906
+ type: "compaction",
2907
+ // Sit between the two turns: `build` derives before/after from the turns
2908
+ // around the event, and the drop itself happened in that gap.
2909
+ at: Math.round((prev.ts + r.ts) / 2),
2910
+ reason: "auto",
2911
+ detail: "context reset (context drop)",
2912
+ tokensBefore: prev.tokens,
2913
+ tokensAfter: r.tokens,
2914
+ model: null
2915
+ });
2916
+ }
2917
+ prev = r;
2918
+ }
2919
+ return {
2920
+ id,
2921
+ file,
2922
+ mtime,
2923
+ directory: directory || "/",
2924
+ title: title ?? (lastPrompt ? lastPrompt.split("\n")[0].slice(0, 120) : null),
2925
+ created,
2926
+ updated,
2927
+ turns,
2928
+ compactions,
2929
+ paths: [...paths],
2930
+ models,
2931
+ credits,
2932
+ creditsMicro: micro(credits),
2933
+ tInput,
2934
+ additions,
2935
+ deletions,
2936
+ files
2937
+ };
2938
+ }
2939
+ };
2940
+
2941
+ // src/source-select.ts
2942
+ function openSource(kind = SOURCE_KIND) {
2943
+ return kind === "qoder" ? new QoderSource(QODER_PROJECTS) : new SourceReader(SOURCE_DB);
2944
+ }
2945
+
2240
2946
  // src/metrics/catalog.ts
2241
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
2947
+ import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync3 } from "node:fs";
2242
2948
  var FALLBACK_CONTEXT = 128e3;
2243
2949
  var CATALOG_TTL_MS = 7 * 864e5;
2244
2950
  var catalogKey = (provider, model) => `${provider ?? "unknown"}/${model ?? "unknown"}`;
@@ -2269,9 +2975,9 @@ function normalize(modelID, providerID, m) {
2269
2975
  };
2270
2976
  }
2271
2977
  function readCache() {
2272
- if (!existsSync(MODEL_CATALOG)) return { fetchedAt: 0, models: {} };
2978
+ if (!existsSync3(MODEL_CATALOG)) return { fetchedAt: 0, models: {} };
2273
2979
  try {
2274
- const parsed = JSON.parse(readFileSync(MODEL_CATALOG, "utf8"));
2980
+ const parsed = JSON.parse(readFileSync2(MODEL_CATALOG, "utf8"));
2275
2981
  if (parsed && typeof parsed === "object" && "models" in parsed && parsed.models) {
2276
2982
  return { fetchedAt: Number(parsed.fetchedAt ?? 0), models: parsed.models };
2277
2983
  }
@@ -2281,9 +2987,9 @@ function readCache() {
2281
2987
  }
2282
2988
  }
2283
2989
  function readOverrides() {
2284
- if (!existsSync(MODEL_OVERRIDES)) return {};
2990
+ if (!existsSync3(MODEL_OVERRIDES)) return {};
2285
2991
  try {
2286
- return JSON.parse(readFileSync(MODEL_OVERRIDES, "utf8"));
2992
+ return JSON.parse(readFileSync2(MODEL_OVERRIDES, "utf8"));
2287
2993
  } catch {
2288
2994
  return {};
2289
2995
  }
@@ -2393,13 +3099,13 @@ function inferProject(paths, fallback, accounts) {
2393
3099
  for (const [label, v] of votes) if (v > bestV) bestV = v, best = label;
2394
3100
  return best;
2395
3101
  }
2396
- function accountOf(path3) {
2397
- return (path3 ?? "").replace(/^\/mnt\/[a-zA-Z]\//, "/").match(/^\/(?:home|Users)\/([^/]+)$/)?.[1] ?? null;
3102
+ function accountOf(path4) {
3103
+ return (path4 ?? "").replace(/^\/mnt\/[a-zA-Z]\//, "/").match(/^\/(?:home|Users)\/([^/]+)$/)?.[1] ?? null;
2398
3104
  }
2399
3105
  async function build(db, opts = {}) {
2400
3106
  const t0 = Date.now();
2401
3107
  const mode = opts.mode ?? "full";
2402
- const src = new SourceReader(opts.sourceDb ?? SOURCE_DB);
3108
+ const src = openSource(opts.kind);
2403
3109
  try {
2404
3110
  initSchema(db, { drop: mode === "full" });
2405
3111
  ensureRollups(db);
@@ -2454,7 +3160,7 @@ async function build(db, opts = {}) {
2454
3160
  const m = info(t.provider, t.model);
2455
3161
  const promptTotal = t.input + t.cacheRead + t.cacheWrite;
2456
3162
  const occupancy = promptTotal + t.output + t.reasoning;
2457
- const contextWindow = m?.context ?? 128e3;
3163
+ const contextWindow = t.contextWindow ?? m?.context ?? 128e3;
2458
3164
  const costComputed = m ? (t.input * m.costInput + t.output * m.costOutput + t.reasoning * m.costOutput + t.cacheRead * m.costCacheRead + t.cacheWrite * m.costCacheWrite) / 1e6 : 0;
2459
3165
  return {
2460
3166
  ...t,
@@ -2529,6 +3235,7 @@ async function build(db, opts = {}) {
2529
3235
  "cache_read",
2530
3236
  "cache_write",
2531
3237
  "cost",
3238
+ "credits_micro",
2532
3239
  "prompt_total",
2533
3240
  "occupancy",
2534
3241
  "context_window",
@@ -2577,6 +3284,7 @@ async function build(db, opts = {}) {
2577
3284
  t.cacheRead,
2578
3285
  t.cacheWrite,
2579
3286
  t.costComputed,
3287
+ t.creditsMicro ?? 0,
2580
3288
  t.promptTotal,
2581
3289
  t.occupancy,
2582
3290
  t.contextWindow,
@@ -2687,6 +3395,7 @@ async function build(db, opts = {}) {
2687
3395
  "t_cache_read",
2688
3396
  "t_cache_write",
2689
3397
  "cost",
3398
+ "credits_micro",
2690
3399
  "turns",
2691
3400
  "tool_calls",
2692
3401
  "tool_errors",
@@ -2743,6 +3452,7 @@ async function build(db, opts = {}) {
2743
3452
  const compactions = sessionEvents.filter((e) => e.type === "compaction").length;
2744
3453
  const retries = sessionEvents.filter((e) => e.type === "retry").length;
2745
3454
  const loops = sessionEvents.filter((e) => e.type === "loop").length;
3455
+ const creditsMicro = s.creditsMicro || ts.reduce((a, t) => a + (t.creditsMicro ?? 0), 0);
2746
3456
  insSession.run(
2747
3457
  s.id,
2748
3458
  s.project,
@@ -2762,6 +3472,7 @@ async function build(db, opts = {}) {
2762
3472
  tCacheRead,
2763
3473
  tCacheWrite,
2764
3474
  Math.max(s.cost, ts.reduce((a, t) => a + t.costComputed, 0)),
3475
+ creditsMicro,
2765
3476
  ts.length,
2766
3477
  toolCalls,
2767
3478
  toolErrors,
@@ -2793,8 +3504,10 @@ async function build(db, opts = {}) {
2793
3504
  upModel.run(key, m.provider, m.label, m.context, m.costInput, m.costOutput, m.costCacheRead, m.costCacheWrite, m.source);
2794
3505
  }
2795
3506
  refreshRollups(db, idList, [...affectedDays], mode === "full");
2796
- setMeta(db, "sourceDb", opts.sourceDb ?? SOURCE_DB);
3507
+ setMeta(db, "sourceDb", src.path);
2797
3508
  setMeta(db, "sourceSchema", src.schema);
3509
+ setMeta(db, "sourceKind", src.kind);
3510
+ setMeta(db, "unit", src.unit);
2798
3511
  setMeta(db, "sourceMaxTime", String(src.maxMessageTime()));
2799
3512
  setMeta(db, "materializedAt", String(Date.now()));
2800
3513
  setMeta(db, "bucketMs", String(BUCKET_MS));
@@ -3014,42 +3727,54 @@ function recomputeHeat(db, days) {
3014
3727
  }
3015
3728
 
3016
3729
  // src/metrics/store.ts
3017
- var NEXT = METRICS_DB + ".next";
3018
- var handle = null;
3019
- var handleStat = null;
3020
- function fileIdentity() {
3730
+ var slots = /* @__PURE__ */ new Map();
3731
+ function slotOf(kind) {
3732
+ let slot = slots.get(kind);
3733
+ if (!slot) {
3734
+ slot = { handle: null, stat: null };
3735
+ slots.set(kind, slot);
3736
+ }
3737
+ return slot;
3738
+ }
3739
+ function fileIdentity(kind) {
3021
3740
  try {
3022
- const st = statSync(METRICS_DB);
3741
+ const st = statSync2(metricsDbPath(kind));
3023
3742
  return `${st.dev}:${st.ino}`;
3024
3743
  } catch {
3025
3744
  return "missing";
3026
3745
  }
3027
3746
  }
3028
- function current() {
3747
+ var hasStore = (kind = SOURCE_KIND) => existsSync4(metricsDbPath(kind));
3748
+ function current(kind = SOURCE_KIND) {
3029
3749
  mkdirSync2(DATA_DIR, { recursive: true });
3030
- const id = fileIdentity();
3031
- if (handle && handleStat !== id) {
3032
- closeReader();
3750
+ const slot = slotOf(kind);
3751
+ const id = fileIdentity(kind);
3752
+ if (slot.handle && slot.stat !== id) {
3753
+ closeReader(kind);
3033
3754
  }
3034
- if (!handle) {
3035
- handle = openMetrics();
3036
- handleStat = id;
3755
+ if (!slot.handle) {
3756
+ slot.handle = openMetrics(metricsDbPath(kind));
3757
+ slot.stat = id;
3037
3758
  }
3038
- return handle;
3759
+ return slot.handle;
3039
3760
  }
3040
- function closeReader() {
3041
- try {
3042
- handle?.close();
3043
- } catch {
3761
+ function closeReader(kind) {
3762
+ const kinds = kind ? [kind] : [...slots.keys()];
3763
+ for (const k of kinds) {
3764
+ const slot = slotOf(k);
3765
+ try {
3766
+ slot.handle?.close();
3767
+ } catch {
3768
+ }
3769
+ slot.handle = null;
3770
+ slot.stat = null;
3044
3771
  }
3045
- handle = null;
3046
- handleStat = null;
3047
3772
  }
3048
- function sourceChanged() {
3049
- if (!existsSync2(METRICS_DB) || !existsSync2(SOURCE_DB)) return true;
3050
- const prev = getMetaNumber(current(), "sourceMaxTime");
3773
+ function sourceChanged(kind = SOURCE_KIND) {
3774
+ if (!hasStore(kind)) return true;
3775
+ const prev = getMetaNumber(current(kind), "sourceMaxTime");
3051
3776
  if (prev <= 0) return true;
3052
- const src = new SourceReader(SOURCE_DB);
3777
+ const src = openSource(kind);
3053
3778
  try {
3054
3779
  return src.maxMessageTime() > prev;
3055
3780
  } catch {
@@ -3058,37 +3783,40 @@ function sourceChanged() {
3058
3783
  src.close();
3059
3784
  }
3060
3785
  }
3061
- async function rebuild(opts = {}) {
3786
+ async function rebuild(kind = SOURCE_KIND, opts = {}) {
3062
3787
  mkdirSync2(DATA_DIR, { recursive: true });
3063
- if (opts.full === true || !existsSync2(METRICS_DB) || schemaStale()) return fullRebuild();
3064
- return incrementalRebuild();
3788
+ if (opts.full === true || !hasStore(kind) || schemaStale(kind)) return fullRebuild(kind);
3789
+ return incrementalRebuild(kind);
3065
3790
  }
3066
- function schemaStale() {
3791
+ function schemaStale(kind) {
3067
3792
  try {
3068
- return getMeta(current(), "schemaSig") !== SCHEMA_SIG;
3793
+ return getMeta(current(kind), "schemaSig") !== SCHEMA_SIG;
3069
3794
  } catch {
3070
3795
  return true;
3071
3796
  }
3072
3797
  }
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);
3798
+ var nextFile = (kind) => `${metricsDbPath(kind)}.next`;
3799
+ async function fullRebuild(kind) {
3800
+ const next = nextFile(kind);
3801
+ for (const suffix of ["", "-wal", "-shm"]) if (existsSync4(next + suffix)) rmSync(next + suffix, { force: true });
3802
+ const writer = new DatabaseSync3(next);
3076
3803
  writer.exec("PRAGMA journal_mode = WAL");
3077
3804
  let report;
3078
3805
  try {
3079
- report = await build(writer, { mode: "full" });
3806
+ report = await build(writer, { mode: "full", kind });
3080
3807
  } finally {
3081
3808
  writer.close();
3082
3809
  }
3083
- closeReader();
3084
- for (const suffix of ["", "-wal", "-shm"]) rmSync(METRICS_DB + suffix, { force: true });
3085
- renameSync(NEXT, METRICS_DB);
3810
+ closeReader(kind);
3811
+ const target = metricsDbPath(kind);
3812
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(target + suffix, { force: true });
3813
+ renameSync(next, target);
3086
3814
  return report;
3087
3815
  }
3088
- async function incrementalRebuild() {
3089
- const db = current();
3816
+ async function incrementalRebuild(kind) {
3817
+ const db = current(kind);
3090
3818
  const prevWatermark = getMetaNumber(db, "sourceMaxTime");
3091
- const src = new SourceReader(SOURCE_DB);
3819
+ const src = openSource(kind);
3092
3820
  let changed;
3093
3821
  try {
3094
3822
  changed = prevWatermark > 0 ? src.changedSessions(prevWatermark) : /* @__PURE__ */ new Set();
@@ -3111,43 +3839,54 @@ async function incrementalRebuild() {
3111
3839
  ms: 0
3112
3840
  };
3113
3841
  }
3114
- return build(db, { mode: "incremental", sessionIds: changed });
3842
+ return build(db, { mode: "incremental", sessionIds: changed, kind });
3115
3843
  }
3116
3844
 
3117
3845
  // src/index.ts
3118
3846
  var REFRESH_MS = 12e4;
3119
3847
  var queue = Promise.resolve();
3120
- function materialize(opts = {}) {
3121
- const run = queue.then(() => doMaterialize(opts), () => doMaterialize(opts));
3848
+ function materialize(kind, opts = {}) {
3849
+ const run = queue.then(() => doMaterialize(kind, opts), () => doMaterialize(kind, opts));
3122
3850
  queue = run.catch(() => void 0);
3123
3851
  return run;
3124
3852
  }
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`);
3853
+ async function doMaterialize(kind, { force = false, full = false }) {
3854
+ if (!hasStore(kind) || full) {
3855
+ console.log(`[osm] ${kind}: ${full ? "full rebuild requested" : "no store yet"} \u2014 materializing\u2026`);
3856
+ const r2 = await rebuild(kind, { full: true });
3857
+ console.log(
3858
+ `[osm] ${kind}: built ${r2.sessions} sessions / ${r2.turns} turns / ${r2.tools} tools / ${r2.parts} parts in ${r2.ms}ms`
3859
+ );
3130
3860
  return r2;
3131
3861
  }
3132
- if (!force && !sourceChanged()) return void 0;
3133
- const r = await rebuild();
3862
+ if (!force && !sourceChanged(kind)) return void 0;
3863
+ const r = await rebuild(kind);
3134
3864
  console.log(
3135
- `[osm] ${r.mode} sync: ${r.sessions} sessions / ${r.turns} turns / ${r.parts} parts / ${r.events} events in ${r.ms}ms`
3865
+ `[osm] ${kind}: ${r.mode} sync ${r.sessions} sessions / ${r.turns} turns / ${r.parts} parts / ${r.events} events in ${r.ms}ms`
3136
3866
  );
3137
3867
  return r;
3138
3868
  }
3139
- var app = createApp({ db: current, refresh: (full) => materialize({ force: true, full }) });
3140
- var webDist = path2.join(ROOT, "web", "dist");
3141
- if (existsSync3(webDist)) {
3869
+ var app = createApp({
3870
+ db: (kind) => current(kind),
3871
+ hasStore: (kind) => hasStore(kind),
3872
+ refresh: (kind, full) => materialize(kind, { force: true, full })
3873
+ });
3874
+ var webDist = path3.join(ROOT, "web", "dist");
3875
+ if (existsSync5(webDist)) {
3142
3876
  app.use(express2.static(webDist));
3143
3877
  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"));
3878
+ if (path3.extname(req.path)) return res.status(404).type("text/plain").send("not found");
3879
+ res.sendFile(path3.join(webDist, "index.html"));
3146
3880
  });
3147
3881
  console.log(`[osm] serving built web from ${webDist}`);
3148
3882
  }
3149
3883
  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);
3884
+ console.log(`[osm] api on http://${HOST}:${PORT} (default source: ${SOURCE_KIND})`);
3885
+ await materialize(SOURCE_KIND);
3886
+ setInterval(() => {
3887
+ for (const kind of SOURCE_KINDS) {
3888
+ if (!hasStore(kind)) continue;
3889
+ void materialize(kind).catch((e) => console.error(`[osm] ${kind} refresh failed:`, e.message));
3890
+ }
3891
+ }, REFRESH_MS);
3153
3892
  });