@swestash/agent-lens 0.5.4 → 0.7.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.
@@ -591,7 +591,7 @@ var CAC = class extends EventEmitter {
591
591
  var cac = (name = "") => new CAC(name);
592
592
 
593
593
  // ../core/dist/schema.js
594
- var SCHEMA_VERSION = 12;
594
+ var SCHEMA_VERSION = 13;
595
595
  var SCHEMA_SQL = (
596
596
  /* sql */
597
597
  `
@@ -712,7 +712,8 @@ CREATE TABLE IF NOT EXISTS tool_calls (
712
712
  workflow_run_id TEXT, -- for Workflow: the run id (toolUseResult.runId, e.g. wf_<id>) \u2014 ties the run to its subagents
713
713
  workflow_name TEXT, -- for Workflow: the workflow name (toolUseResult.workflowName)
714
714
  resolved_model TEXT,
715
- status TEXT, -- success | error | ...
715
+ status TEXT, -- success | error | ... ('error' when the tool_result had is_error)
716
+ error_type TEXT, -- heuristic bucket for status='error' rows (see errors.ts); NULL otherwise
716
717
  total_duration_ms INTEGER,
717
718
  total_tokens INTEGER,
718
719
  total_tool_use_count INTEGER,
@@ -725,6 +726,7 @@ CREATE INDEX IF NOT EXISTS idx_tool_calls_skill ON tool_calls(skill_name);
725
726
  CREATE INDEX IF NOT EXISTS idx_tool_calls_skill_id ON tool_calls(skill_id);
726
727
  CREATE INDEX IF NOT EXISTS idx_tool_calls_spawned ON tool_calls(spawned_session_id);
727
728
  CREATE INDEX IF NOT EXISTS idx_tool_calls_workflow_run ON tool_calls(workflow_run_id);
729
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_error_type ON tool_calls(error_type);
728
730
 
729
731
  -- Skill *versions* (content-addressed). A skill firing injects the full SKILL.md body into the
730
732
  -- transcript as an isMeta user event (begins "Base directory for this skill: \u2026", then the body,
@@ -945,6 +947,35 @@ var SECURITY_CATEGORIES = [
945
947
  }
946
948
  ];
947
949
 
950
+ // ../core/dist/errors.js
951
+ var ERROR_CLASSIFIER_VERSION = 1;
952
+ var REJECTION_TYPES = /* @__PURE__ */ new Set(["user-rejected", "guardrail-blocked"]);
953
+ function errorKind(type) {
954
+ return REJECTION_TYPES.has(type) ? "rejection" : "failure";
955
+ }
956
+ var PATTERNS = [
957
+ // — Rejections / blocks: is_error, but the agent's tool didn't fail — a human or guardrail stopped it.
958
+ { re: /interrupted by user|tool use was rejected|does(?:n['’]| not)t want to proceed|\bCancelled:/i, type: "user-rejected", kind: "rejection" },
959
+ { re: /\bBlocked:/i, type: "guardrail-blocked", kind: "rejection" },
960
+ // — Real tool failures.
961
+ { re: /String to replace not found/i, type: "string-not-found", kind: "failure" },
962
+ { re: /exceeds maximum allowed tokens/i, type: "token-limit", kind: "failure" },
963
+ // A non-zero Bash exit code is a command failure regardless of what its stderr text mentions — check
964
+ // it before the file-state fs-error patterns, since ENOENT/EISDIR also appear inside command stderr
965
+ // (e.g. `Exit code 2 npm error ENOENT`). The file-state patterns then catch the Read/Write/Edit
966
+ // tool_use_errors, which carry no exit code.
967
+ { re: /\bExit code\b/i, type: "command-failed", kind: "failure" },
968
+ { re: /has not been read yet|modified since (?:you |it was )?read|File (?:content )?does not exist|\bEISDIR\b|\bENOENT\b/i, type: "file-state", kind: "failure" }
969
+ ];
970
+ function classifyToolError(resultSummary) {
971
+ const s = resultSummary ?? "";
972
+ for (const p of PATTERNS) {
973
+ if (p.re.test(s))
974
+ return { type: p.type, kind: p.kind };
975
+ }
976
+ return { type: "other", kind: "failure" };
977
+ }
978
+
948
979
  // ../core/dist/pricing.js
949
980
  var PRICE_TABLE = {
950
981
  // Opus 4.0 / 4.1 launched at $15/$75; Opus 4.5 cut the Opus tier to $5/$25 and 4.6/4.7/4.8 kept it.
@@ -2041,8 +2072,13 @@ var ClaudeCodeAdapter = class {
2041
2072
  toolResults.push({
2042
2073
  tool_use_id: b.tool_use_id,
2043
2074
  result_summary: isAskUser ? JSON.stringify({ answers: tur.answers, annotations: tur.annotations ?? {} }) : summarizeResult(b.content),
2075
+ // Tool failure is signalled by `is_error` on the tool_result block itself — NOT by
2076
+ // toolUseResult.status (whose values are task/lifecycle states like completed/pending/
2077
+ // async_launched and never "error"). Map is_error → status="error" so the error roll-ups,
2078
+ // the transcript `tool-err` styling, and detect.ts's failed-attempt de-escalation all fire.
2079
+ // Non-error results fall back to the toolUseResult lifecycle status when present.
2080
+ status: b.is_error === true ? "error" : tur ? asString(tur.status) : null,
2044
2081
  ...tur ? {
2045
- status: asString(tur.status),
2046
2082
  agent_type: asString(tur.agentType),
2047
2083
  // Deterministic link: a Task/Agent subagent's transcript is keyed off its agentId
2048
2084
  // ('agent-'||agentId is the session id assigned by discover()'s filename stem).
@@ -2832,6 +2868,33 @@ function detect(db, dirty) {
2832
2868
  return { count, version: DETECTOR_VERSION };
2833
2869
  }
2834
2870
 
2871
+ // ../ingest/dist/errors.js
2872
+ function classifyErrors(db, dirty) {
2873
+ const incremental = dirty != null;
2874
+ const scope = incremental ? " AND session_id IN (SELECT id FROM _dirty_err)" : "";
2875
+ if (incremental) {
2876
+ db.exec("DROP TABLE IF EXISTS _dirty_err");
2877
+ db.exec("CREATE TEMP TABLE _dirty_err (id TEXT PRIMARY KEY)");
2878
+ const ins = db.prepare("INSERT OR IGNORE INTO _dirty_err (id) VALUES (?)");
2879
+ db.transaction((ids) => {
2880
+ for (const id of ids)
2881
+ ins.run(id);
2882
+ })(dirty);
2883
+ }
2884
+ const rows = db.prepare(`SELECT id, result_summary FROM tool_calls WHERE status = 'error'${scope}`).all();
2885
+ const update = db.prepare("UPDATE tool_calls SET error_type = ? WHERE id = ?");
2886
+ const tx = db.transaction(() => {
2887
+ db.exec(`UPDATE tool_calls SET error_type = NULL WHERE error_type IS NOT NULL${scope}`);
2888
+ for (const r of rows)
2889
+ update.run(classifyToolError(r.result_summary).type, r.id);
2890
+ });
2891
+ tx();
2892
+ if (incremental)
2893
+ db.exec("DROP TABLE IF EXISTS _dirty_err");
2894
+ const count = db.prepare("SELECT COUNT(*) n FROM tool_calls WHERE error_type IS NOT NULL").get().n;
2895
+ return { count, version: ERROR_CLASSIFIER_VERSION };
2896
+ }
2897
+
2835
2898
  // ../ingest/dist/pipeline.js
2836
2899
  import { createHash as createHash2 } from "crypto";
2837
2900
  function newStats() {
@@ -3767,6 +3830,7 @@ function runIngest(argv = process.argv.slice(2)) {
3767
3830
  const expanded = rebuildDerived(db, args.full ? null : dirty);
3768
3831
  const classified = classify(db, args.full ? null : expanded);
3769
3832
  const detected = detect(db, args.full ? null : expanded);
3833
+ classifyErrors(db, args.full ? null : expanded);
3770
3834
  const count = (sql) => db.prepare(sql).get().n;
3771
3835
  const sessions = count("SELECT COUNT(*) n FROM sessions");
3772
3836
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -3808,8 +3872,9 @@ function runMetrics(argv = process.argv.slice(2)) {
3808
3872
  const db = openDb(dbPath);
3809
3873
  const r = classify(db);
3810
3874
  const d = detect(db);
3875
+ const e = classifyErrors(db);
3811
3876
  db.close();
3812
- console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} db=${dbPath}`);
3877
+ console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} errors_classified=${e.count} error_classifier_version=${e.version} db=${dbPath}`);
3813
3878
  }
3814
3879
 
3815
3880
  // ../server/dist/run.js
@@ -3889,6 +3954,14 @@ function listSessions(db, f) {
3889
3954
  where.push("s.is_sidechain = 0");
3890
3955
  if (f.kind === "subagent")
3891
3956
  where.push("s.is_sidechain = 1");
3957
+ if (f.severity?.length) {
3958
+ where.push(`EXISTS (SELECT 1 FROM findings fd WHERE fd.session_id = s.id AND fd.severity IN (${f.severity.map(() => "?").join(",")}))`);
3959
+ params.push(...f.severity);
3960
+ }
3961
+ if (f.errorType?.length) {
3962
+ where.push(`EXISTS (SELECT 1 FROM tool_calls tc WHERE tc.session_id = s.id AND tc.error_type IN (${f.errorType.map(() => "?").join(",")}))`);
3963
+ params.push(...f.errorType);
3964
+ }
3892
3965
  if (f.model) {
3893
3966
  where.push("EXISTS (SELECT 1 FROM token_usage t WHERE t.session_id = s.id AND t.model = ?)");
3894
3967
  params.push(f.model);
@@ -3904,7 +3977,13 @@ function listSessions(db, f) {
3904
3977
  const total = db.prepare(`SELECT COUNT(*) n FROM sessions s ${whereSql}`).get(...params).n;
3905
3978
  const baseSelect = `SELECT s.id, s.ai_title, s.slug, s.source_id, s.is_sidechain, s.started_at, s.ended_at,
3906
3979
  s.duration_ms, s.event_count, s.turn_count, p.path AS project_path,
3907
- (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models
3980
+ (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models,
3981
+ (SELECT COUNT(*) FROM tool_calls tc WHERE tc.session_id = s.id) AS tool_call_count,
3982
+ (SELECT COUNT(*) FROM tool_calls tc WHERE tc.session_id = s.id AND tc.status = 'error') AS tool_error_count,
3983
+ (SELECT COUNT(*) FROM findings fd WHERE fd.session_id = s.id) AS finding_count,
3984
+ (SELECT fd.severity FROM findings fd WHERE fd.session_id = s.id
3985
+ ORDER BY CASE fd.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 WHEN 'info' THEN 0 ELSE -1 END DESC
3986
+ LIMIT 1) AS worst_severity
3908
3987
  FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
3909
3988
  ${whereSql}`;
3910
3989
  const NATIVE_ORDER = {
@@ -3915,13 +3994,29 @@ function listSessions(db, f) {
3915
3994
  };
3916
3995
  const sortKey = f.sort ?? "started";
3917
3996
  const sqlDir = f.dir === "asc" ? "ASC" : "DESC";
3997
+ const JS_SORT = /* @__PURE__ */ new Set(["tokens", "cost", "errors", "security"]);
3918
3998
  let rows;
3919
- if (sortKey === "tokens" || sortKey === "cost") {
3999
+ if (JS_SORT.has(sortKey)) {
3920
4000
  const all = db.prepare(baseSelect).all(...params);
3921
4001
  attachSessionCost(db, all);
3922
4002
  const sign = f.dir === "asc" ? 1 : -1;
4003
+ const metric = (r) => {
4004
+ switch (sortKey) {
4005
+ case "tokens":
4006
+ return r.tokens;
4007
+ case "cost":
4008
+ return r.cost;
4009
+ case "errors":
4010
+ return r.tool_error_count ?? 0;
4011
+ // Security: rank by worst severity (info=0 … critical=4; none = -1), finding count as tiebreak.
4012
+ case "security":
4013
+ return (severityRank(r.worst_severity ?? "") + 1) * 1e6 + (r.finding_count ?? 0);
4014
+ default:
4015
+ return 0;
4016
+ }
4017
+ };
3923
4018
  all.sort((a, b) => {
3924
- const c = (sortKey === "tokens" ? a.tokens - b.tokens : a.cost - b.cost) * sign;
4019
+ const c = (metric(a) - metric(b)) * sign;
3925
4020
  return c !== 0 ? c : String(a.id).localeCompare(String(b.id));
3926
4021
  });
3927
4022
  rows = all.slice(f.offset, f.offset + f.limit);
@@ -3976,7 +4071,7 @@ function getSession(db, id) {
3976
4071
  const eventRows = db.prepare(`SELECT uuid, type, role, timestamp, model, is_sidechain, turn_id, raw_json
3977
4072
  FROM events WHERE session_id = ? ORDER BY timestamp, seq`).all(id);
3978
4073
  const toolRows = db.prepare(`SELECT id, event_uuid, tool_name, skill_name, skill_id, agent_type, spawned_session_id,
3979
- workflow_run_id, workflow_name, status,
4074
+ workflow_run_id, workflow_name, status, error_type,
3980
4075
  total_duration_ms, total_tokens, input_json, result_summary,
3981
4076
  (SELECT COUNT(*) FROM sessions s WHERE s.workflow_run_id = tool_calls.workflow_run_id) AS workflow_agent_count
3982
4077
  FROM tool_calls WHERE session_id = ?`).all(id);
@@ -4106,6 +4201,10 @@ function getSession(db, id) {
4106
4201
  FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
4107
4202
  WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
4108
4203
  ORDER BY t.seq`).all(id);
4204
+ session.tool_call_count = toolRows.length;
4205
+ session.tool_error_count = toolRows.filter((t) => t.status === "error").length;
4206
+ session.tool_rejection_count = toolRows.filter((t) => t.status === "error" && t.error_type && errorKind(t.error_type) === "rejection").length;
4207
+ session.tool_failure_count = session.tool_error_count - session.tool_rejection_count;
4109
4208
  return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
4110
4209
  }
4111
4210
  function xmlTag(text, tag) {
@@ -4388,6 +4487,53 @@ function pct(sorted, p) {
4388
4487
  const rank = Math.ceil(p / 100 * sorted.length) - 1;
4389
4488
  return sorted[Math.min(Math.max(rank, 0), sorted.length - 1)];
4390
4489
  }
4490
+ function tableExists2(db, name) {
4491
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(name);
4492
+ }
4493
+ function workflowWhere(f) {
4494
+ const where = [];
4495
+ const params = [];
4496
+ if (f.source)
4497
+ where.push("source_id = ?"), params.push(f.source);
4498
+ if (f.from)
4499
+ where.push("date(started_at) >= date(?)"), params.push(f.from);
4500
+ if (f.to)
4501
+ where.push("date(started_at) <= date(?)"), params.push(f.to);
4502
+ return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
4503
+ }
4504
+ function workflowAgg(db, f) {
4505
+ const empty = { total: 0, by_status: [], completed: 0, failed: 0, success_rate: 0, total_tokens: 0, avg_duration_ms: 0 };
4506
+ if (!tableExists2(db, "workflow_results"))
4507
+ return empty;
4508
+ const ww = workflowWhere(f);
4509
+ const rows = db.prepare(`SELECT COALESCE(status, '(unknown)') status, COUNT(*) n,
4510
+ SUM(COALESCE(total_tokens, 0)) tokens,
4511
+ SUM(COALESCE(duration_ms, 0)) dur,
4512
+ SUM(CASE WHEN duration_ms IS NOT NULL THEN 1 ELSE 0 END) dur_n
4513
+ FROM workflow_results ${ww.sql} GROUP BY status ORDER BY n DESC`).all(...ww.params);
4514
+ let total = 0, completed = 0, failed = 0, tokens = 0, durSum = 0, durN = 0;
4515
+ const by_status = rows.map((r) => {
4516
+ total += r.n;
4517
+ if (r.status === "completed")
4518
+ completed += r.n;
4519
+ else if (r.status === "failed")
4520
+ failed += r.n;
4521
+ tokens += r.tokens ?? 0;
4522
+ durSum += r.dur ?? 0;
4523
+ durN += r.dur_n ?? 0;
4524
+ return { status: r.status, n: r.n };
4525
+ });
4526
+ const decided = completed + failed;
4527
+ return {
4528
+ total,
4529
+ by_status,
4530
+ completed,
4531
+ failed,
4532
+ success_rate: decided ? completed / decided : 0,
4533
+ total_tokens: tokens,
4534
+ avg_duration_ms: durN ? Math.round(durSum / durN) : 0
4535
+ };
4536
+ }
4391
4537
  function dashboardOverview(db, f) {
4392
4538
  const w = sessionWhere(f);
4393
4539
  const counts = db.prepare(`SELECT
@@ -4414,6 +4560,8 @@ function dashboardOverview(db, f) {
4414
4560
  const totalTokens = tokens.input + tokens.output + tokens.cache_creation + tokens.cache_read;
4415
4561
  const durs = db.prepare(`SELECT tn.duration_ms d FROM turns tn JOIN sessions s ON s.id = tn.session_id
4416
4562
  ${w.sql ? w.sql + " AND" : "WHERE"} tn.duration_ms IS NOT NULL ORDER BY tn.duration_ms`).all(...w.params).map((r) => r.d);
4563
+ const sessDurs = db.prepare(`SELECT s.duration_ms d FROM sessions s
4564
+ ${w.sql ? w.sql + " AND" : "WHERE"} s.is_sidechain = 0 AND s.duration_ms IS NOT NULL ORDER BY s.duration_ms`).all(...w.params).map((r) => r.d);
4417
4565
  return {
4418
4566
  range: { from: f.from ?? null, to: f.to ?? null, source: f.source ?? null },
4419
4567
  sessions: counts.sessions ?? 0,
@@ -4428,7 +4576,9 @@ function dashboardOverview(db, f) {
4428
4576
  cache_read_ratio: totalTokens ? tokens.cache_read / totalTokens : 0,
4429
4577
  cost: Number(cost.toFixed(4)),
4430
4578
  unpriced_models: [...new Set(unpriced)].sort(),
4431
- turn_duration_ms: { p50: pct(durs, 50), p95: pct(durs, 95), count: durs.length }
4579
+ turn_duration_ms: { p50: pct(durs, 50), p95: pct(durs, 95), count: durs.length },
4580
+ session_duration_ms: { p50: pct(sessDurs, 50), p95: pct(sessDurs, 95), count: sessDurs.length },
4581
+ workflows: workflowAgg(db, f)
4432
4582
  };
4433
4583
  }
4434
4584
  function chooseBucket(db, f) {
@@ -4456,7 +4606,7 @@ function dashboardTimeseries(db, f, bucketParam) {
4456
4606
  const get = (b) => {
4457
4607
  let r = byBucket.get(b);
4458
4608
  if (!r) {
4459
- r = { bucket: b, ...zeroSplit(), cost: 0, sessions: 0, turns: 0 };
4609
+ r = { bucket: b, ...zeroSplit(), cost: 0, sessions: 0, turns: 0, failures: 0, rejections: 0 };
4460
4610
  byBucket.set(b, r);
4461
4611
  }
4462
4612
  return r;
@@ -4479,6 +4629,16 @@ function dashboardTimeseries(db, f, bucketParam) {
4479
4629
  if (t.b)
4480
4630
  get(t.b).turns = t.n;
4481
4631
  }
4632
+ for (const e of db.prepare(`SELECT ${expr} b, tc.error_type et, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
4633
+ ${w.sql ? w.sql + " AND" : "WHERE"} tc.status = 'error' GROUP BY b, tc.error_type`).all(...w.params)) {
4634
+ if (!e.b)
4635
+ continue;
4636
+ const r = get(e.b);
4637
+ if (e.et && errorKind(e.et) === "rejection")
4638
+ r.rejections += e.n;
4639
+ else
4640
+ r.failures += e.n;
4641
+ }
4482
4642
  const series = [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
4483
4643
  for (const r of series)
4484
4644
  r.cost = Number(r.cost.toFixed(4));
@@ -4529,7 +4689,20 @@ function dashboardBreakdowns(db, f) {
4529
4689
  max_per_session: counts.length ? Math.max(...counts) : 0,
4530
4690
  avg_per_session: counts.length ? Number((counts.reduce((a, b) => a + b, 0) / counts.length).toFixed(2)) : 0
4531
4691
  };
4532
- return { by_model: byModel, by_source: bySource, by_category: byCategory, by_complexity: byComplexity, tools, skills, skill_versions: skillVersions, subagent_fanout: subagentFanout };
4692
+ const errorRows = db.prepare(`SELECT COALESCE(tc.error_type, 'other') type, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
4693
+ ${w.sql ? w.sql + " AND" : "WHERE"} tc.status = 'error' GROUP BY tc.error_type ORDER BY n DESC`).all(...w.params);
4694
+ let failures = 0;
4695
+ let rejections = 0;
4696
+ const byType = errorRows.map((r) => {
4697
+ const kind = errorKind(r.type);
4698
+ if (kind === "rejection")
4699
+ rejections += r.n;
4700
+ else
4701
+ failures += r.n;
4702
+ return { type: r.type, kind, n: r.n };
4703
+ });
4704
+ const errorTypes = { by_type: byType, failures, rejections };
4705
+ return { by_model: byModel, by_source: bySource, by_category: byCategory, by_complexity: byComplexity, tools, skills, skill_versions: skillVersions, subagent_fanout: subagentFanout, error_types: errorTypes };
4533
4706
  }
4534
4707
 
4535
4708
  // ../server/dist/refresh.js
@@ -4629,12 +4802,33 @@ function listMutes(db) {
4629
4802
  return db.prepare("SELECT rule_id, scope, scope_id, note, muted_at FROM muted_rules ORDER BY muted_at DESC").all();
4630
4803
  }
4631
4804
 
4805
+ // ../server/dist/prefs.js
4806
+ var PREFS_SCHEMA_SQL = (
4807
+ /* sql */
4808
+ `
4809
+ CREATE TABLE IF NOT EXISTS ui_prefs (
4810
+ key TEXT PRIMARY KEY, -- e.g. 'dashboard.charts', 'sessions.columns'
4811
+ value TEXT NOT NULL, -- JSON blob, opaque to the server
4812
+ updated_at TEXT NOT NULL
4813
+ );
4814
+ `
4815
+ );
4816
+ function getPref(db, key) {
4817
+ const row = db.prepare("SELECT value FROM ui_prefs WHERE key = ?").get(key);
4818
+ return row ? row.value : null;
4819
+ }
4820
+ function setPref(db, key, value) {
4821
+ db.prepare(`INSERT INTO ui_prefs (key, value, updated_at) VALUES (?, ?, ?)
4822
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).run(key, value, (/* @__PURE__ */ new Date()).toISOString());
4823
+ }
4824
+
4632
4825
  // ../server/dist/app.js
4633
4826
  async function createApp(db, opts = {}) {
4634
4827
  const app = Fastify({ logger: false });
4635
4828
  let triageDb = null;
4636
4829
  if (opts.triageDbPath) {
4637
4830
  triageDb = openTriage(opts.triageDbPath);
4831
+ triageDb.exec(PREFS_SCHEMA_SQL);
4638
4832
  db.exec(`ATTACH DATABASE '${opts.triageDbPath.replace(/'/g, "''")}' AS triage`);
4639
4833
  app.addHook("onClose", async () => triageDb?.close());
4640
4834
  }
@@ -4681,6 +4875,26 @@ async function createApp(db, opts = {}) {
4681
4875
  return dashboardTimeseries(db, dashFilters(req), q.bucket);
4682
4876
  });
4683
4877
  app.get("/api/dashboard/breakdowns", async (req) => dashboardBreakdowns(db, dashFilters(req)));
4878
+ const PREF_KEY = /^[a-z0-9._-]{1,64}$/i;
4879
+ app.get("/api/prefs/:key", async (req, reply) => {
4880
+ const { key } = req.params;
4881
+ if (!PREF_KEY.test(key))
4882
+ return reply.code(400).send({ error: { code: "BAD_KEY", message: "invalid pref key" } });
4883
+ if (!triageDb)
4884
+ return { value: null };
4885
+ const raw = getPref(triageDb, key);
4886
+ return { value: raw == null ? null : JSON.parse(raw) };
4887
+ });
4888
+ app.put("/api/prefs/:key", async (req, reply) => {
4889
+ const { key } = req.params;
4890
+ if (!PREF_KEY.test(key))
4891
+ return reply.code(400).send({ error: { code: "BAD_KEY", message: "invalid pref key" } });
4892
+ if (!guardWrite(req, reply))
4893
+ return;
4894
+ const body = req.body;
4895
+ setPref(triageDb, key, JSON.stringify(body?.value ?? null));
4896
+ return { ok: true };
4897
+ });
4684
4898
  app.get("/api/sessions", async (req) => {
4685
4899
  const q = req.query;
4686
4900
  return listSessions(db, {
@@ -4691,7 +4905,9 @@ async function createApp(db, opts = {}) {
4691
4905
  from: q.from,
4692
4906
  to: q.to,
4693
4907
  kind: q.kind === "main" || q.kind === "subagent" ? q.kind : void 0,
4694
- sort: ["started", "title", "turns", "tokens", "cost", "duration"].find((s) => s === q.sort),
4908
+ severity: q.severity ? q.severity.split(",").filter(Boolean) : void 0,
4909
+ errorType: q.error_type ? q.error_type.split(",").filter(Boolean) : void 0,
4910
+ sort: ["started", "title", "turns", "tokens", "cost", "duration", "errors", "security"].find((s) => s === q.sort),
4695
4911
  dir: q.dir === "asc" ? "asc" : q.dir === "desc" ? "desc" : void 0,
4696
4912
  limit: Math.min(Number(q.limit) || 50, 200),
4697
4913
  offset: Number(q.offset) || 0