@klars/agentobs 0.1.0 → 0.1.2

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.
@@ -15,20 +15,45 @@ export function rangeStart(range) {
15
15
  return null;
16
16
  }
17
17
  }
18
+ /**
19
+ * Totals for an explicit window. Used for the previous-period comparison;
20
+ * `getSummary` handles the current window itself.
21
+ */
22
+ function periodTotals(db, from, to) {
23
+ const calls = db
24
+ .prepare(`SELECT COUNT(*) AS tool_calls,
25
+ COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errors,
26
+ SUM(cost_usd) AS total_cost_usd
27
+ FROM tool_calls
28
+ WHERE started_at >= ? AND started_at < ?`)
29
+ .get(from, to);
30
+ const sessions = db
31
+ .prepare('SELECT COUNT(*) AS n FROM sessions WHERE started_at >= ? AND started_at < ?')
32
+ .get(from, to);
33
+ const toolCalls = Number(calls.tool_calls ?? 0);
34
+ const errors = Number(calls.errors ?? 0);
35
+ return {
36
+ total_cost_usd: calls.total_cost_usd === null ? null : Number(calls.total_cost_usd),
37
+ tool_calls: toolCalls,
38
+ sessions: Number(sessions.n ?? 0),
39
+ errors,
40
+ error_rate: toolCalls === 0 ? 0 : errors / toolCalls,
41
+ };
42
+ }
18
43
  export function getSummary(db, range) {
19
44
  const since = rangeStart(range);
20
45
  const where = since ? 'WHERE started_at >= ?' : '';
21
46
  const args = since ? [since] : [];
22
47
  const calls = db
23
- .prepare(`SELECT
24
- COUNT(*) AS tool_calls,
25
- COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errors,
26
- COALESCE(SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END), 0) AS blocked,
27
- COALESCE(SUM(tokens_in), 0) AS tokens_in,
28
- COALESCE(SUM(tokens_out), 0) AS tokens_out,
29
- SUM(cost_usd) AS total_cost_usd,
30
- COALESCE(SUM(CASE WHEN cost_usd IS NULL AND status <> 'pending' THEN 1 ELSE 0 END), 0) AS uncosted_calls,
31
- AVG(duration_ms) AS avg_duration_ms
48
+ .prepare(`SELECT
49
+ COUNT(*) AS tool_calls,
50
+ COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errors,
51
+ COALESCE(SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END), 0) AS blocked,
52
+ COALESCE(SUM(tokens_in), 0) AS tokens_in,
53
+ COALESCE(SUM(tokens_out), 0) AS tokens_out,
54
+ SUM(cost_usd) AS total_cost_usd,
55
+ COALESCE(SUM(CASE WHEN cost_usd IS NULL AND status <> 'pending' THEN 1 ELSE 0 END), 0) AS uncosted_calls,
56
+ AVG(duration_ms) AS avg_duration_ms
32
57
  FROM tool_calls ${where}`)
33
58
  .get(...args);
34
59
  const sessions = db.prepare(`SELECT COUNT(*) AS n FROM sessions ${where}`).get(...args);
@@ -47,8 +72,20 @@ export function getSummary(db, range) {
47
72
  tokens_in: Number(calls.tokens_in ?? 0),
48
73
  tokens_out: Number(calls.tokens_out ?? 0),
49
74
  avg_duration_ms: calls.avg_duration_ms === null ? null : Number(calls.avg_duration_ms),
75
+ previous: previousPeriod(db, range, since),
50
76
  };
51
77
  }
78
+ /**
79
+ * Totals for the window immediately before the current one, of equal length.
80
+ * Returns null for 'all', where there is no previous period to compare to.
81
+ */
82
+ function previousPeriod(db, range, since) {
83
+ if (!since)
84
+ return null;
85
+ const start = Date.parse(since);
86
+ const spanMs = range === 'today' ? 864e5 : range === '7d' ? 7 * 864e5 : 30 * 864e5;
87
+ return periodTotals(db, new Date(start - spanMs).toISOString(), since);
88
+ }
52
89
  /**
53
90
  * Activity/cost over time. Buckets hourly for `today` and daily otherwise so
54
91
  * the chart keeps a readable number of points at every range.
@@ -59,29 +96,67 @@ export function getTimeline(db, range) {
59
96
  const where = since ? 'WHERE started_at >= ?' : '';
60
97
  const args = since ? [since] : [];
61
98
  return db
62
- .prepare(`SELECT strftime(?, started_at) AS bucket,
63
- COUNT(*) AS calls,
64
- COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errors,
65
- SUM(cost_usd) AS cost_usd,
66
- COALESCE(SUM(COALESCE(tokens_in, 0) + COALESCE(tokens_out, 0)), 0) AS tokens
67
- FROM tool_calls ${where}
68
- GROUP BY bucket
99
+ .prepare(`SELECT strftime(?, started_at) AS bucket,
100
+ COUNT(*) AS calls,
101
+ COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errors,
102
+ SUM(cost_usd) AS cost_usd,
103
+ COALESCE(SUM(COALESCE(tokens_in, 0) + COALESCE(tokens_out, 0)), 0) AS tokens
104
+ FROM tool_calls ${where}
105
+ GROUP BY bucket
69
106
  ORDER BY bucket ASC`)
70
107
  .all(fmt, ...args);
71
108
  }
109
+ /**
110
+ * Compact per-bucket series for the stat-tile sparklines.
111
+ *
112
+ * Returns a fixed 12 buckets (the stat-tile contract's trend length),
113
+ * zero-filled so a quiet day renders as a gap in the line rather than
114
+ * silently shortening the series and misstating the shape.
115
+ */
116
+ export function getSparklines(db, range) {
117
+ const POINTS = 12;
118
+ const spanMs = range === 'today' ? 864e5 : range === '7d' ? 7 * 864e5 : 30 * 864e5;
119
+ const end = Date.now();
120
+ const start = range === 'all' ? null : end - spanMs;
121
+ const bucketMs = (start ? spanMs : 30 * 864e5) / POINTS;
122
+ const origin = start ?? end - 30 * 864e5;
123
+ const rows = db
124
+ .prepare(`SELECT started_at, status, cost_usd, session_id
125
+ FROM tool_calls
126
+ WHERE started_at >= ?`)
127
+ .all(new Date(origin).toISOString());
128
+ const calls = new Array(POINTS).fill(0);
129
+ const cost = new Array(POINTS).fill(0);
130
+ const errors = new Array(POINTS).fill(0);
131
+ const blocked = new Array(POINTS).fill(0);
132
+ const sessionSets = Array.from({ length: POINTS }, () => new Set());
133
+ for (const row of rows) {
134
+ const i = Math.min(POINTS - 1, Math.floor((Date.parse(row.started_at) - origin) / bucketMs));
135
+ if (i < 0)
136
+ continue;
137
+ calls[i] += 1;
138
+ cost[i] += row.cost_usd ?? 0;
139
+ if (row.status === 'error')
140
+ errors[i] += 1;
141
+ if (row.status === 'blocked')
142
+ blocked[i] += 1;
143
+ sessionSets[i].add(row.session_id);
144
+ }
145
+ return { calls, cost, errors, blocked, sessions: sessionSets.map((s) => s.size) };
146
+ }
72
147
  export function getToolsBreakdown(db, range) {
73
148
  const since = rangeStart(range);
74
149
  const where = since ? 'WHERE started_at >= ?' : '';
75
150
  const args = since ? [since] : [];
76
151
  return db
77
- .prepare(`SELECT tool_name,
78
- COUNT(*) AS calls,
79
- COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errors,
80
- COALESCE(SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END), 0) AS blocked,
81
- SUM(cost_usd) AS cost_usd,
82
- AVG(duration_ms) AS avg_duration_ms
83
- FROM tool_calls ${where}
84
- GROUP BY tool_name
152
+ .prepare(`SELECT tool_name,
153
+ COUNT(*) AS calls,
154
+ COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) AS errors,
155
+ COALESCE(SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END), 0) AS blocked,
156
+ SUM(cost_usd) AS cost_usd,
157
+ AVG(duration_ms) AS avg_duration_ms
158
+ FROM tool_calls ${where}
159
+ GROUP BY tool_name
85
160
  ORDER BY calls DESC`)
86
161
  .all(...args);
87
162
  }
@@ -104,16 +179,16 @@ export function getRecentToolCalls(db, opts = {}) {
104
179
  }
105
180
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
106
181
  return db
107
- .prepare(`SELECT tc.id, tc.session_id, s.agent_name, tc.tool_name, tc.started_at,
108
- tc.duration_ms, tc.status, tc.input_summary, tc.output_summary,
109
- tc.cost_usd, tc.error_message,
110
- (SELECT pd.rule_matched FROM policy_decisions pd
111
- WHERE pd.tool_call_id = tc.id
112
- ORDER BY pd.decided_at DESC LIMIT 1) AS rule_matched
113
- FROM tool_calls tc
114
- LEFT JOIN sessions s ON s.id = tc.session_id
115
- ${where}
116
- ORDER BY tc.started_at DESC
182
+ .prepare(`SELECT tc.id, tc.session_id, s.agent_name, tc.tool_name, tc.started_at,
183
+ tc.duration_ms, tc.status, tc.input_summary, tc.output_summary,
184
+ tc.cost_usd, tc.error_message,
185
+ (SELECT pd.rule_matched FROM policy_decisions pd
186
+ WHERE pd.tool_call_id = tc.id
187
+ ORDER BY pd.decided_at DESC LIMIT 1) AS rule_matched
188
+ FROM tool_calls tc
189
+ LEFT JOIN sessions s ON s.id = tc.session_id
190
+ ${where}
191
+ ORDER BY tc.started_at DESC
117
192
  LIMIT ?`)
118
193
  .all(...args, limit);
119
194
  }
@@ -123,20 +198,20 @@ export function getSessions(db, opts = {}) {
123
198
  const where = since ? 'WHERE started_at >= ?' : '';
124
199
  const args = since ? [since] : [];
125
200
  return db
126
- .prepare(`SELECT id, agent_name, started_at, ended_at, cwd, fidelity, tool_call_count,
127
- error_count, blocked_count, total_cost_usd, total_tokens_in,
128
- total_tokens_out, exit_code
129
- FROM sessions ${where}
130
- ORDER BY started_at DESC
201
+ .prepare(`SELECT id, agent_name, started_at, ended_at, cwd, fidelity, tool_call_count,
202
+ error_count, blocked_count, total_cost_usd, total_tokens_in,
203
+ total_tokens_out, exit_code
204
+ FROM sessions ${where}
205
+ ORDER BY started_at DESC
131
206
  LIMIT ?`)
132
207
  .all(...args, limit);
133
208
  }
134
209
  export function getPolicyDecisions(db, opts = {}) {
135
210
  const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
136
211
  return db
137
- .prepare(`SELECT id, tool_call_id, session_id, tool_name, rule_matched, decision, reason, decided_at
138
- FROM policy_decisions
139
- ORDER BY decided_at DESC
212
+ .prepare(`SELECT id, tool_call_id, session_id, tool_name, rule_matched, decision, reason, decided_at
213
+ FROM policy_decisions
214
+ ORDER BY decided_at DESC
140
215
  LIMIT ?`)
141
216
  .all(limit);
142
217
  }
@@ -18,7 +18,7 @@ import { dirname, extname, join, normalize } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { timingSafeEqual } from 'node:crypto';
20
20
  import { openDb } from '../core/db.js';
21
- import { getPolicyDecisions, getRecentToolCalls, getSessions, getSummary, getTimeline, getToolsBreakdown, } from '../core/queries.js';
21
+ import { getPolicyDecisions, getRecentToolCalls, getSessions, getSparklines, getSummary, getTimeline, getToolsBreakdown, } from '../core/queries.js';
22
22
  import { loadPolicy } from '../core/policy-engine.js';
23
23
  const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), 'public');
24
24
  const MIME = {
@@ -86,7 +86,9 @@ export function createDashboardServer(opts) {
86
86
  const range = parseRange(url.searchParams.get('range'));
87
87
  switch (url.pathname) {
88
88
  case '/api/summary':
89
- json(res, getSummary(db, range));
89
+ // Sparklines ride along with the summary: the tiles need both, and
90
+ // one request keeps the 5s poll to a single round trip.
91
+ json(res, { ...getSummary(db, range), sparklines: getSparklines(db, range) });
90
92
  return;
91
93
  case '/api/timeline':
92
94
  json(res, getTimeline(db, range));