@klars/agentobs 0.1.0 → 0.1.1

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 CHANGED
@@ -1,190 +1,211 @@
1
- # AgentObs
2
-
3
- **See every tool call, token, and dollar your AI coding agents spend — and stop them before they do something risky.**
4
-
5
- AgentObs is an observability and control layer for AI coding agents. It runs
6
- entirely on your machine: a CLI, a local SQLite database, and a dashboard. No
7
- account, no cloud, no telemetry.
8
-
9
- [![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](LICENSE)
10
-
11
- ---
12
-
13
- ## Quick start
14
-
15
- ```bash
16
- npm install -g @klars/agentobs
17
- agentobs init
18
- ```
19
-
20
- `init` prints a hook configuration block. Paste it into `~/.claude/settings.json`
21
- (or a project's `.claude/settings.json`), then:
22
-
23
- ```bash
24
- agentobs dashboard
25
- ```
26
-
27
- Run Claude Code as usual. Tool calls appear in the dashboard within seconds.
28
-
29
- ---
30
-
31
- ## What you get
32
-
33
- | | |
34
- | ---------------------- | ------------------------------------------------------------ |
35
- | **Cost tracking** | Per session, per tool, per day — or blank if the model's price is unknown. Never guessed. |
36
- | **Tool-call timeline** | Every call, its duration, status, and truncated input. |
37
- | **Guardrails** | Block `rm -rf`, require approval for `.env` edits, stop `curl \| sh`. |
38
- | **Audit trail** | Every policy decision recorded with the rule that fired. |
39
- | **Any agent** | Native Claude Code hooks; JSONL ingestion or process-wrapping for everything else. |
40
-
41
- ---
42
-
43
- ## Privacy
44
-
45
- This is the part that matters most, since AgentObs sits in the middle of
46
- everything your agent does.
47
-
48
- - **Nothing leaves your machine.** No network calls, no analytics, no account.
49
- - **Secrets are redacted before anything is written to disk.** Tool inputs and
50
- outputs pass through a redaction layer that recognises AWS keys, Anthropic /
51
- OpenAI / GitHub / GitLab / Slack / Stripe / Google / npm tokens, JWTs, PEM
52
- private keys, `KEY=value` assignments, `--flag secret` arguments,
53
- `Authorization:` headers, and credentials embedded in URLs.
54
- - **Summaries are truncated** to ~500 characters.
55
- - The redaction rules are unit-tested in
56
- [`src/core/redact.test.ts`](src/core/redact.test.ts) — the tests are the
57
- guarantee, and they have caught real leaks during development.
58
-
59
- Everything lives in `~/.agentobs/`. Uninstalling is `rm -rf ~/.agentobs`.
60
-
61
- ---
62
-
63
- ## Commands
64
-
65
- ```
66
- agentobs init Set up ~/.agentobs and print the hook config
67
- agentobs dashboard [--port] [--host] Serve the dashboard (default 127.0.0.1:4300)
68
- agentobs stats [--today] [--since] Print totals in the terminal
69
- agentobs run -- <command...> Observe any command (coarse detail)
70
- agentobs watch <file.jsonl> Ingest a JSONL agent log
71
- agentobs export --format csv|json Export sessions, tool calls, or decisions
72
-
73
- agentobs policy init Write a starter policy.json
74
- agentobs policy check Validate it and list active rules
75
- agentobs policy test <tool> <input> Dry-run a call against the policy
76
- ```
77
-
78
- ---
79
-
80
- ## Guardrails
81
-
82
- `agentobs policy init` writes `~/.agentobs/policy.json`:
83
-
84
- ```json
85
- {
86
- "rules": [
87
- {
88
- "name": "no-recursive-force-delete",
89
- "match": { "tool": "Bash", "command_pattern": "*rm -rf*" },
90
- "decision": "block",
91
- "message": "Recursive force-delete is blocked by AgentObs policy."
92
- },
93
- {
94
- "name": "protect-env-files",
95
- "match": { "tool": "*", "path_pattern": "**/.env*" },
96
- "decision": "needs_approval"
97
- }
98
- ],
99
- "default_decision": "allow"
100
- }
101
- ```
102
-
103
- Rules are evaluated top to bottom; **the first match wins**, so you can put a
104
- narrow `allow` above a broad `block`. Check what a rule will do *before* it
105
- fires mid-task:
106
-
107
- ```bash
108
- $ agentobs policy test Bash "rm -rf ./build"
109
-
110
- Tool Bash
111
- Input rm -rf ./build
112
- Decision BLOCK
113
- Rule no-recursive-force-delete
114
-
115
- This call would be BLOCKED before running.
116
- ```
117
-
118
- Two deliberate behaviours worth knowing:
119
-
120
- - **`needs_approval` currently behaves as a block** with a clearer message.
121
- There is no channel for a hook to prompt you interactively mid-call.
122
- - **A broken policy file fails open.** Invalid JSON or a malformed rule
123
- degrades to allow-everything and reports the problem, because a guardrail
124
- that wedges your agent is worse than no guardrail. Run `agentobs policy check`.
125
-
126
- ---
127
-
128
- ## Agent support
129
-
130
- | Agent | How | Detail |
131
- | --------------- | -------------------------- | ------------------------------------------------- |
132
- | **Claude Code** | Native hooks | **Rich** — every tool call, plus policy enforcement |
133
- | Any CLI agent | `agentobs run -- <cmd>` | **Coarse** — duration and exit code only |
134
- | Custom / in-house | `agentobs watch <file>` | **Rich**, if it writes JSONL |
135
-
136
- The dashboard labels coarse sessions as `coarse` rather than implying detail it
137
- does not have.
138
-
139
- ### A note on cost accuracy
140
-
141
- Claude Code's `PostToolUse` hook payload carries **no token or cost fields**.
142
- AgentObs therefore reads token usage from the session transcript at
143
- `SessionEnd`, which makes **session-level cost accurate** but leaves
144
- **per-tool-call cost blank** for hook-sourced data. It does not divide a total
145
- across calls to manufacture a number.
146
-
147
- Model prices live in `~/.agentobs/pricing.json` and are yours to edit. A model
148
- missing from that file shows cost as `—`, never `$0.00`.
149
-
150
- ---
151
-
152
- ## Dashboard access
153
-
154
- Binds to `127.0.0.1` with no authenticationsame machine, same user, same
155
- trust boundary as the database file.
156
-
157
- Binding anywhere else **requires a token**, printed at startup and included in
158
- the URL:
159
-
160
- ```bash
161
- agentobs dashboard --host 0.0.0.0
162
- ```
163
-
164
- **Never expose the dashboard to the public internet.** It shows tool inputs and
165
- file paths from your repositories.
166
-
167
- ---
168
-
169
- ## Requirements
170
-
171
- Node.js **≥ 22.5** — AgentObs uses the built-in `node:sqlite` module, so there
172
- is no native addon to compile and no C++ toolchain to install.
173
-
174
- ---
175
-
176
- ## Development
177
-
178
- ```bash
179
- npm install
180
- npm run build
181
- npm test
182
- ```
183
-
184
- Adding an adapter for another agent: see [CONTRIBUTING.md](CONTRIBUTING.md).
185
-
186
- ---
187
-
188
- ## License
189
-
190
- MIT © [Klars AI](https://klars.ai)
1
+ <div align="center">
2
+
3
+ <img src="docs/assets/logo.svg" width="72" height="72" alt="" />
4
+
5
+ # AgentObs
6
+
7
+ **See every tool call, token, and dollar your AI agents spend — and stop the risky ones.**
8
+
9
+ [![npm](https://img.shields.io/npm/v/@klars/agentobs?color=2a78d6&label=npm)](https://www.npmjs.com/package/@klars/agentobs)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](LICENSE)
11
+ [![Node](https://img.shields.io/badge/node-%E2%89%A522.5-1baf7a)](https://nodejs.org)
12
+
13
+ [Website](https://agents.klars.ai) · [npm](https://www.npmjs.com/package/@klars/agentobs) · [Contributing](CONTRIBUTING.md)
14
+
15
+ </div>
16
+
17
+ <br>
18
+
19
+ <picture>
20
+ <source srcset="docs/assets/dashboard-dark.png" media="(prefers-color-scheme: dark)" />
21
+ <img src="docs/assets/dashboard.png" alt="The AgentObs dashboard: spend for the week, tool call and error-rate tiles with trend sparklines, an activity chart, and tables of tools and sessions." />
22
+ </picture>
23
+
24
+ <br>
25
+
26
+ ## Guardrails that actually stop things
27
+
28
+ <img src="docs/assets/demo.gif" alt="Terminal demo: agentobs policy test blocks an rm -rf command, then agentobs stats shows the cost, calls, errors and blocked totals." width="820" />
29
+
30
+ AgentObs is an observability and control layer for AI coding agents. It runs
31
+ entirely on your machine: a CLI, a local SQLite database, and a dashboard. No
32
+ account, no cloud, no telemetry.
33
+
34
+ ## Quick start
35
+
36
+ ```bash
37
+ npm install -g @klars/agentobs
38
+ agentobs init
39
+ ```
40
+
41
+ `init` prints a hook configuration block. Paste it into `~/.claude/settings.json`
42
+ (or a project's `.claude/settings.json`), then:
43
+
44
+ ```bash
45
+ agentobs dashboard
46
+ ```
47
+
48
+ Run Claude Code as usual. Tool calls appear in the dashboard within seconds.
49
+
50
+ ---
51
+
52
+ ## What you get
53
+
54
+ | | |
55
+ | ---------------------- | ------------------------------------------------------------ |
56
+ | **Cost tracking** | Per session, per tool, per day or blank if the model's price is unknown. Never guessed. |
57
+ | **Tool-call timeline** | Every call, its duration, status, and truncated input. |
58
+ | **Guardrails** | Block `rm -rf`, require approval for `.env` edits, stop `curl \| sh`. |
59
+ | **Audit trail** | Every policy decision recorded with the rule that fired. |
60
+ | **Any agent** | Native Claude Code hooks; JSONL ingestion or process-wrapping for everything else. |
61
+
62
+ ---
63
+
64
+ ## Privacy
65
+
66
+ This is the part that matters most, since AgentObs sits in the middle of
67
+ everything your agent does.
68
+
69
+ - **Nothing leaves your machine.** No network calls, no analytics, no account.
70
+ - **Secrets are redacted before anything is written to disk.** Tool inputs and
71
+ outputs pass through a redaction layer that recognises AWS keys, Anthropic /
72
+ OpenAI / GitHub / GitLab / Slack / Stripe / Google / npm tokens, JWTs, PEM
73
+ private keys, `KEY=value` assignments, `--flag secret` arguments,
74
+ `Authorization:` headers, and credentials embedded in URLs.
75
+ - **Summaries are truncated** to ~500 characters.
76
+ - The redaction rules are unit-tested in
77
+ [`src/core/redact.test.ts`](src/core/redact.test.ts) — the tests are the
78
+ guarantee, and they have caught real leaks during development.
79
+
80
+ Everything lives in `~/.agentobs/`. Uninstalling is `rm -rf ~/.agentobs`.
81
+
82
+ ---
83
+
84
+ ## Commands
85
+
86
+ ```
87
+ agentobs init Set up ~/.agentobs and print the hook config
88
+ agentobs dashboard [--port] [--host] Serve the dashboard (default 127.0.0.1:4300)
89
+ agentobs stats [--today] [--since] Print totals in the terminal
90
+ agentobs run -- <command...> Observe any command (coarse detail)
91
+ agentobs watch <file.jsonl> Ingest a JSONL agent log
92
+ agentobs export --format csv|json Export sessions, tool calls, or decisions
93
+
94
+ agentobs policy init Write a starter policy.json
95
+ agentobs policy check Validate it and list active rules
96
+ agentobs policy test <tool> <input> Dry-run a call against the policy
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Guardrails
102
+
103
+ `agentobs policy init` writes `~/.agentobs/policy.json`:
104
+
105
+ ```json
106
+ {
107
+ "rules": [
108
+ {
109
+ "name": "no-recursive-force-delete",
110
+ "match": { "tool": "Bash", "command_pattern": "*rm -rf*" },
111
+ "decision": "block",
112
+ "message": "Recursive force-delete is blocked by AgentObs policy."
113
+ },
114
+ {
115
+ "name": "protect-env-files",
116
+ "match": { "tool": "*", "path_pattern": "**/.env*" },
117
+ "decision": "needs_approval"
118
+ }
119
+ ],
120
+ "default_decision": "allow"
121
+ }
122
+ ```
123
+
124
+ Rules are evaluated top to bottom; **the first match wins**, so you can put a
125
+ narrow `allow` above a broad `block`. Check what a rule will do *before* it
126
+ fires mid-task:
127
+
128
+ ```bash
129
+ $ agentobs policy test Bash "rm -rf ./build"
130
+
131
+ Tool Bash
132
+ Input rm -rf ./build
133
+ Decision BLOCK
134
+ Rule no-recursive-force-delete
135
+
136
+ This call would be BLOCKED before running.
137
+ ```
138
+
139
+ Two deliberate behaviours worth knowing:
140
+
141
+ - **`needs_approval` currently behaves as a block** with a clearer message.
142
+ There is no channel for a hook to prompt you interactively mid-call.
143
+ - **A broken policy file fails open.** Invalid JSON or a malformed rule
144
+ degrades to allow-everything and reports the problem, because a guardrail
145
+ that wedges your agent is worse than no guardrail. Run `agentobs policy check`.
146
+
147
+ ---
148
+
149
+ ## Agent support
150
+
151
+ | Agent | How | Detail |
152
+ | --------------- | -------------------------- | ------------------------------------------------- |
153
+ | **Claude Code** | Native hooks | **Rich** — every tool call, plus policy enforcement |
154
+ | Any CLI agent | `agentobs run -- <cmd>` | **Coarse** duration and exit code only |
155
+ | Custom / in-house | `agentobs watch <file>` | **Rich**, if it writes JSONL |
156
+
157
+ The dashboard labels coarse sessions as `coarse` rather than implying detail it
158
+ does not have.
159
+
160
+ ### A note on cost accuracy
161
+
162
+ Claude Code's `PostToolUse` hook payload carries **no token or cost fields**.
163
+ AgentObs therefore reads token usage from the session transcript at
164
+ `SessionEnd`, which makes **session-level cost accurate** but leaves
165
+ **per-tool-call cost blank** for hook-sourced data. It does not divide a total
166
+ across calls to manufacture a number.
167
+
168
+ Model prices live in `~/.agentobs/pricing.json` and are yours to edit. A model
169
+ missing from that file shows cost as `—`, never `$0.00`.
170
+
171
+ ---
172
+
173
+ ## Dashboard access
174
+
175
+ Binds to `127.0.0.1` with no authentication — same machine, same user, same
176
+ trust boundary as the database file.
177
+
178
+ Binding anywhere else **requires a token**, printed at startup and included in
179
+ the URL:
180
+
181
+ ```bash
182
+ agentobs dashboard --host 0.0.0.0
183
+ ```
184
+
185
+ **Never expose the dashboard to the public internet.** It shows tool inputs and
186
+ file paths from your repositories.
187
+
188
+ ---
189
+
190
+ ## Requirements
191
+
192
+ Node.js **≥ 22.5** — AgentObs uses the built-in `node:sqlite` module, so there
193
+ is no native addon to compile and no C++ toolchain to install.
194
+
195
+ ---
196
+
197
+ ## Development
198
+
199
+ ```bash
200
+ npm install
201
+ npm run build
202
+ npm test
203
+ ```
204
+
205
+ Adding an adapter for another agent: see [CONTRIBUTING.md](CONTRIBUTING.md).
206
+
207
+ ---
208
+
209
+ ## License
210
+
211
+ MIT © [Klars AI](https://klars.ai)
@@ -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));