@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.
- package/README.md +231 -190
- package/dist/commands/hook-config.js +25 -1
- package/dist/core/queries.js +117 -42
- package/dist/server/index.js +4 -2
- package/dist/server/public/app.css +961 -668
- package/dist/server/public/app.js +647 -502
- package/dist/server/public/index.html +212 -196
- package/dist/server/public/logo.svg +18 -0
- package/package.json +1 -1
package/dist/core/queries.js
CHANGED
|
@@ -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
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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));
|