@klars/agentobs 0.1.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.
@@ -0,0 +1,143 @@
1
+ /** Start of a range as an ISO string, or null for "all". */
2
+ export function rangeStart(range) {
3
+ const now = new Date();
4
+ switch (range) {
5
+ case 'today': {
6
+ const d = new Date(now);
7
+ d.setHours(0, 0, 0, 0);
8
+ return d.toISOString();
9
+ }
10
+ case '7d':
11
+ return new Date(now.getTime() - 7 * 864e5).toISOString();
12
+ case '30d':
13
+ return new Date(now.getTime() - 30 * 864e5).toISOString();
14
+ default:
15
+ return null;
16
+ }
17
+ }
18
+ export function getSummary(db, range) {
19
+ const since = rangeStart(range);
20
+ const where = since ? 'WHERE started_at >= ?' : '';
21
+ const args = since ? [since] : [];
22
+ 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
32
+ FROM tool_calls ${where}`)
33
+ .get(...args);
34
+ const sessions = db.prepare(`SELECT COUNT(*) AS n FROM sessions ${where}`).get(...args);
35
+ const toolCalls = Number(calls.tool_calls ?? 0);
36
+ const errors = Number(calls.errors ?? 0);
37
+ return {
38
+ range,
39
+ since,
40
+ total_cost_usd: calls.total_cost_usd === null ? null : Number(calls.total_cost_usd),
41
+ uncosted_calls: Number(calls.uncosted_calls ?? 0),
42
+ tool_calls: toolCalls,
43
+ sessions: Number(sessions.n ?? 0),
44
+ errors,
45
+ blocked: Number(calls.blocked ?? 0),
46
+ error_rate: toolCalls === 0 ? 0 : errors / toolCalls,
47
+ tokens_in: Number(calls.tokens_in ?? 0),
48
+ tokens_out: Number(calls.tokens_out ?? 0),
49
+ avg_duration_ms: calls.avg_duration_ms === null ? null : Number(calls.avg_duration_ms),
50
+ };
51
+ }
52
+ /**
53
+ * Activity/cost over time. Buckets hourly for `today` and daily otherwise so
54
+ * the chart keeps a readable number of points at every range.
55
+ */
56
+ export function getTimeline(db, range) {
57
+ const since = rangeStart(range);
58
+ const fmt = range === 'today' ? '%Y-%m-%dT%H:00' : '%Y-%m-%d';
59
+ const where = since ? 'WHERE started_at >= ?' : '';
60
+ const args = since ? [since] : [];
61
+ 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
69
+ ORDER BY bucket ASC`)
70
+ .all(fmt, ...args);
71
+ }
72
+ export function getToolsBreakdown(db, range) {
73
+ const since = rangeStart(range);
74
+ const where = since ? 'WHERE started_at >= ?' : '';
75
+ const args = since ? [since] : [];
76
+ 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
85
+ ORDER BY calls DESC`)
86
+ .all(...args);
87
+ }
88
+ export function getRecentToolCalls(db, opts = {}) {
89
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
90
+ const clauses = [];
91
+ const args = [];
92
+ const since = opts.range ? rangeStart(opts.range) : null;
93
+ if (since) {
94
+ clauses.push('tc.started_at >= ?');
95
+ args.push(since);
96
+ }
97
+ if (opts.status) {
98
+ clauses.push('tc.status = ?');
99
+ args.push(opts.status);
100
+ }
101
+ if (opts.sessionId) {
102
+ clauses.push('tc.session_id = ?');
103
+ args.push(opts.sessionId);
104
+ }
105
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
106
+ 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
117
+ LIMIT ?`)
118
+ .all(...args, limit);
119
+ }
120
+ export function getSessions(db, opts = {}) {
121
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
122
+ const since = opts.range ? rangeStart(opts.range) : null;
123
+ const where = since ? 'WHERE started_at >= ?' : '';
124
+ const args = since ? [since] : [];
125
+ 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
131
+ LIMIT ?`)
132
+ .all(...args, limit);
133
+ }
134
+ export function getPolicyDecisions(db, opts = {}) {
135
+ const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
136
+ 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
140
+ LIMIT ?`)
141
+ .all(limit);
142
+ }
143
+ //# sourceMappingURL=queries.js.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Secret redaction for anything that lands in input_summary/output_summary.
3
+ *
4
+ * This runs before a summary is written to disk or pushed to the cloud, and
5
+ * it is the single reason a hosted tier is trustworthy enough to use: an
6
+ * agent's tool calls routinely contain exactly the credentials that would be
7
+ * most damaging to leak (a `Bash` call exporting an API key, an `Edit`
8
+ * writing a .env file, a curl carrying an Authorization header).
9
+ *
10
+ * Design posture is deliberately over-eager. A false positive costs a user
11
+ * some readability in one dashboard row; a false negative writes a live
12
+ * credential to a database and possibly into a shared team view. Those costs
13
+ * are not symmetric, so when a token merely *looks* like a secret, it goes.
14
+ *
15
+ * Ordering matters: vendor-specific patterns run before the generic
16
+ * structural ones so a recognisable key is labelled by provider
17
+ * (`[REDACTED:aws-access-key-id]`) rather than the vague
18
+ * `[REDACTED:assignment]`. That makes an audit of "what did we nearly leak"
19
+ * far more actionable. The structural rules then skip any value that already
20
+ * carries a marker, so they can never relabel a precise hit.
21
+ */
22
+ /**
23
+ * Vendor-specific credential shapes, matched on distinctive prefixes and so
24
+ * effectively free of false positives.
25
+ */
26
+ const VENDOR_RULES = [
27
+ { name: 'aws-access-key-id', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
28
+ { name: 'anthropic-api-key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
29
+ { name: 'openai-api-key', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
30
+ { name: 'github-token', pattern: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}/g },
31
+ { name: 'gitlab-token', pattern: /\bglpat-[A-Za-z0-9_-]{18,}/g },
32
+ { name: 'slack-token', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
33
+ { name: 'stripe-key', pattern: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
34
+ { name: 'resend-key', pattern: /\bre_[A-Za-z0-9_-]{16,}/g },
35
+ { name: 'google-api-key', pattern: /\bAIza[0-9A-Za-z_-]{30,}/g },
36
+ { name: 'sendgrid-key', pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
37
+ { name: 'npm-token', pattern: /\bnpm_[A-Za-z0-9]{30,}/g },
38
+ { name: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
39
+ { name: 'private-key-block', pattern: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g },
40
+ ];
41
+ /**
42
+ * A secret-ish *name*. The structural rules below key off this rather than
43
+ * the value's shape, which is what catches the long tail no vendor list can
44
+ * (`DB_PASSWORD=hunter2`, `--token abc`, `"client_secret": "..."`).
45
+ */
46
+ const SENSITIVE_NAME = String.raw `[A-Za-z0-9_.-]*(?:passwd|password|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth|credential|session[_-]?id|cookie|passphrase|signing[_-]?key|webhook[_-]?secret|dsn)[A-Za-z0-9_.-]*`;
47
+ /** Value shapes: a quoted string, or a bare run of non-delimiter chars. */
48
+ const QUOTED_OR_BARE = String.raw `"[^"\n]{4,}"|'[^'\n]{4,}'|[^\s,;"'\n})]{4,}`;
49
+ /** Skips a value a vendor rule already replaced, so labels stay precise. */
50
+ const NOT_ALREADY_REDACTED = String.raw `(?!\[REDACTED:)`;
51
+ const STRUCTURAL_RULES = [
52
+ // Authorization: Bearer <token>. Must run BEFORE `assignment`: the header
53
+ // name "Authorization" itself matches SENSITIVE_NAME, so assignment would
54
+ // otherwise consume the scheme word `Bearer` as the value and leave the
55
+ // real credential sitting in the clear after it.
56
+ {
57
+ name: 'auth-header',
58
+ pattern: new RegExp(String.raw `\b(Bearer|Basic|Token)(\s+)${NOT_ALREADY_REDACTED}([A-Za-z0-9_\-.=+/]{8,})`, 'gi'),
59
+ group: 3,
60
+ },
61
+ // KEY=value, KEY: value, "key": "value"
62
+ {
63
+ name: 'assignment',
64
+ pattern: new RegExp(String.raw `("?\b${SENSITIVE_NAME}"?\s*[:=]\s*)${NOT_ALREADY_REDACTED}(${QUOTED_OR_BARE})`, 'gi'),
65
+ group: 2,
66
+ },
67
+ // CLI flags: --token abc, --api-key=abc
68
+ {
69
+ name: 'cli-flag',
70
+ pattern: new RegExp(String.raw `(--?${SENSITIVE_NAME}[=\s]+)${NOT_ALREADY_REDACTED}("[^"\n]{4,}"|'[^'\n]{4,}'|[^\s"'\n]{4,})`, 'gi'),
71
+ group: 2,
72
+ },
73
+ // Credentials embedded in a URL: proto://user:pass@host
74
+ //
75
+ // The userinfo halves exclude '[' and ']' so this cannot re-wrap a marker
76
+ // a vendor rule already inserted. A plain NOT_ALREADY_REDACTED lookahead
77
+ // is not enough here: "[REDACTED:github-token]" itself contains a ':',
78
+ // so the rule would read "[REDACTED" as the username and re-redact the
79
+ // rest into an unreadable "[REDACTED:[REDACTED:url-credentials]".
80
+ {
81
+ name: 'url-credentials',
82
+ pattern: new RegExp(String.raw `(:\/\/[^\s:@/\[\]]{1,64}:)${NOT_ALREADY_REDACTED}([^\s@/\[\]]{1,256})(?=@)`, 'g'),
83
+ group: 2,
84
+ },
85
+ ];
86
+ const ALL_RULES = [...VENDOR_RULES, ...STRUCTURAL_RULES];
87
+ /** Default summary cap: long enough to be useful, short enough to stay cheap. */
88
+ export const DEFAULT_MAX_LENGTH = 500;
89
+ /**
90
+ * Redact secrets from `input`, then truncate.
91
+ *
92
+ * Redaction runs before truncation deliberately: truncating first could slice
93
+ * a credential in half, leaving a fragment that matches no pattern but is
94
+ * still sensitive (and, for a short key, still brute-forceable).
95
+ */
96
+ export function redact(input, maxLength = DEFAULT_MAX_LENGTH) {
97
+ if (input === null || input === undefined)
98
+ return { text: '', redactions: [] };
99
+ let text = typeof input === 'string' ? input : safeStringify(input);
100
+ const fired = new Set();
101
+ for (const rule of ALL_RULES) {
102
+ // These regexes carry /g and are module-level constants, so lastIndex
103
+ // must be reset: a stale value from a previous call would silently skip
104
+ // matches near the start of this string.
105
+ rule.pattern.lastIndex = 0;
106
+ text = text.replace(rule.pattern, (...args) => {
107
+ const match = args[0];
108
+ const groups = args.slice(1, -2);
109
+ fired.add(rule.name);
110
+ const secret = rule.group ? groups[rule.group - 1] : undefined;
111
+ if (secret === undefined)
112
+ return `[REDACTED:${rule.name}]`;
113
+ return match.replace(secret, `[REDACTED:${rule.name}]`);
114
+ });
115
+ }
116
+ if (text.length > maxLength) {
117
+ text = `${text.slice(0, maxLength)}… (+${text.length - maxLength} chars)`;
118
+ }
119
+ return { text, redactions: [...fired] };
120
+ }
121
+ /** Convenience wrapper for callers that only want the cleaned text. */
122
+ export function redactText(input, maxLength) {
123
+ return redact(input, maxLength).text;
124
+ }
125
+ /**
126
+ * JSON.stringify that cannot throw. Tool payloads arrive from external
127
+ * processes and may contain cycles or BigInts; the logging layer must never
128
+ * be the thing that crashes the agent it is observing.
129
+ */
130
+ function safeStringify(value) {
131
+ const seen = new WeakSet();
132
+ try {
133
+ return (JSON.stringify(value, (_key, val) => {
134
+ if (typeof val === 'bigint')
135
+ return val.toString();
136
+ if (typeof val === 'object' && val !== null) {
137
+ if (seen.has(val))
138
+ return '[Circular]';
139
+ seen.add(val);
140
+ }
141
+ return val;
142
+ }) ?? String(value));
143
+ }
144
+ catch {
145
+ return String(value);
146
+ }
147
+ }
148
+ /** Exposed for tests and for `agentobs policy test` diagnostics. */
149
+ export const __ruleNames = ALL_RULES.map((r) => r.name);
150
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { redact } from './redact.js';
3
+ import { computeCost } from './pricing.js';
4
+ import { getMeta } from './db.js';
5
+ const nowIso = () => new Date().toISOString();
6
+ export function startSession(db, input) {
7
+ const id = input.id ?? randomUUID();
8
+ const ts = input.startedAt ?? nowIso();
9
+ db.prepare(`INSERT INTO sessions (id, agent_name, started_at, cwd, git_branch, fidelity, device_id, updated_at)
10
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
11
+ ON CONFLICT(id) DO NOTHING`).run(id, input.agentName, ts, input.cwd ?? null, input.gitBranch ?? null, input.fidelity ?? 'rich', getMeta(db, 'device_id'), ts);
12
+ return id;
13
+ }
14
+ export function endSession(db, sessionId, opts = {}) {
15
+ db.prepare(`UPDATE sessions
16
+ SET ended_at = ?, exit_code = ?, updated_at = ?, synced_at = NULL
17
+ WHERE id = ?`).run(opts.endedAt ?? nowIso(), opts.exitCode ?? null, nowIso(), sessionId);
18
+ }
19
+ /**
20
+ * Ensures a session row exists before a tool call references it.
21
+ *
22
+ * Hooks can fire without a SessionStart ever reaching us - the agent may
23
+ * have been running before AgentObs was installed, or a SessionStart hook
24
+ * may not be configured. Dropping those tool calls would silently lose data,
25
+ * so we synthesise the parent session instead.
26
+ */
27
+ export function ensureSession(db, sessionId, agentName, cwd) {
28
+ const exists = db.prepare('SELECT 1 FROM sessions WHERE id = ?').get(sessionId);
29
+ if (!exists)
30
+ startSession(db, { id: sessionId, agentName, cwd });
31
+ }
32
+ /** Inserts a `pending` tool call at PreToolUse time. Returns its id. */
33
+ export function beginToolCall(db, input) {
34
+ const id = input.id ?? randomUUID();
35
+ const ts = input.startedAt ?? nowIso();
36
+ const summary = redact(input.input);
37
+ db.prepare(`INSERT INTO tool_calls (id, session_id, tool_name, started_at, status, input_summary, model, updated_at)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
39
+ ON CONFLICT(id) DO NOTHING`).run(id, input.sessionId, input.toolName, ts, input.status ?? 'pending', summary.text, input.model ?? null, ts);
40
+ return id;
41
+ }
42
+ /** Finalises a tool call at PostToolUse time, computing duration and cost. */
43
+ export function completeToolCall(db, toolCallId, input) {
44
+ const row = db
45
+ .prepare('SELECT started_at, session_id, model FROM tool_calls WHERE id = ?')
46
+ .get(toolCallId);
47
+ if (!row)
48
+ return;
49
+ const endedAt = input.endedAt ?? nowIso();
50
+ const durationMs = Math.max(0, Date.parse(endedAt) - Date.parse(row.started_at));
51
+ const model = input.model ?? row.model;
52
+ const cost = computeCost(model, input.tokensIn, input.tokensOut);
53
+ const summary = redact(input.output);
54
+ db.prepare(`UPDATE tool_calls
55
+ SET ended_at = ?, duration_ms = ?, status = ?, output_summary = ?,
56
+ tokens_in = ?, tokens_out = ?, cost_usd = ?, model = ?,
57
+ error_message = ?, updated_at = ?, synced_at = NULL
58
+ WHERE id = ?`).run(endedAt, durationMs, input.status, summary.text, input.tokensIn ?? null, input.tokensOut ?? null, cost, model, input.errorMessage ? redact(input.errorMessage, 300).text : null, nowIso(), toolCallId);
59
+ rollUpSession(db, row.session_id);
60
+ }
61
+ /**
62
+ * Recomputes a session's aggregates from its tool calls.
63
+ *
64
+ * Deliberately a full re-aggregation rather than incremental counters: a
65
+ * hook that fires twice, or a crash between insert and update, would drift
66
+ * an incremental counter permanently, and these totals are what the whole
67
+ * dashboard reports. Cost is SUM over known-model rows only, so an unknown
68
+ * model leaves the total honest rather than silently under-reporting.
69
+ */
70
+ export function rollUpSession(db, sessionId) {
71
+ db.prepare(`UPDATE sessions SET
72
+ tool_call_count = (SELECT COUNT(*) FROM tool_calls WHERE session_id = ?),
73
+ error_count = (SELECT COUNT(*) FROM tool_calls WHERE session_id = ? AND status = 'error'),
74
+ blocked_count = (SELECT COUNT(*) FROM tool_calls WHERE session_id = ? AND status = 'blocked'),
75
+ total_tokens_in = (SELECT COALESCE(SUM(tokens_in), 0) FROM tool_calls WHERE session_id = ?),
76
+ total_tokens_out = (SELECT COALESCE(SUM(tokens_out), 0) FROM tool_calls WHERE session_id = ?),
77
+ total_cost_usd = (SELECT SUM(cost_usd) FROM tool_calls WHERE session_id = ?),
78
+ updated_at = ?,
79
+ synced_at = NULL
80
+ WHERE id = ?`).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, nowIso(), sessionId);
81
+ }
82
+ /** Records a policy decision - the audit trail behind every block. */
83
+ export function recordPolicyDecision(db, input) {
84
+ const id = randomUUID();
85
+ db.prepare(`INSERT INTO policy_decisions (id, tool_call_id, session_id, tool_name, rule_matched, decision, reason, decided_at)
86
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.toolCallId ?? null, input.sessionId ?? null, input.toolName ?? null, input.ruleMatched ?? null, input.decision, input.reason ?? null, nowIso());
87
+ return id;
88
+ }
89
+ //# sourceMappingURL=repo.js.map
@@ -0,0 +1,78 @@
1
+ -- AgentObs local schema. Applied idempotently on every open() via
2
+ -- migrations in db.ts, so a fresh install and an upgrade take the same
3
+ -- path. Timestamps are ISO-8601 UTC strings throughout - SQLite has no
4
+ -- native date type, and storing text keeps rows readable in a plain
5
+ -- `sqlite3` shell during support/debugging.
6
+
7
+ CREATE TABLE IF NOT EXISTS sessions (
8
+ id TEXT PRIMARY KEY,
9
+ agent_name TEXT NOT NULL,
10
+ started_at TEXT NOT NULL,
11
+ ended_at TEXT,
12
+ cwd TEXT,
13
+ git_branch TEXT,
14
+ total_tokens_in INTEGER NOT NULL DEFAULT 0,
15
+ total_tokens_out INTEGER NOT NULL DEFAULT 0,
16
+ total_cost_usd REAL,
17
+ tool_call_count INTEGER NOT NULL DEFAULT 0,
18
+ error_count INTEGER NOT NULL DEFAULT 0,
19
+ blocked_count INTEGER NOT NULL DEFAULT 0,
20
+ -- Fidelity of this session's data, so the dashboard can be honest about
21
+ -- what it does and doesn't know rather than implying detail it lacks:
22
+ -- "rich" - per-tool-call detail (hook-based adapters)
23
+ -- "coarse" - session duration/exit code only (process-wrap)
24
+ fidelity TEXT NOT NULL DEFAULT 'rich',
25
+ exit_code INTEGER,
26
+ -- Cloud-sync columns. Present from the start so the local schema never
27
+ -- needs a breaking migration when sync ships; null until logged in.
28
+ device_id TEXT,
29
+ account_id TEXT,
30
+ updated_at TEXT NOT NULL,
31
+ synced_at TEXT
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS tool_calls (
35
+ id TEXT PRIMARY KEY,
36
+ session_id TEXT NOT NULL REFERENCES sessions(id),
37
+ tool_name TEXT NOT NULL,
38
+ started_at TEXT NOT NULL,
39
+ ended_at TEXT,
40
+ duration_ms INTEGER,
41
+ status TEXT NOT NULL, -- success | error | pending | blocked
42
+ input_summary TEXT, -- truncated + secret-redacted
43
+ output_summary TEXT, -- truncated + secret-redacted
44
+ tokens_in INTEGER,
45
+ tokens_out INTEGER,
46
+ cost_usd REAL,
47
+ model TEXT,
48
+ error_message TEXT,
49
+ updated_at TEXT NOT NULL,
50
+ synced_at TEXT
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS policy_decisions (
54
+ id TEXT PRIMARY KEY,
55
+ tool_call_id TEXT REFERENCES tool_calls(id),
56
+ session_id TEXT,
57
+ tool_name TEXT,
58
+ rule_matched TEXT, -- null when default_decision applied
59
+ decision TEXT NOT NULL, -- allow | block | needs_approval
60
+ reason TEXT,
61
+ decided_at TEXT NOT NULL,
62
+ synced_at TEXT
63
+ );
64
+
65
+ -- Single-row table holding this install's identity and local settings.
66
+ CREATE TABLE IF NOT EXISTS meta (
67
+ key TEXT PRIMARY KEY,
68
+ value TEXT
69
+ );
70
+
71
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
72
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_started ON tool_calls(started_at);
73
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_status ON tool_calls(status);
74
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_unsynced ON tool_calls(synced_at) WHERE synced_at IS NULL;
75
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
76
+ CREATE INDEX IF NOT EXISTS idx_sessions_unsynced ON sessions(synced_at) WHERE synced_at IS NULL;
77
+ CREATE INDEX IF NOT EXISTS idx_policy_tool_call ON policy_decisions(tool_call_id);
78
+ CREATE INDEX IF NOT EXISTS idx_policy_decided ON policy_decisions(decided_at);
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Local dashboard server.
3
+ *
4
+ * Uses node:http directly rather than express/fastify: the routing surface is
5
+ * a handful of GET endpoints, and a zero-dependency server keeps `npx
6
+ * agentobs` install-fast and shrinks the supply-chain surface of a tool that
7
+ * reads a developer's activity.
8
+ *
9
+ * Security posture: binds 127.0.0.1 by default, where same-machine access is
10
+ * the same trust boundary as the database file itself. Binding anywhere else
11
+ * requires a token (see requireToken), because the dashboard exposes tool
12
+ * inputs and file paths across the network.
13
+ */
14
+ import { createServer } from 'node:http';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { existsSync } from 'node:fs';
17
+ import { dirname, extname, join, normalize } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { timingSafeEqual } from 'node:crypto';
20
+ import { openDb } from '../core/db.js';
21
+ import { getPolicyDecisions, getRecentToolCalls, getSessions, getSummary, getTimeline, getToolsBreakdown, } from '../core/queries.js';
22
+ import { loadPolicy } from '../core/policy-engine.js';
23
+ const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), 'public');
24
+ const MIME = {
25
+ '.html': 'text/html; charset=utf-8',
26
+ '.css': 'text/css; charset=utf-8',
27
+ '.js': 'text/javascript; charset=utf-8',
28
+ '.svg': 'image/svg+xml',
29
+ '.json': 'application/json; charset=utf-8',
30
+ '.ico': 'image/x-icon',
31
+ '.woff2': 'font/woff2',
32
+ };
33
+ function parseRange(value) {
34
+ return value === 'today' || value === '7d' || value === '30d' || value === 'all' ? value : '7d';
35
+ }
36
+ function json(res, body, status = 200) {
37
+ const payload = JSON.stringify(body);
38
+ res.writeHead(status, {
39
+ 'Content-Type': 'application/json; charset=utf-8',
40
+ 'Content-Length': Buffer.byteLength(payload),
41
+ // The dashboard renders tool inputs; never let a browser cache or a
42
+ // proxy hold on to them.
43
+ 'Cache-Control': 'no-store',
44
+ 'X-Content-Type-Options': 'nosniff',
45
+ });
46
+ res.end(payload);
47
+ }
48
+ export function isLoopback(host) {
49
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
50
+ }
51
+ /**
52
+ * Constant-time token comparison.
53
+ *
54
+ * A plain `===` leaks the token's prefix through timing. That matters here
55
+ * precisely because the non-loopback mode is the one exposed to a network.
56
+ */
57
+ function tokenMatches(expected, provided) {
58
+ if (!provided)
59
+ return false;
60
+ const a = Buffer.from(expected);
61
+ const b = Buffer.from(provided);
62
+ if (a.length !== b.length)
63
+ return false;
64
+ return timingSafeEqual(a, b);
65
+ }
66
+ export function createDashboardServer(opts) {
67
+ const db = opts.db ?? openDb();
68
+ const requireToken = !isLoopback(opts.host);
69
+ return createServer(async (req, res) => {
70
+ try {
71
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
72
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
73
+ json(res, { error: 'method not allowed' }, 405);
74
+ return;
75
+ }
76
+ if (requireToken) {
77
+ const supplied = url.searchParams.get('token') ??
78
+ (req.headers.authorization?.startsWith('Bearer ')
79
+ ? req.headers.authorization.slice(7)
80
+ : null);
81
+ if (!opts.token || !tokenMatches(opts.token, supplied)) {
82
+ json(res, { error: 'unauthorized: pass ?token=<value> printed at startup' }, 401);
83
+ return;
84
+ }
85
+ }
86
+ const range = parseRange(url.searchParams.get('range'));
87
+ switch (url.pathname) {
88
+ case '/api/summary':
89
+ json(res, getSummary(db, range));
90
+ return;
91
+ case '/api/timeline':
92
+ json(res, getTimeline(db, range));
93
+ return;
94
+ case '/api/tools-breakdown':
95
+ json(res, getToolsBreakdown(db, range));
96
+ return;
97
+ case '/api/tool-calls':
98
+ json(res, {
99
+ calls: getRecentToolCalls(db, {
100
+ range,
101
+ limit: Number(url.searchParams.get('limit') ?? 50),
102
+ status: url.searchParams.get('status') ?? undefined,
103
+ sessionId: url.searchParams.get('session') ?? undefined,
104
+ }),
105
+ });
106
+ return;
107
+ case '/api/sessions':
108
+ json(res, {
109
+ sessions: getSessions(db, {
110
+ range,
111
+ limit: Number(url.searchParams.get('limit') ?? 50),
112
+ }),
113
+ });
114
+ return;
115
+ case '/api/policy':
116
+ json(res, {
117
+ ...loadPolicy(),
118
+ decisions: getPolicyDecisions(db, { limit: 100 }),
119
+ });
120
+ return;
121
+ case '/api/health':
122
+ json(res, { ok: true, version: 1 });
123
+ return;
124
+ }
125
+ await serveStatic(url.pathname, res);
126
+ }
127
+ catch (err) {
128
+ json(res, { error: err.message }, 500);
129
+ }
130
+ });
131
+ }
132
+ async function serveStatic(pathname, res) {
133
+ const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
134
+ // normalize + prefix check keeps a crafted "../.." from escaping the
135
+ // public directory and serving arbitrary files off the user's disk.
136
+ const target = normalize(join(PUBLIC_DIR, rel));
137
+ if (!target.startsWith(PUBLIC_DIR) || !existsSync(target)) {
138
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
139
+ res.end('not found');
140
+ return;
141
+ }
142
+ const body = await readFile(target);
143
+ res.writeHead(200, {
144
+ 'Content-Type': MIME[extname(target)] ?? 'application/octet-stream',
145
+ 'Content-Length': body.length,
146
+ 'Cache-Control': 'no-store',
147
+ 'X-Content-Type-Options': 'nosniff',
148
+ });
149
+ res.end(body);
150
+ }
151
+ export function startDashboard(opts) {
152
+ const server = createDashboardServer(opts);
153
+ return new Promise((resolve, reject) => {
154
+ server.once('error', reject);
155
+ server.listen(opts.port, opts.host, () => {
156
+ const address = server.address();
157
+ const port = typeof address === 'object' && address ? address.port : opts.port;
158
+ resolve({ port, close: () => server.close() });
159
+ });
160
+ });
161
+ }
162
+ //# sourceMappingURL=index.js.map