@swestash/agent-lens 0.14.1 → 0.16.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.
@@ -812,9 +812,9 @@ CREATE INDEX IF NOT EXISTS idx_findings_category ON findings(category);
812
812
  CREATE INDEX IF NOT EXISTS idx_findings_rule ON findings(rule_id);
813
813
  CREATE INDEX IF NOT EXISTS idx_findings_tool_call ON findings(tool_call_id);
814
814
 
815
- -- File modifications derived from tool calls (ADR-022). One row per (tool_call, file): today
816
- -- Edit/Write/NotebookEdit are 1:1, but the hash id leaves room for multi-file sources (e.g. a
817
- -- future Bash-write heuristic) without a schema break. Failed calls (status='error') are excluded
815
+ -- File modifications derived from tool calls (ADR-022). One row per (tool_call, file):
816
+ -- Edit/Write/NotebookEdit are 1:1; a Bash call can write several files, which the hash id already
817
+ -- accommodated without a schema break. Failed calls (status='error') are excluded
818
818
  -- at derivation \u2014 they didn't change the file. Re-runnable like findings: incremental runs DELETE
819
819
  -- the dirty sessions' rows and re-INSERT (delete-then-insert). Populated by ingest's
820
820
  -- deriveFileChanges (filechanges.ts).
@@ -826,7 +826,7 @@ CREATE TABLE IF NOT EXISTS file_changes (
826
826
  event_uuid TEXT, -- jump-to-transcript anchor (#ev-<uuid>)
827
827
  project_id TEXT REFERENCES projects(id), -- from the session, for per-project grouping
828
828
  file_path TEXT NOT NULL, -- normalized absolute path
829
- tool_name TEXT NOT NULL, -- Edit | Write | NotebookEdit
829
+ tool_name TEXT NOT NULL, -- Edit | Write | NotebookEdit | Bash
830
830
  lines_added INTEGER, -- newline-count delta (NULL when unknowable)
831
831
  lines_removed INTEGER,
832
832
  timestamp TEXT, -- ISO8601, from the tool call's event
@@ -1363,6 +1363,10 @@ function rateForModel(model) {
1363
1363
  }
1364
1364
  return best?.rate ?? null;
1365
1365
  }
1366
+ var DATED_SNAPSHOT = /-\d{8}$/;
1367
+ function canonicalModel(model) {
1368
+ return model && DATED_SNAPSHOT.test(model) ? model.slice(0, -9) : model;
1369
+ }
1366
1370
  function costForUsage(model, u) {
1367
1371
  const r = rateForModel(model);
1368
1372
  if (!r)
@@ -3423,8 +3427,98 @@ function classifyErrors(db, dirty) {
3423
3427
  // ../ingest/dist/filechanges.js
3424
3428
  import { createHash as createHash3 } from "node:crypto";
3425
3429
  import { posix as path2 } from "node:path";
3426
- var FILECHANGES_VERSION = 1;
3427
- var FILE_TOOLS = ["Edit", "Write", "NotebookEdit"];
3430
+
3431
+ // ../ingest/dist/shellwrites.js
3432
+ var TARGET = `"([^"]+)"|'([^']+)'|([^\\s;|&<>()]+)`;
3433
+ var REDIRECT = new RegExp(`(?:^|[\\s;|&(])\\d?>{1,2}\\s*(?:${TARGET})`, "g");
3434
+ var TEE = new RegExp(`(?:^|[\\s;|&(])tee\\s+(?:-a\\s+)?(?:${TARGET})`, "g");
3435
+ var SED_I = new RegExp(`(?:^|[\\s;|&(])sed\\s+(?:-[a-zA-Z]*i[a-zA-Z]*(?:\\.\\w+)?)\\s+(?:(?:-e\\s+)?(?:'[^']*'|"[^"]*"|\\S+)\\s+)+?(?:${TARGET})`, "g");
3436
+ var CD = new RegExp(`^\\s*cd\\s+(?:${TARGET})`);
3437
+ var HEREDOC = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
3438
+ var REDIRECT_BEFORE_HEREDOC = new RegExp(`\\d?>{1,2}\\s*(?:${TARGET})\\s*$`);
3439
+ function target(m, first) {
3440
+ return m[first] ?? m[first + 1] ?? m[first + 2] ?? null;
3441
+ }
3442
+ function usable(path3) {
3443
+ if (!path3)
3444
+ return false;
3445
+ if (/[$*?`~]/.test(path3))
3446
+ return false;
3447
+ if (path3.startsWith("&"))
3448
+ return false;
3449
+ if (path3 === "/dev/null" || path3.startsWith("/dev/"))
3450
+ return false;
3451
+ return true;
3452
+ }
3453
+ function bodyLines(body) {
3454
+ const trimmed = body.replace(/\n$/, "");
3455
+ return trimmed === "" ? 0 : trimmed.split("\n").length;
3456
+ }
3457
+ function stripHeredocs(command) {
3458
+ const writes = [];
3459
+ let scan = command;
3460
+ HEREDOC.lastIndex = 0;
3461
+ let m;
3462
+ while (m = HEREDOC.exec(scan)) {
3463
+ const delim = m[2];
3464
+ const openerEnd = scan.indexOf("\n", m.index);
3465
+ if (openerEnd === -1)
3466
+ break;
3467
+ const terminator = new RegExp(`^[ \\t]*${delim}[ \\t]*$`, "m");
3468
+ const rest = scan.slice(openerEnd + 1);
3469
+ const end = rest.search(terminator);
3470
+ const bodyEnd = end === -1 ? rest.length : end;
3471
+ const before = scan.slice(0, m.index);
3472
+ const red = before.match(REDIRECT_BEFORE_HEREDOC);
3473
+ if (red) {
3474
+ const p = target(red, 1);
3475
+ if (usable(p))
3476
+ writes.push({ at: red.index ?? m.index, write: { path: p, linesAdded: bodyLines(rest.slice(0, bodyEnd)) } });
3477
+ }
3478
+ const stripEnd = end === -1 ? scan.length : openerEnd + 1 + bodyEnd + delim.length;
3479
+ scan = scan.slice(0, m.index) + " ".repeat(stripEnd - m.index) + scan.slice(stripEnd);
3480
+ HEREDOC.lastIndex = stripEnd;
3481
+ }
3482
+ return { scan, writes };
3483
+ }
3484
+ function parseShellWrites(command) {
3485
+ if (typeof command !== "string" || !command)
3486
+ return { cwd: null, writes: [] };
3487
+ const cdMatch = command.match(CD);
3488
+ const cdPath = cdMatch ? target(cdMatch, 1) : null;
3489
+ const cwd = cdPath && cdPath.startsWith("/") && !/[$*?`~]/.test(cdPath) ? cdPath : null;
3490
+ const { scan, writes: found } = stripHeredocs(command);
3491
+ for (const [re, group] of [
3492
+ [REDIRECT, 1],
3493
+ [TEE, 1],
3494
+ [SED_I, 1]
3495
+ ]) {
3496
+ re.lastIndex = 0;
3497
+ let m;
3498
+ while (m = re.exec(scan)) {
3499
+ const p = target(m, group);
3500
+ if (usable(p))
3501
+ found.push({ at: m.index, write: { path: p, linesAdded: null } });
3502
+ }
3503
+ }
3504
+ const byPath = /* @__PURE__ */ new Map();
3505
+ for (const entry of found) {
3506
+ const prev = byPath.get(entry.write.path);
3507
+ if (!prev) {
3508
+ byPath.set(entry.write.path, entry);
3509
+ continue;
3510
+ }
3511
+ byPath.set(entry.write.path, {
3512
+ at: Math.min(prev.at, entry.at),
3513
+ write: { path: entry.write.path, linesAdded: prev.write.linesAdded ?? entry.write.linesAdded }
3514
+ });
3515
+ }
3516
+ return { cwd, writes: [...byPath.values()].sort((a, b) => a.at - b.at).map((e) => e.write) };
3517
+ }
3518
+
3519
+ // ../ingest/dist/filechanges.js
3520
+ var FILECHANGES_VERSION = 2;
3521
+ var FILE_TOOLS = ["Edit", "Write", "NotebookEdit", "Bash"];
3428
3522
  function fileChangeId(toolCallId, filePath) {
3429
3523
  return createHash3("sha1").update(toolCallId).update("\0").update(filePath).digest("hex").slice(0, 16);
3430
3524
  }
@@ -3485,31 +3579,40 @@ function deriveFileChanges(db, dirty) {
3485
3579
  if (!input)
3486
3580
  continue;
3487
3581
  const p = proj.get(row.session_id);
3488
- const filePath = normalizeFilePath(input.file_path ?? input.notebook_path, p?.path ?? null);
3489
- if (!filePath)
3490
- continue;
3491
- let added = null;
3492
- let removed = null;
3493
- if (row.tool_name === "Edit") {
3494
- added = lineCount(input.new_string);
3495
- removed = lineCount(input.old_string);
3496
- } else if (row.tool_name === "Write") {
3497
- added = lineCount(input.content);
3582
+ const targets = [];
3583
+ if (row.tool_name === "Bash") {
3584
+ const { cwd, writes } = parseShellWrites(input.command);
3585
+ for (const w of writes) {
3586
+ const filePath = normalizeFilePath(w.path, cwd ?? p?.path ?? null);
3587
+ if (filePath)
3588
+ targets.push({ filePath, added: w.linesAdded, removed: null });
3589
+ }
3590
+ } else {
3591
+ const filePath = normalizeFilePath(input.file_path ?? input.notebook_path, p?.path ?? null);
3592
+ if (filePath) {
3593
+ targets.push({
3594
+ filePath,
3595
+ added: row.tool_name === "Edit" ? lineCount(input.new_string) : row.tool_name === "Write" ? lineCount(input.content) : null,
3596
+ removed: row.tool_name === "Edit" ? lineCount(input.old_string) : null
3597
+ });
3598
+ }
3599
+ }
3600
+ for (const t of targets) {
3601
+ insert.run({
3602
+ id: fileChangeId(row.id, t.filePath),
3603
+ tool_call_id: row.id,
3604
+ session_id: row.session_id,
3605
+ turn_id: row.turn_id,
3606
+ event_uuid: row.event_uuid,
3607
+ project_id: p?.id ?? null,
3608
+ file_path: t.filePath,
3609
+ tool_name: row.tool_name,
3610
+ lines_added: t.added,
3611
+ lines_removed: t.removed,
3612
+ timestamp: row.timestamp,
3613
+ derive_version: FILECHANGES_VERSION
3614
+ });
3498
3615
  }
3499
- insert.run({
3500
- id: fileChangeId(row.id, filePath),
3501
- tool_call_id: row.id,
3502
- session_id: row.session_id,
3503
- turn_id: row.turn_id,
3504
- event_uuid: row.event_uuid,
3505
- project_id: p?.id ?? null,
3506
- file_path: filePath,
3507
- tool_name: row.tool_name,
3508
- lines_added: added,
3509
- lines_removed: removed,
3510
- timestamp: row.timestamp,
3511
- derive_version: FILECHANGES_VERSION
3512
- });
3513
3616
  }
3514
3617
  });
3515
3618
  tx();
@@ -3581,15 +3684,15 @@ function canonicalizeProjects(db, opts = {}) {
3581
3684
  const touched = /* @__PURE__ */ new Set();
3582
3685
  transaction(db, () => {
3583
3686
  for (const r of rows) {
3584
- const target = canon(r.path);
3585
- if (target === strip(r.path))
3687
+ const target2 = canon(r.path);
3688
+ if (target2 === strip(r.path))
3586
3689
  continue;
3587
- const targetId = byPath.get(target)?.id ?? projectId(r.agent_id, target);
3690
+ const targetId = byPath.get(target2)?.id ?? projectId(r.agent_id, target2);
3588
3691
  if (targetId === r.id)
3589
3692
  continue;
3590
- if (!byPath.has(target)) {
3591
- upsertProject.run(targetId, r.agent_id, target);
3592
- byPath.set(target, { id: targetId, agent_id: r.agent_id });
3693
+ if (!byPath.has(target2)) {
3694
+ upsertProject.run(targetId, r.agent_id, target2);
3695
+ byPath.set(target2, { id: targetId, agent_id: r.agent_id });
3593
3696
  }
3594
3697
  repointSessions.run(targetId, r.id);
3595
3698
  repointFileChanges.run(targetId, r.id);
@@ -4568,6 +4671,10 @@ function pushDateRange(clauses, params, col, from, to) {
4568
4671
  params.push(to);
4569
4672
  }
4570
4673
  }
4674
+ function canonModelSql(col) {
4675
+ return `CASE WHEN ${col} GLOB '*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]'
4676
+ THEN substr(${col}, 1, length(${col}) - 9) ELSE ${col} END`;
4677
+ }
4571
4678
  function metaJoin(hasMeta) {
4572
4679
  return hasMeta ? "LEFT JOIN session_meta sm ON sm.session_id = s.id" : "";
4573
4680
  }
@@ -4657,7 +4764,8 @@ function listProjects(db) {
4657
4764
  FROM projects p ORDER BY session_count DESC, p.path`);
4658
4765
  }
4659
4766
  function listModels(db) {
4660
- return queryAll(db, `SELECT DISTINCT model FROM token_usage WHERE model IS NOT NULL ORDER BY model`).map((r) => r.model);
4767
+ return queryAll(db, `SELECT DISTINCT ${canonModelSql("m")} AS model
4768
+ FROM (SELECT DISTINCT model m FROM token_usage WHERE model IS NOT NULL) ORDER BY 1`).map((r) => r.model);
4661
4769
  }
4662
4770
  function toFtsQuery(raw) {
4663
4771
  return raw.trim().split(/\s+/).filter(Boolean).map((t) => `"${t.replace(/"/g, '""')}"`).join(" ");
@@ -4709,8 +4817,8 @@ function listSessions(db, f) {
4709
4817
  params.push(...f.errorType);
4710
4818
  }
4711
4819
  if (f.model) {
4712
- where.push("EXISTS (SELECT 1 FROM token_usage t WHERE t.session_id = s.id AND t.model = ?)");
4713
- params.push(f.model);
4820
+ where.push(`EXISTS (SELECT 1 FROM token_usage t WHERE t.session_id = s.id AND ${canonModelSql("t.model")} = ?)`);
4821
+ params.push(canonicalModel(f.model));
4714
4822
  }
4715
4823
  if (f.q && f.q.trim()) {
4716
4824
  const like = `%${f.q.trim()}%`;
@@ -4723,7 +4831,7 @@ function listSessions(db, f) {
4723
4831
  const total = queryGet(db, `SELECT COUNT(*) n FROM sessions s ${whereSql}`, ...params).n;
4724
4832
  const baseSelect = `SELECT s.id, s.ai_title, s.slug, s.source_id, s.is_sidechain, s.started_at,
4725
4833
  s.duration_ms, s.event_count, s.turn_count, p.path AS project_path,
4726
- (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models,
4834
+ (SELECT GROUP_CONCAT(DISTINCT ${canonModelSql("model")}) FROM token_usage t WHERE t.session_id = s.id) AS models,
4727
4835
  (SELECT COUNT(*) FROM tool_calls tc WHERE tc.session_id = s.id) AS tool_call_count,
4728
4836
  (SELECT COUNT(*) FROM tool_calls tc WHERE tc.session_id = s.id AND tc.status = 'error') AS tool_error_count,
4729
4837
  (SELECT COUNT(*) FROM findings fd WHERE fd.session_id = s.id) AS finding_count,
@@ -4827,8 +4935,51 @@ function loadFindings(db, id, toolRows) {
4827
4935
  function toResponseToolCall({ event_uuid: _e, error_type: _t, ...call }) {
4828
4936
  return call;
4829
4937
  }
4938
+ function rendersSomething(e, toolsByEvent) {
4939
+ return !!(e.text || e.thinking || toolsByEvent.get(e.uuid)?.length);
4940
+ }
4941
+ function addSplit(a, b) {
4942
+ if (!b)
4943
+ return a;
4944
+ if (!a)
4945
+ return { ...b };
4946
+ return {
4947
+ input: a.input + b.input,
4948
+ output: a.output + b.output,
4949
+ cache_creation: a.cache_creation + b.cache_creation,
4950
+ cache_read: a.cache_read + b.cache_read
4951
+ };
4952
+ }
4953
+ function foldUsage(eventRows, toolsByEvent, usageByEvent) {
4954
+ const out = /* @__PURE__ */ new Map();
4955
+ if (usageByEvent.size === 0)
4956
+ return out;
4957
+ const byTurn = /* @__PURE__ */ new Map();
4958
+ for (const e of eventRows)
4959
+ pushGrouped(byTurn, e.turn_id ?? "", e);
4960
+ for (const group of byTurn.values()) {
4961
+ const ordered = [...group].sort((a, b) => a.seq - b.seq);
4962
+ let pending = null;
4963
+ let lastRendering = null;
4964
+ for (const e of ordered) {
4965
+ const own = usageByEvent.get(e.uuid);
4966
+ if (rendersSomething(e, toolsByEvent)) {
4967
+ const total = addSplit(pending, own);
4968
+ if (total)
4969
+ out.set(e.uuid, total);
4970
+ pending = null;
4971
+ lastRendering = e.uuid;
4972
+ } else {
4973
+ pending = addSplit(pending, own);
4974
+ }
4975
+ }
4976
+ if (pending && lastRendering)
4977
+ out.set(lastRendering, addSplit(out.get(lastRendering) ?? null, pending));
4978
+ }
4979
+ return out;
4980
+ }
4830
4981
  function loadEvents(db, id, toolRows) {
4831
- const eventRows = queryAll(db, `SELECT uuid, type, role, timestamp, model, is_sidechain, turn_id, text, thinking
4982
+ const eventRows = queryAll(db, `SELECT uuid, seq, type, role, timestamp, model, is_sidechain, turn_id, text, thinking
4832
4983
  FROM events WHERE session_id = ? ORDER BY timestamp, seq`, id);
4833
4984
  const toolsByEvent = /* @__PURE__ */ new Map();
4834
4985
  for (const t of toolRows) {
@@ -4836,18 +4987,30 @@ function loadEvents(db, id, toolRows) {
4836
4987
  continue;
4837
4988
  pushGrouped(toolsByEvent, t.event_uuid, t);
4838
4989
  }
4839
- return eventRows.map((e) => ({
4840
- uuid: e.uuid,
4841
- type: e.type,
4842
- role: e.role,
4843
- timestamp: e.timestamp,
4844
- model: e.model,
4845
- is_sidechain: e.is_sidechain,
4846
- turn_id: e.turn_id,
4847
- text: e.text,
4848
- thinking: e.thinking,
4849
- toolCalls: (toolsByEvent.get(e.uuid) ?? []).map(toResponseToolCall)
4850
- }));
4990
+ const usageByEvent = /* @__PURE__ */ new Map();
4991
+ for (const u of queryAll(db, `SELECT event_uuid, input_tokens i, output_tokens o, cache_creation_input_tokens cw,
4992
+ cache_read_input_tokens cr
4993
+ FROM token_usage WHERE session_id = ?`, id)) {
4994
+ usageByEvent.set(u.event_uuid, { input: u.i, output: u.o, cache_creation: u.cw, cache_read: u.cr });
4995
+ }
4996
+ const folded = foldUsage(eventRows, toolsByEvent, usageByEvent);
4997
+ return eventRows.map((e) => {
4998
+ const usage = folded.get(e.uuid);
4999
+ return {
5000
+ uuid: e.uuid,
5001
+ type: e.type,
5002
+ role: e.role,
5003
+ timestamp: e.timestamp,
5004
+ model: e.model,
5005
+ is_sidechain: e.is_sidechain,
5006
+ turn_id: e.turn_id,
5007
+ text: e.text,
5008
+ thinking: e.thinking,
5009
+ toolCalls: (toolsByEvent.get(e.uuid) ?? []).map(toResponseToolCall),
5010
+ // Spread so the key is ABSENT, not null, on the majority of events that carry no usage row.
5011
+ ...usage ? { usage } : {}
5012
+ };
5013
+ });
4851
5014
  }
4852
5015
  function loadClassification(db, id) {
4853
5016
  const cls = queryGet(db, `SELECT category, complexity_score, complexity_band, signals_json, classifier_version
@@ -4878,7 +5041,7 @@ function loadParent(db, session) {
4878
5041
  function loadChildren(db, id) {
4879
5042
  const hasMeta = tableExists(db, "session_meta");
4880
5043
  const children = queryAll(db, `SELECT s.id, s.ai_title, s.slug, s.turn_count, s.started_at, s.workflow_run_id,
4881
- (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models
5044
+ (SELECT GROUP_CONCAT(DISTINCT ${canonModelSql("model")}) FROM token_usage t WHERE t.session_id = s.id) AS models
4882
5045
  ${metaProjection(hasMeta)}
4883
5046
  FROM sessions s ${metaJoin(hasMeta)}
4884
5047
  WHERE s.parent_session_id = ? ORDER BY s.started_at`, id);
@@ -4954,7 +5117,7 @@ function safeJson(s) {
4954
5117
  function loadAgents(db, runId) {
4955
5118
  const hasMeta = tableExists(db, "session_meta");
4956
5119
  const agentRows = queryAll(db, `SELECT s.id, s.ai_title, s.slug, s.turn_count, s.started_at, s.ended_at, s.duration_ms,
4957
- (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models
5120
+ (SELECT GROUP_CONCAT(DISTINCT ${canonModelSql("model")}) FROM token_usage t WHERE t.session_id = s.id) AS models
4958
5121
  ${metaProjection(hasMeta)}
4959
5122
  FROM sessions s ${metaJoin(hasMeta)}
4960
5123
  WHERE s.workflow_run_id = ? ORDER BY s.started_at`, runId);
@@ -5348,8 +5511,24 @@ function sessionWhere(f) {
5348
5511
  if (f.source)
5349
5512
  where.push("s.source_id = ?"), params.push(f.source);
5350
5513
  pushDateRange(where, params, "s.started_at", f.from, f.to);
5351
- return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
5352
- }
5514
+ const models = f.models?.length ? [...new Set(f.models.map((m) => canonicalModel(m)))] : void 0;
5515
+ if (models) {
5516
+ where.push(`EXISTS (SELECT 1 FROM token_usage t WHERE t.session_id = s.id AND ${canonModelSql("t.model")} IN (${placeholders(models)}))`);
5517
+ params.push(...models);
5518
+ }
5519
+ return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params, models };
5520
+ }
5521
+ var placeholders = (xs) => xs.map(() => "?").join(",");
5522
+ var and = (w, sql, params = []) => ({
5523
+ sql: `${andWhere(w)} ${sql}`,
5524
+ params: [...w.params, ...params],
5525
+ models: w.models
5526
+ });
5527
+ var withModel = (w, col) => w.models ? and(w, `${canonModelSql(col)} IN (${placeholders(w.models)})`, w.models) : w;
5528
+ var toolModelJoin = (w) => w.models ? "JOIN events e ON e.uuid = tc.event_uuid" : "";
5529
+ var eventJoin = "JOIN events e ON e.uuid = tc.event_uuid";
5530
+ var andWhere = (w) => w.sql ? w.sql + " AND" : "WHERE";
5531
+ var mainOnly = (w) => ({ sql: `${andWhere(w)} s.is_sidechain = 0`, params: w.params, models: w.models });
5353
5532
  var zeroSplit = () => ({ input: 0, output: 0, cache_creation: 0, cache_read: 0 });
5354
5533
  function addUsage(acc, r) {
5355
5534
  acc.input += r.i ?? 0;
@@ -5426,11 +5605,13 @@ function dashboardOverview(db, f) {
5426
5605
  SUM(turn_count) turns,
5427
5606
  COUNT(DISTINCT project_id) projects
5428
5607
  FROM sessions s ${w.sql}`, ...w.params);
5429
- const toolCount = queryGet(db, `SELECT COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id ${w.sql}`, ...w.params).n;
5608
+ const tw = withModel(w, "e.model");
5609
+ const toolCount = queryGet(db, `SELECT COUNT(*) n FROM tool_calls tc ${toolModelJoin(w)} JOIN sessions s ON s.id = tc.session_id ${tw.sql}`, ...tw.params).n;
5610
+ const uw = withModel(w, "t.model");
5430
5611
  const usage = queryAll(db, `SELECT t.model model, SUM(t.input_tokens) i, SUM(t.output_tokens) o,
5431
5612
  SUM(t.cache_creation_input_tokens) cw, SUM(t.cache_read_input_tokens) cr
5432
- FROM token_usage t JOIN sessions s ON s.id = t.session_id ${w.sql}
5433
- GROUP BY t.model`, ...w.params);
5613
+ FROM token_usage t JOIN sessions s ON s.id = t.session_id ${uw.sql}
5614
+ GROUP BY t.model`, ...uw.params);
5434
5615
  const tokens = zeroSplit();
5435
5616
  let cost = 0;
5436
5617
  const unpriced2 = [];
@@ -5441,16 +5622,20 @@ function dashboardOverview(db, f) {
5441
5622
  unpriced2.push(u.model);
5442
5623
  }
5443
5624
  const totalTokens = tokens.input + tokens.output + tokens.cache_creation + tokens.cache_read;
5625
+ const tnw = withModel(w, "tn.model");
5626
+ const turns = tnw.models ? queryGet(db, `SELECT COUNT(*) n FROM turns tn JOIN sessions s ON s.id = tn.session_id ${tnw.sql}`, ...tnw.params)?.n ?? 0 : counts.turns ?? 0;
5444
5627
  const turnDur = percentiles(db, `SELECT tn.duration_ms v FROM turns tn JOIN sessions s ON s.id = tn.session_id
5445
- ${w.sql ? w.sql + " AND" : "WHERE"} tn.duration_ms IS NOT NULL`, w.params);
5446
- const sessDur = percentiles(db, `SELECT s.duration_ms v FROM sessions s
5447
- ${w.sql ? w.sql + " AND" : "WHERE"} s.is_sidechain = 0 AND s.duration_ms IS NOT NULL`, w.params);
5628
+ ${andWhere(tnw)} tn.duration_ms IS NOT NULL`, tnw.params);
5629
+ const mw = mainOnly(w);
5630
+ const sessDur = percentiles(db, `SELECT s.duration_ms v FROM sessions s ${mw.sql} AND s.duration_ms IS NOT NULL`, mw.params);
5448
5631
  return {
5449
- range: { from: f.from ?? null, to: f.to ?? null, source: f.source ?? null },
5632
+ // `w.models`, not `f.models`: the echo names the models actually queried, so a link that arrived
5633
+ // with a raw dated id reports the family key it resolved to rather than the string it was sent.
5634
+ range: { from: f.from ?? null, to: f.to ?? null, source: f.source ?? null, models: w.models ?? null },
5450
5635
  sessions: counts.sessions ?? 0,
5451
5636
  sessions_main: counts.main ?? 0,
5452
5637
  sessions_subagent: counts.subagent ?? 0,
5453
- turns: counts.turns ?? 0,
5638
+ turns,
5454
5639
  projects: counts.projects ?? 0,
5455
5640
  tool_calls: toolCount ?? 0,
5456
5641
  tokens,
@@ -5476,15 +5661,11 @@ function chooseBucket(db, f) {
5476
5661
  return "week";
5477
5662
  return "month";
5478
5663
  }
5479
- var BUCKET_EXPR = {
5480
- day: "strftime('%Y-%m-%d', s.started_at)",
5481
- week: "strftime('%Y-W%W', s.started_at)",
5482
- month: "strftime('%Y-%m', s.started_at)"
5483
- };
5664
+ var bucketExpr = (b, col) => b === "day" ? `strftime('%Y-%m-%d', ${col})` : b === "week" ? `strftime('%Y-W%W', ${col})` : `strftime('%Y-%m', ${col})`;
5484
5665
  function dashboardTimeseries(db, f, bucketParam) {
5485
5666
  const bucket = bucketParam === "day" || bucketParam === "week" || bucketParam === "month" ? bucketParam : chooseBucket(db, f);
5486
5667
  const w = sessionWhere(f);
5487
- const expr = BUCKET_EXPR[bucket];
5668
+ const expr = bucketExpr(bucket, "s.started_at");
5488
5669
  const byBucket = /* @__PURE__ */ new Map();
5489
5670
  const get = (b) => {
5490
5671
  let r = byBucket.get(b);
@@ -5494,10 +5675,13 @@ function dashboardTimeseries(db, f, bucketParam) {
5494
5675
  }
5495
5676
  return r;
5496
5677
  };
5678
+ const uw = withModel(w, "t.model");
5679
+ const tnw = withModel(w, "tn.model");
5680
+ const ew = withModel(w, "e.model");
5497
5681
  for (const u of queryAll(db, `SELECT ${expr} b, t.model model, SUM(t.input_tokens) i, SUM(t.output_tokens) o,
5498
5682
  SUM(t.cache_creation_input_tokens) cw, SUM(t.cache_read_input_tokens) cr
5499
5683
  FROM token_usage t JOIN sessions s ON s.id = t.session_id
5500
- ${w.sql} GROUP BY b, t.model`, ...w.params)) {
5684
+ ${uw.sql} GROUP BY b, t.model`, ...uw.params)) {
5501
5685
  if (!u.b)
5502
5686
  continue;
5503
5687
  const r = get(u.b);
@@ -5508,12 +5692,12 @@ function dashboardTimeseries(db, f, bucketParam) {
5508
5692
  if (s.b)
5509
5693
  get(s.b).sessions = s.n;
5510
5694
  }
5511
- for (const t of queryAll(db, `SELECT ${expr} b, COUNT(*) n FROM turns tn JOIN sessions s ON s.id = tn.session_id ${w.sql} GROUP BY b`, ...w.params)) {
5695
+ for (const t of queryAll(db, `SELECT ${expr} b, COUNT(*) n FROM turns tn JOIN sessions s ON s.id = tn.session_id ${tnw.sql} GROUP BY b`, ...tnw.params)) {
5512
5696
  if (t.b)
5513
5697
  get(t.b).turns = t.n;
5514
5698
  }
5515
- for (const e of queryAll(db, `SELECT ${expr} b, tc.error_type et, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
5516
- ${w.sql ? w.sql + " AND" : "WHERE"} tc.status = 'error' GROUP BY b, tc.error_type`, ...w.params)) {
5699
+ for (const e of queryAll(db, `SELECT ${expr} b, tc.error_type et, COUNT(*) n FROM tool_calls tc ${toolModelJoin(w)} JOIN sessions s ON s.id = tc.session_id
5700
+ ${andWhere(ew)} tc.status = 'error' GROUP BY b, tc.error_type`, ...ew.params)) {
5517
5701
  if (!e.b)
5518
5702
  continue;
5519
5703
  const r = get(e.b);
@@ -5527,35 +5711,40 @@ function dashboardTimeseries(db, f, bucketParam) {
5527
5711
  r.cost = Number(r.cost.toFixed(4));
5528
5712
  return { bucket, series };
5529
5713
  }
5530
- var andWhere = (w) => w.sql ? w.sql + " AND" : "WHERE";
5531
5714
  function modelBreakdown(db, w) {
5532
- const modelRows = queryAll(
5533
- db,
5534
- // COALESCE the bucket label, exactly as by_source does for a null source_id: token_usage.model is
5535
- // nullable and this GROUP BY — unlike the model FILTER list — does not exclude nulls, so a
5536
- // null-model bucket reaches the chart. It used to render as a nameless bar.
5537
- `SELECT COALESCE(t.model, '(unknown)') model, SUM(t.input_tokens) i, SUM(t.output_tokens) o,
5715
+ const uw = withModel(w, "t.model");
5716
+ const canon = canonModelSql("t.model");
5717
+ const modelRows = queryAll(db, `SELECT COALESCE(${canon}, '(unknown)') model, SUM(t.input_tokens) i, SUM(t.output_tokens) o,
5538
5718
  SUM(t.cache_creation_input_tokens) cw, SUM(t.cache_read_input_tokens) cr,
5539
5719
  COUNT(DISTINCT t.session_id) sessions
5540
- FROM token_usage t JOIN sessions s ON s.id = t.session_id ${w.sql}
5541
- GROUP BY t.model ORDER BY (SUM(t.input_tokens)+SUM(t.output_tokens)+SUM(t.cache_creation_input_tokens)+SUM(t.cache_read_input_tokens)) DESC`,
5542
- ...w.params
5543
- );
5720
+ FROM token_usage t JOIN sessions s ON s.id = t.session_id ${uw.sql}
5721
+ GROUP BY 1 ORDER BY (SUM(t.input_tokens)+SUM(t.output_tokens)+SUM(t.cache_creation_input_tokens)+SUM(t.cache_read_input_tokens)) DESC`, ...uw.params);
5722
+ const cost = /* @__PURE__ */ new Map();
5723
+ const unpriced2 = /* @__PURE__ */ new Set();
5724
+ for (const u of queryAll(db, `SELECT COALESCE(t.model, '(unknown)') model, SUM(t.input_tokens) i, SUM(t.output_tokens) o,
5725
+ SUM(t.cache_creation_input_tokens) cw, SUM(t.cache_read_input_tokens) cr
5726
+ FROM token_usage t JOIN sessions s ON s.id = t.session_id ${uw.sql} GROUP BY t.model`, ...uw.params)) {
5727
+ const key = canonicalModel(u.model);
5728
+ cost.set(key, (cost.get(key) ?? 0) + costForUsage(u.model, usageForCost(u)));
5729
+ if (!rateForModel(u.model))
5730
+ unpriced2.add(key);
5731
+ }
5544
5732
  return modelRows.map((u) => ({
5545
5733
  model: u.model,
5546
5734
  tokens: { input: u.i ?? 0, output: u.o ?? 0, cache_creation: u.cw ?? 0, cache_read: u.cr ?? 0 },
5547
5735
  total_tokens: (u.i ?? 0) + (u.o ?? 0) + (u.cw ?? 0) + (u.cr ?? 0),
5548
- cost: Number(costForUsage(u.model, usageForCost(u)).toFixed(4)),
5736
+ cost: Number((cost.get(u.model) ?? 0).toFixed(4)),
5549
5737
  sessions: u.sessions ?? 0,
5550
- priced: !!rateForModel(u.model)
5738
+ priced: !unpriced2.has(u.model)
5551
5739
  }));
5552
5740
  }
5553
5741
  function sourceBreakdown(db, w) {
5554
- return queryAll(db, `SELECT COALESCE(s.source_id, '(none)') source, COUNT(*) sessions, SUM(s.turn_count) turns
5555
- FROM sessions s ${w.sql} GROUP BY s.source_id ORDER BY sessions DESC`, ...w.params);
5742
+ const perSourceTurns = w.models ? `SUM((SELECT COUNT(*) FROM turns tn WHERE tn.session_id = s.id AND ${canonModelSql("tn.model")} IN (${placeholders(w.models)})))` : "SUM(s.turn_count)";
5743
+ return queryAll(db, `SELECT COALESCE(s.source_id, '(none)') source, COUNT(*) sessions, ${perSourceTurns} turns
5744
+ FROM sessions s ${w.sql} GROUP BY s.source_id ORDER BY sessions DESC`, ...w.models ?? [], ...w.params);
5556
5745
  }
5557
5746
  function classificationBreakdowns(db, w) {
5558
- const mainW = { sql: w.sql ? w.sql + " AND s.is_sidechain = 0" : "WHERE s.is_sidechain = 0", params: w.params };
5747
+ const mainW = mainOnly(w);
5559
5748
  const byCategory = queryAll(db, `SELECT c.category, COUNT(*) n FROM classifications c JOIN sessions s ON s.id = c.target_id
5560
5749
  ${mainW.sql} GROUP BY c.category ORDER BY n DESC`, ...mainW.params);
5561
5750
  const byComplexity = queryAll(db, `SELECT c.complexity_band band, COUNT(*) n FROM classifications c JOIN sessions s ON s.id = c.target_id
@@ -5563,26 +5752,30 @@ function classificationBreakdowns(db, w) {
5563
5752
  return { byCategory, byComplexity };
5564
5753
  }
5565
5754
  function toolBreakdown(db, w) {
5566
- return queryAll(db, `SELECT tc.tool_name name, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
5567
- ${w.sql} GROUP BY tc.tool_name ORDER BY n DESC LIMIT 20`, ...w.params);
5755
+ const ew = withModel(w, "e.model");
5756
+ return queryAll(db, `SELECT tc.tool_name name, COUNT(*) n FROM tool_calls tc ${toolModelJoin(w)} JOIN sessions s ON s.id = tc.session_id
5757
+ ${ew.sql} GROUP BY tc.tool_name ORDER BY n DESC LIMIT 20`, ...ew.params);
5568
5758
  }
5569
5759
  function skillBreakdowns(db, w) {
5570
- const skills = queryAll(db, `SELECT tc.skill_name name, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
5571
- ${andWhere(w)} tc.skill_name IS NOT NULL GROUP BY tc.skill_name ORDER BY n DESC LIMIT 20`, ...w.params);
5760
+ const ew = withModel(w, "e.model");
5761
+ const skills = queryAll(db, `SELECT tc.skill_name name, COUNT(*) n FROM tool_calls tc ${toolModelJoin(w)} JOIN sessions s ON s.id = tc.session_id
5762
+ ${andWhere(ew)} tc.skill_name IS NOT NULL GROUP BY tc.skill_name ORDER BY n DESC LIMIT 20`, ...ew.params);
5572
5763
  const skillVersions = queryAll(db, `SELECT tc.skill_name name, sk.id version_id, sk.summary, sk.last_seen, COUNT(*) n
5573
5764
  FROM tool_calls tc
5765
+ ${toolModelJoin(w)}
5574
5766
  JOIN skills sk ON sk.id = tc.skill_id
5575
5767
  JOIN sessions s ON s.id = tc.session_id
5576
- ${andWhere(w)} tc.skill_id IS NOT NULL
5577
- GROUP BY tc.skill_name, sk.id ORDER BY name, n DESC`, ...w.params);
5768
+ ${andWhere(ew)} tc.skill_id IS NOT NULL
5769
+ GROUP BY tc.skill_name, sk.id ORDER BY name, n DESC`, ...ew.params);
5578
5770
  return { skills, skillVersions };
5579
5771
  }
5580
5772
  function subagentFanoutBreakdown(db, w) {
5581
- const subagentByType = queryAll(db, `SELECT tc.agent_type type, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
5582
- ${andWhere(w)} tc.agent_type IS NOT NULL GROUP BY tc.agent_type ORDER BY n DESC`, ...w.params);
5583
- const perSession = queryAll(db, `SELECT COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
5584
- ${andWhere(w)} s.is_sidechain = 0 AND tc.tool_name IN ('Agent','Task')
5585
- GROUP BY tc.session_id`, ...w.params);
5773
+ const ew = withModel(w, "e.model");
5774
+ const subagentByType = queryAll(db, `SELECT tc.agent_type type, COUNT(*) n FROM tool_calls tc ${toolModelJoin(w)} JOIN sessions s ON s.id = tc.session_id
5775
+ ${andWhere(ew)} tc.agent_type IS NOT NULL GROUP BY tc.agent_type ORDER BY n DESC`, ...ew.params);
5776
+ const perSession = queryAll(db, `SELECT COUNT(*) n FROM tool_calls tc ${toolModelJoin(w)} JOIN sessions s ON s.id = tc.session_id
5777
+ ${mainOnly(ew).sql} AND tc.tool_name IN ('Agent','Task')
5778
+ GROUP BY tc.session_id`, ...ew.params);
5586
5779
  const counts = perSession.map((r) => r.n);
5587
5780
  return {
5588
5781
  by_type: subagentByType,
@@ -5593,8 +5786,9 @@ function subagentFanoutBreakdown(db, w) {
5593
5786
  };
5594
5787
  }
5595
5788
  function errorTypeBreakdown(db, w) {
5596
- const errorRows = queryAll(db, `SELECT COALESCE(tc.error_type, 'other') type, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
5597
- ${andWhere(w)} tc.status = 'error' GROUP BY tc.error_type ORDER BY n DESC`, ...w.params);
5789
+ const ew = withModel(w, "e.model");
5790
+ const errorRows = queryAll(db, `SELECT COALESCE(tc.error_type, 'other') type, COUNT(*) n FROM tool_calls tc ${toolModelJoin(w)} JOIN sessions s ON s.id = tc.session_id
5791
+ ${andWhere(ew)} tc.status = 'error' GROUP BY tc.error_type ORDER BY n DESC`, ...ew.params);
5598
5792
  let failures = 0;
5599
5793
  let rejections = 0;
5600
5794
  const byType = errorRows.map((r) => {
@@ -5623,6 +5817,143 @@ function dashboardBreakdowns(db, f) {
5623
5817
  error_types: errorTypeBreakdown(db, w)
5624
5818
  };
5625
5819
  }
5820
+ var bucketFor = (b) => b === "day" || b === "week" || b === "month" ? b : "day";
5821
+ function dashboardTime(db, f, bucketParam) {
5822
+ const w = sessionWhere(f);
5823
+ const mw = mainOnly(w);
5824
+ const uw = withModel(w, "t.model");
5825
+ const burn_hours = queryAll(db, `SELECT strftime('%Y-%m-%dT%H', e.timestamp) h, s.source_id src,
5826
+ SUM(t.input_tokens + t.output_tokens + t.cache_creation_input_tokens) work
5827
+ FROM token_usage t
5828
+ JOIN events e ON e.uuid = t.event_uuid
5829
+ JOIN sessions s ON s.id = t.session_id
5830
+ ${uw.sql} GROUP BY h, src ORDER BY h`, ...uw.params).filter((r) => r.h != null).map((r) => ({ hour: r.h, source: r.src, work: r.work ?? 0 }));
5831
+ const tw = withModel(mw, "t.model");
5832
+ const turn_hours = queryAll(db, `SELECT strftime('%Y-%m-%dT%H', t.started_at) h, s.source_id src, COUNT(*) turns
5833
+ FROM turns t
5834
+ JOIN sessions s ON s.id = t.session_id
5835
+ ${tw.sql} GROUP BY h, src ORDER BY h`, ...tw.params).filter((r) => r.h != null).map((r) => ({ hour: r.h, source: r.src, turns: r.turns ?? 0 }));
5836
+ return {
5837
+ burn_hours,
5838
+ turn_hours,
5839
+ latency: modelLatency(db, mw, bucketParam ? bucketFor(bucketParam) : chooseBucket(db, f)),
5840
+ review: reviewLatency(db, mw)
5841
+ };
5842
+ }
5843
+ var MIN_LATENCY_SAMPLES = 5;
5844
+ function modelLatency(db, mw, bucket) {
5845
+ const expr = bucketExpr(bucket, "s.started_at");
5846
+ const lw = withModel(mw, "tn.model");
5847
+ const series = queryAll(db, `WITH lat AS MATERIALIZED (
5848
+ SELECT ${expr} b, COALESCE(${canonModelSql("tn.model")}, '(unknown)') model,
5849
+ (julianday(MIN(e.timestamp)) - julianday(tn.started_at)) * 86400000.0 ms
5850
+ FROM turns tn
5851
+ JOIN sessions s ON s.id = tn.session_id
5852
+ JOIN events e ON e.turn_id = tn.id AND e.role = 'assistant'
5853
+ ${lw.sql} AND tn.started_at IS NOT NULL AND tn.model IS NOT '<synthetic>'
5854
+ GROUP BY tn.id
5855
+ ),
5856
+ ranked AS (
5857
+ SELECT b, model, ms,
5858
+ ROW_NUMBER() OVER (PARTITION BY b, model ORDER BY ms) rn,
5859
+ COUNT(*) OVER (PARTITION BY b, model) c
5860
+ FROM lat WHERE ms IS NOT NULL AND ms >= 0
5861
+ )
5862
+ SELECT b, model, MAX(c) n,
5863
+ MAX(CASE WHEN rn = MAX(1, CAST(ceil(0.5 * c) AS INTEGER)) THEN ms END) p50,
5864
+ MAX(CASE WHEN rn = MAX(1, CAST(ceil(0.9 * c) AS INTEGER)) THEN ms END) p90
5865
+ FROM ranked GROUP BY b, model HAVING MAX(c) >= ${MIN_LATENCY_SAMPLES} ORDER BY b, model`, ...lw.params).filter((r) => r.b != null).map((r) => ({
5866
+ bucket: r.b,
5867
+ model: r.model ?? "(unknown)",
5868
+ p50_ms: Math.round(r.p50 ?? 0),
5869
+ p90_ms: Math.round(r.p90 ?? 0),
5870
+ n: r.n
5871
+ }));
5872
+ return { bucket, series };
5873
+ }
5874
+ function reviewLatency(db, mw) {
5875
+ const empty = () => ({ n: 0, under_10s: 0, under_30s: 0, under_2min: 0 });
5876
+ const out = { wrote: empty(), none: empty() };
5877
+ for (const r of queryAll(
5878
+ db,
5879
+ // `wrote` is resolved against a materialized set of turn ids rather than a correlated EXISTS:
5880
+ // file_changes has no index on turn_id, so the per-turn subquery rescanned the table once per
5881
+ // turn and cost 5.7s on the real corpus against 0.02s for this. Same counts either way.
5882
+ `WITH wrote_turns AS MATERIALIZED (SELECT DISTINCT turn_id FROM file_changes WHERE turn_id IS NOT NULL),
5883
+ turnaround AS MATERIALIZED (
5884
+ SELECT (tn.id IN (SELECT turn_id FROM wrote_turns)) wrote,
5885
+ (julianday(LEAD(tn.started_at) OVER (PARTITION BY tn.session_id ORDER BY tn.seq))
5886
+ - julianday(tn.ended_at)) * 86400000.0 d
5887
+ FROM turns tn JOIN sessions s ON s.id = tn.session_id ${mw.sql}
5888
+ )
5889
+ SELECT wrote, COUNT(*) n,
5890
+ SUM(CASE WHEN d <= 10000 THEN 1 ELSE 0 END) u10,
5891
+ SUM(CASE WHEN d <= 30000 THEN 1 ELSE 0 END) u30,
5892
+ SUM(CASE WHEN d <= 120000 THEN 1 ELSE 0 END) u120
5893
+ FROM turnaround WHERE d IS NOT NULL AND d >= 0 GROUP BY wrote`,
5894
+ ...mw.params
5895
+ )) {
5896
+ out[r.wrote ? "wrote" : "none"] = { n: r.n, under_10s: r.u10 ?? 0, under_30s: r.u30 ?? 0, under_2min: r.u120 ?? 0 };
5897
+ }
5898
+ return out;
5899
+ }
5900
+ function dashboardAudit(db, f, bucketParam) {
5901
+ const w = sessionWhere(f);
5902
+ const bucket = bucketParam ? bucketFor(bucketParam) : chooseBucket(db, f);
5903
+ return {
5904
+ bucket,
5905
+ edit_reliability: editReliability(db, w),
5906
+ plan_rejections: planRejections(db, mainOnly(w), bucket),
5907
+ file_rework: fileRework(db, w),
5908
+ findings_over_time: findingsOverTime(db, w, bucket)
5909
+ };
5910
+ }
5911
+ function editReliability(db, w) {
5912
+ const ew = withModel(w, "e.model");
5913
+ return queryAll(db, `SELECT COALESCE(${canonModelSql("e.model")}, '(unknown)') model, COUNT(*) calls,
5914
+ SUM(CASE WHEN tc.status = 'error' THEN 1 ELSE 0 END) errors
5915
+ FROM tool_calls tc ${eventJoin} JOIN sessions s ON s.id = tc.session_id
5916
+ ${andWhere(ew)} tc.tool_name IN ('Edit', 'Write', 'NotebookEdit')
5917
+ GROUP BY 1 ORDER BY calls DESC`, ...ew.params);
5918
+ }
5919
+ function planRejections(db, mw, bucket) {
5920
+ const ew = withModel(mw, "e.model");
5921
+ const rejected = (tool) => `SUM(CASE WHEN tc.tool_name = '${tool}' AND tc.error_type = 'user-rejected' THEN 1 ELSE 0 END)`;
5922
+ const calls = (tool) => `SUM(CASE WHEN tc.tool_name = '${tool}' THEN 1 ELSE 0 END)`;
5923
+ return queryAll(db, `SELECT ${bucketExpr(bucket, "s.started_at")} bucket,
5924
+ ${calls("ExitPlanMode")} plan_calls, ${rejected("ExitPlanMode")} plan_rejected,
5925
+ ${calls("AskUserQuestion")} question_calls, ${rejected("AskUserQuestion")} question_rejected
5926
+ FROM tool_calls tc ${eventJoin} JOIN sessions s ON s.id = tc.session_id
5927
+ ${andWhere(ew)} tc.tool_name IN ('ExitPlanMode', 'AskUserQuestion')
5928
+ GROUP BY bucket ORDER BY bucket`, ...ew.params).filter((r) => r.bucket != null);
5929
+ }
5930
+ var REWORK_BANDS = ["1", "2-3", "4-6", "7-12", "13+"];
5931
+ function fileRework(db, w) {
5932
+ const ew = withModel(w, "e.model");
5933
+ const rows = queryAll(db, `WITH pairs AS (
5934
+ SELECT COUNT(*) n
5935
+ FROM file_changes fc
5936
+ JOIN events e ON e.uuid = fc.event_uuid
5937
+ JOIN sessions s ON s.id = fc.session_id
5938
+ ${ew.sql} GROUP BY fc.session_id, fc.file_path
5939
+ )
5940
+ SELECT CASE WHEN n = 1 THEN '1' WHEN n <= 3 THEN '2-3' WHEN n <= 6 THEN '4-6'
5941
+ WHEN n <= 12 THEN '7-12' ELSE '13+' END band,
5942
+ COUNT(*) pairs, SUM(n) changes
5943
+ FROM pairs GROUP BY band`, ...ew.params);
5944
+ const byBand = new Map(rows.map((r) => [r.band, r]));
5945
+ return REWORK_BANDS.map((band2) => byBand.get(band2) ?? { band: band2, pairs: 0, changes: 0 });
5946
+ }
5947
+ function findingsOverTime(db, w, bucket) {
5948
+ const ew = withModel(w, "e.model");
5949
+ const sev = (s) => `SUM(CASE WHEN f.severity = '${s}' THEN 1 ELSE 0 END) ${s}`;
5950
+ return queryAll(db, `SELECT ${bucketExpr(bucket, "e.timestamp")} bucket,
5951
+ ${sev("info")}, ${sev("low")}, ${sev("medium")}, ${sev("high")}, ${sev("critical")}
5952
+ FROM findings f
5953
+ JOIN events e ON e.uuid = f.event_uuid
5954
+ JOIN sessions s ON s.id = f.session_id
5955
+ ${ew.sql} GROUP BY bucket ORDER BY bucket`, ...ew.params).filter((r) => r.bucket != null);
5956
+ }
5626
5957
 
5627
5958
  // ../server/dist/refresh.js
5628
5959
  import { existsSync as existsSync6, mkdirSync as mkdirSync5 } from "node:fs";
@@ -5894,7 +6225,7 @@ async function createApp(db, opts = {}) {
5894
6225
  app.get("/api/models", async () => listModels(db));
5895
6226
  const dashFilters = (req) => {
5896
6227
  const q = req.query;
5897
- return { source: q.source, from: q.from, to: q.to };
6228
+ return { source: q.source, from: q.from, to: q.to, models: q.models?.split(",").filter(Boolean) };
5898
6229
  };
5899
6230
  app.get("/api/dashboard/overview", async (req) => dashboardOverview(db, dashFilters(req)));
5900
6231
  app.get("/api/dashboard/timeseries", async (req) => {
@@ -5902,6 +6233,14 @@ async function createApp(db, opts = {}) {
5902
6233
  return dashboardTimeseries(db, dashFilters(req), q.bucket);
5903
6234
  });
5904
6235
  app.get("/api/dashboard/breakdowns", async (req) => dashboardBreakdowns(db, dashFilters(req)));
6236
+ app.get("/api/dashboard/time", async (req) => {
6237
+ const q = req.query;
6238
+ return dashboardTime(db, dashFilters(req), q.bucket);
6239
+ });
6240
+ app.get("/api/dashboard/audit", async (req) => {
6241
+ const q = req.query;
6242
+ return dashboardAudit(db, dashFilters(req), q.bucket);
6243
+ });
5905
6244
  const PREF_KEY = /^[a-z0-9._-]{1,64}$/i;
5906
6245
  app.get("/api/prefs/:key", async (req, reply) => {
5907
6246
  const { key } = req.params;
@@ -6251,8 +6590,8 @@ cli.command("export <sessionId>", "Write a session to a shareable Markdown file
6251
6590
  cli.command(
6252
6591
  "service <action> [target]",
6253
6592
  "Install/uninstall/status OS services \u2014 action: install|uninstall|status; target: collector|server|all (default all)"
6254
- ).option("--times <hours>", "Collector cadence: comma-separated hours 0-23 (default: 9,13,17,21)").action((action, target, opts) => {
6255
- const targets = parseTargets(target);
6593
+ ).option("--times <hours>", "Collector cadence: comma-separated hours 0-23 (default: 9,13,17,21)").action((action, target2, opts) => {
6594
+ const targets = parseTargets(target2);
6256
6595
  const hours = action === "install" ? parseHours(opts.times) : void 0;
6257
6596
  runService(action, { cliEntry: fileURLToPath4(import.meta.url), hours, targets });
6258
6597
  });