@klars/agentobs 0.1.6 → 0.2.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
@@ -36,16 +36,31 @@ account, no cloud, no telemetry.
36
36
  ```bash
37
37
  npm install -g @klars/agentobs
38
38
  agentobs init
39
+ agentobs import
39
40
  ```
40
41
 
41
- `init` prints a hook configuration block. Paste it into `~/.claude/settings.json`
42
- (or a project's `.claude/settings.json`), then:
42
+ `import` reads Claude Code's own session transcripts from `~/.claude/projects/`
43
+ and backfills everything you have already done — no configuration, no hooks.
44
+ Then:
43
45
 
44
46
  ```bash
45
47
  agentobs dashboard
46
48
  ```
47
49
 
48
- Run Claude Code as usual. Tool calls appear in the dashboard within seconds.
50
+ That is the fastest path to real data, and the one to try first.
51
+
52
+ ### Live capture (optional)
53
+
54
+ `import` is after-the-fact. To record calls **as they happen** — and to let
55
+ guardrails actually block them — add the hook configuration that
56
+ `agentobs init` prints to `~/.claude/settings.json`, then restart Claude Code.
57
+
58
+ > **If hooks record nothing:** this has been observed on at least one Windows
59
+ > install, where Claude Code did not invoke the configured command at all — a
60
+ > plain two-line `.cmd` file also never fired, so it is not specific to
61
+ > AgentObs. Check by running any tool and then `agentobs stats --today`. If it
62
+ > stays at zero, keep using `agentobs import`, which needs no hooks; only
63
+ > guardrail *blocking* depends on them.
49
64
 
50
65
  ---
51
66
 
@@ -85,6 +100,7 @@ Everything lives in `~/.agentobs/`. Uninstalling is `rm -rf ~/.agentobs`.
85
100
 
86
101
  ```
87
102
  agentobs init Set up ~/.agentobs and print the hook config
103
+ agentobs import [--days n] [--all] Import Claude Code transcripts (no hooks needed)
88
104
  agentobs dashboard [--port] [--host] Serve the dashboard (default 127.0.0.1:4300)
89
105
  agentobs stats [--today] [--since] Print totals in the terminal
90
106
  agentobs run -- <command...> Observe any command (coarse detail)
@@ -148,22 +164,37 @@ Two deliberate behaviours worth knowing:
148
164
 
149
165
  ## Agent support
150
166
 
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 |
167
+ | Agent | How | Detail | Needs setup? |
168
+ | --- | --- | --- | --- |
169
+ | **Claude Code** | `agentobs import` | **Rich** — every tool call, tokens, cost | **No** |
170
+ | **Claude Code** | Native hooks | **Rich**, live, and can *block* calls | Yes — hook config |
171
+ | Any CLI agent | `agentobs run -- <cmd>` | **Coarse** duration and exit code only | No |
172
+ | Custom / in-house | `agentobs watch <file>` | **Rich**, if it writes JSONL | No |
173
+
174
+ `import` and hooks read the same underlying data. The difference is timing:
175
+ hooks see a call *before* it runs, which is what makes blocking possible;
176
+ `import` reads the transcript afterwards. If you only want observability,
177
+ `import` is enough and needs no configuration.
156
178
 
157
179
  The dashboard labels coarse sessions as `coarse` rather than implying detail it
158
- does not have.
180
+ does not have, and `agentobs stats` explains why a coarse-only range shows zero
181
+ tool calls.
159
182
 
160
183
  ### A note on cost accuracy
161
184
 
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.
185
+ Claude Code's `PostToolUse` hook payload carries **no token or cost fields**,
186
+ so token usage comes from the session transcript at `SessionEnd` for hooks,
187
+ or directly via `agentobs import`. That makes **session-level cost accurate**
188
+ while leaving **per-tool-call cost blank**: usage is reported per assistant
189
+ message, not per tool call, and dividing a total across calls would be a
190
+ manufactured number.
191
+
192
+ **Cache tokens dominate a long session.** A cached conversation replays its
193
+ whole context on every turn, so `cache_read` can reach hundreds of millions of
194
+ tokens in a single session. AgentObs tracks cache reads (billed at 0.1x input)
195
+ and cache writes (1.25x) separately from fresh tokens, and `agentobs import`
196
+ prints the four lines separately — a single unexplained total looks like a bug
197
+ when the cache line legitimately dwarfs everything else.
167
198
 
168
199
  Model prices live in `~/.agentobs/pricing.json` and are yours to edit. A model
169
200
  missing from that file shows cost as `—`, never `$0.00`.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Claude Code transcript adapter - the hook-free path to rich data.
3
+ *
4
+ * Claude Code writes a JSONL transcript per session under
5
+ * `~/.claude/projects/<slug>/<session-id>.jsonl`, containing every assistant
6
+ * message with its `usage` block and every `tool_use` / `tool_result` pair.
7
+ * That is the same information the hooks would deliver, already on disk.
8
+ *
9
+ * Why this exists: hooks are the intended integration, but they depend on
10
+ * Claude Code actually invoking the configured command, which cannot be
11
+ * verified from inside AgentObs and has been observed silently not happening.
12
+ * Reading the transcript needs no configuration at all, so `agentobs import`
13
+ * works on a machine where hooks do not.
14
+ *
15
+ * Trade-off against hooks, stated plainly: this is after-the-fact. It cannot
16
+ * block a tool call, so guardrails still require the hook. It also attributes
17
+ * tokens per assistant message rather than per tool call, so per-call cost
18
+ * stays null rather than being invented by dividing a total.
19
+ */
20
+ import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
21
+ import { createInterface } from 'node:readline';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { computeCost } from '../core/pricing.js';
25
+ import { beginToolCall, completeToolCall, endSession, ensureSession, rollUpSession, startSession, } from '../core/repo.js';
26
+ /** Root of Claude Code's per-project transcript directories. */
27
+ export function transcriptRoot() {
28
+ return process.env.CLAUDE_CONFIG_DIR
29
+ ? join(process.env.CLAUDE_CONFIG_DIR, 'projects')
30
+ : join(homedir(), '.claude', 'projects');
31
+ }
32
+ /** Every transcript on this machine, newest first. */
33
+ export function findTranscripts(root = transcriptRoot()) {
34
+ if (!existsSync(root))
35
+ return [];
36
+ const out = [];
37
+ for (const project of readdirSync(root)) {
38
+ const dir = join(root, project);
39
+ let entries;
40
+ try {
41
+ if (!statSync(dir).isDirectory())
42
+ continue;
43
+ entries = readdirSync(dir);
44
+ }
45
+ catch {
46
+ continue; // unreadable directory - skip rather than abort the scan
47
+ }
48
+ for (const name of entries) {
49
+ if (!name.endsWith('.jsonl'))
50
+ continue;
51
+ const path = join(dir, name);
52
+ try {
53
+ const st = statSync(path);
54
+ out.push({
55
+ path,
56
+ sessionId: name.replace(/\.jsonl$/, ''),
57
+ project,
58
+ modifiedAt: st.mtimeMs,
59
+ sizeBytes: st.size,
60
+ });
61
+ }
62
+ catch {
63
+ /* vanished between readdir and stat - ignore */
64
+ }
65
+ }
66
+ }
67
+ return out.sort((a, b) => b.modifiedAt - a.modifiedAt);
68
+ }
69
+ /**
70
+ * Cache reads are billed at about a tenth of the normal input rate. Applying
71
+ * the full rate to a long session's replayed context overstates it by roughly
72
+ * an order of magnitude.
73
+ */
74
+ const CACHE_READ_RATE = 0.1;
75
+ /** A cache write bills at 1.25x the normal input rate. */
76
+ const CACHE_WRITE_RATE = 1.25;
77
+ /**
78
+ * Imports one transcript into the local database.
79
+ *
80
+ * Idempotent: session and tool-call ids come from the transcript itself, and
81
+ * the repository layer inserts with ON CONFLICT DO NOTHING, so re-importing a
82
+ * growing transcript adds only what is new. That matters because a session's
83
+ * file keeps being appended to while the session is live.
84
+ */
85
+ export async function importTranscript(db, file) {
86
+ const rl = createInterface({
87
+ input: createReadStream(file.path, { encoding: 'utf8' }),
88
+ crlfDelay: Infinity,
89
+ });
90
+ const result = {
91
+ sessionId: file.sessionId,
92
+ toolCalls: 0,
93
+ tokensIn: 0,
94
+ tokensOut: 0,
95
+ cacheReadTokens: 0,
96
+ cacheWriteTokens: 0,
97
+ cost: null,
98
+ model: null,
99
+ startedAt: null,
100
+ endedAt: null,
101
+ };
102
+ // tool_use appears on an assistant message; its tool_result arrives later on
103
+ // a user message. Hold the open calls so the pair can be joined.
104
+ const pending = new Map();
105
+ let sessionStarted = false;
106
+ let cwd = null;
107
+ for await (const line of rl) {
108
+ if (!line.trim())
109
+ continue;
110
+ let row;
111
+ try {
112
+ row = JSON.parse(line);
113
+ }
114
+ catch {
115
+ continue; // a partially-flushed final line is normal on a live session
116
+ }
117
+ const timestamp = typeof row.timestamp === 'string' ? row.timestamp : null;
118
+ if (timestamp) {
119
+ if (!result.startedAt)
120
+ result.startedAt = timestamp;
121
+ result.endedAt = timestamp;
122
+ }
123
+ if (typeof row.cwd === 'string' && !cwd)
124
+ cwd = row.cwd;
125
+ if (!sessionStarted && timestamp) {
126
+ startSession(db, {
127
+ id: file.sessionId,
128
+ agentName: 'claude-code',
129
+ cwd,
130
+ fidelity: 'rich',
131
+ startedAt: timestamp,
132
+ });
133
+ sessionStarted = true;
134
+ }
135
+ const message = (row.message ?? {});
136
+ const usage = message.usage;
137
+ if (usage && typeof usage === 'object') {
138
+ // Only genuinely fresh input tokens go in tokensIn. Cache writes and
139
+ // reads are tracked separately because they bill at different rates,
140
+ // and because a cache read replays the whole context every turn - the
141
+ // headline token count must not include the same tokens hundreds of
142
+ // times.
143
+ result.tokensIn += usage.input_tokens ?? 0;
144
+ result.tokensOut += usage.output_tokens ?? 0;
145
+ result.cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
146
+ // cache_read is the whole conversation context replayed on every single
147
+ // message, so it re-counts the same tokens on each turn - summing it
148
+ // reported 413 million tokens for one session. It is tracked separately
149
+ // and priced at the cache-read rate, never added to tokens_in, which
150
+ // would make the headline token count meaningless.
151
+ result.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
152
+ if (typeof message.model === 'string')
153
+ result.model = message.model;
154
+ }
155
+ const content = message.content;
156
+ if (!Array.isArray(content))
157
+ continue;
158
+ for (const block of content) {
159
+ if (block?.type === 'tool_use' && typeof block.id === 'string') {
160
+ const name = typeof block.name === 'string' ? block.name : 'unknown';
161
+ const at = timestamp ?? new Date().toISOString();
162
+ ensureSession(db, file.sessionId, 'claude-code', cwd);
163
+ beginToolCall(db, {
164
+ id: block.id,
165
+ sessionId: file.sessionId,
166
+ toolName: name,
167
+ input: block.input,
168
+ startedAt: at,
169
+ });
170
+ pending.set(block.id, { name, startedAt: at });
171
+ result.toolCalls += 1;
172
+ }
173
+ if (block?.type === 'tool_result' && typeof block.tool_use_id === 'string') {
174
+ const isError = block.is_error === true;
175
+ completeToolCall(db, block.tool_use_id, {
176
+ status: isError ? 'error' : 'success',
177
+ output: block.content,
178
+ errorMessage: isError ? String(block.content).slice(0, 300) : null,
179
+ endedAt: timestamp ?? undefined,
180
+ // No per-call tokens: usage is reported per assistant message, and
181
+ // splitting it across calls would be a fabricated number.
182
+ });
183
+ pending.delete(block.tool_use_id);
184
+ }
185
+ }
186
+ }
187
+ if (!sessionStarted)
188
+ return result;
189
+ const baseCost = computeCost(result.model, result.tokensIn, result.tokensOut);
190
+ const readCost = computeCost(result.model, result.cacheReadTokens, 0);
191
+ const writeCost = computeCost(result.model, result.cacheWriteTokens, 0);
192
+ result.cost =
193
+ baseCost === null
194
+ ? null
195
+ : baseCost +
196
+ (readCost ?? 0) * CACHE_READ_RATE +
197
+ (writeCost ?? 0) * CACHE_WRITE_RATE;
198
+ // Session totals come from the transcript's own usage blocks, which are
199
+ // authoritative - the per-call rows have no tokens to sum.
200
+ db.prepare(`UPDATE sessions
201
+ SET total_tokens_in = ?, total_tokens_out = ?, total_cost_usd = ?,
202
+ ended_at = COALESCE(?, ended_at), updated_at = ?, synced_at = NULL
203
+ WHERE id = ?`).run(result.tokensIn, result.tokensOut, result.cost, result.endedAt, new Date().toISOString(), file.sessionId);
204
+ // Recompute counts from the rows just inserted, then restore the token
205
+ // totals, which rollUpSession would otherwise zero out (it sums per-call
206
+ // tokens, and those are deliberately null here).
207
+ rollUpSession(db, file.sessionId);
208
+ db.prepare(`UPDATE sessions SET total_tokens_in = ?, total_tokens_out = ?, total_cost_usd = ? WHERE id = ?`).run(result.tokensIn, result.tokensOut, result.cost, file.sessionId);
209
+ if (result.endedAt)
210
+ endSession(db, file.sessionId, { endedAt: result.endedAt });
211
+ return result;
212
+ }
213
+ //# sourceMappingURL=claude-transcript.js.map
package/dist/cli.js CHANGED
@@ -46,7 +46,7 @@ export function buildProgram() {
46
46
  });
47
47
  program
48
48
  .command('watch')
49
- .argument('<file>', 'JSONL file to tail')
49
+ .argument('[file]', 'JSONL file to tail')
50
50
  .description('Ingest a newline-delimited JSON agent log')
51
51
  .addHelpText('after', `
52
52
  Example:
@@ -78,6 +78,29 @@ On Windows, cmd.exe builtins (dir, echo, type) need: agentobs run -- cmd /c dir`
78
78
  const { run } = await import('./commands/run.js');
79
79
  await run(command ?? [], opts);
80
80
  });
81
+ program
82
+ .command('import')
83
+ .description("Import Claude Code's own transcripts (no hook setup needed)")
84
+ .option('--days <n>', 'only sessions modified in the last n days', '7')
85
+ .option('--all', 'import every transcript found, however old', false)
86
+ .option('--dry-run', 'list what would be imported, write nothing', false)
87
+ .option('--session <id>', 'import one specific session id')
88
+ .addHelpText('after', `
89
+ Claude Code writes a JSONL transcript per session under
90
+ ~/.claude/projects/. This reads them directly, so it works even when
91
+ hooks are not firing - and it backfills everything you have already done.
92
+
93
+ Examples:
94
+ agentobs import last 7 days
95
+ agentobs import --all everything
96
+ agentobs import --dry-run show what would be imported
97
+
98
+ Historical data cannot be blocked retroactively; guardrails still need
99
+ the PreToolUse hook.`)
100
+ .action(async (opts) => {
101
+ const { importCommand } = await import('./commands/import.js');
102
+ await importCommand(opts);
103
+ });
81
104
  program
82
105
  .command('export')
83
106
  .description('Export recorded data')
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `agentobs import` - read Claude Code's own transcripts, no hooks required.
3
+ *
4
+ * This is the fallback when hooks are not firing, and the fastest way to get
5
+ * real data on a fresh install: everything Claude Code has already done is
6
+ * sitting on disk waiting to be read.
7
+ */
8
+ import { openDb } from '../core/db.js';
9
+ import { findTranscripts, importTranscript, transcriptRoot } from '../adapters/claude-transcript.js';
10
+ const money = (v) => (v === null ? '—' : `$${v.toFixed(4)}`);
11
+ export async function importCommand(opts = {}) {
12
+ const all = findTranscripts();
13
+ if (all.length === 0) {
14
+ console.error(`No Claude Code transcripts found under:
15
+ ${transcriptRoot()}
16
+
17
+ Claude Code writes one JSONL file per session there. If you have used it on
18
+ this machine, check the path above exists; set CLAUDE_CONFIG_DIR if your
19
+ install keeps its config somewhere else.`);
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+ const days = opts.all ? Infinity : Number(opts.days ?? 7);
24
+ const cutoff = Number.isFinite(days) ? Date.now() - days * 864e5 : 0;
25
+ const selected = opts.session
26
+ ? all.filter((t) => t.sessionId === opts.session)
27
+ : all.filter((t) => t.modifiedAt >= cutoff);
28
+ if (selected.length === 0) {
29
+ console.log(`Found ${all.length} transcript(s), but none in the last ${days} day(s).\n` +
30
+ `Use --all to import everything, or --days <n> for a wider window.`);
31
+ return;
32
+ }
33
+ if (opts.dryRun) {
34
+ console.log(`Would import ${selected.length} transcript(s):\n`);
35
+ for (const t of selected) {
36
+ const age = Math.round((Date.now() - t.modifiedAt) / 36e5);
37
+ console.log(` ${t.sessionId.slice(0, 8)} ${t.project.padEnd(24).slice(0, 24)} ` +
38
+ `${String(Math.round(t.sizeBytes / 1024)).padStart(6)}KB ${age}h ago`);
39
+ }
40
+ return;
41
+ }
42
+ const db = openDb();
43
+ console.log(`Importing ${selected.length} transcript(s) from ${transcriptRoot()}\n`);
44
+ let calls = 0;
45
+ let tokensIn = 0;
46
+ let tokensOut = 0;
47
+ let cacheRead = 0;
48
+ let cacheWrite = 0;
49
+ let cost = null;
50
+ for (const t of selected) {
51
+ try {
52
+ const r = await importTranscript(db, t);
53
+ calls += r.toolCalls;
54
+ tokensIn += r.tokensIn;
55
+ tokensOut += r.tokensOut;
56
+ cacheRead += r.cacheReadTokens;
57
+ cacheWrite += r.cacheWriteTokens;
58
+ if (r.cost !== null)
59
+ cost = (cost ?? 0) + r.cost;
60
+ console.log(` ${t.sessionId.slice(0, 8)} ${String(r.toolCalls).padStart(5)} calls ` +
61
+ `${String(r.tokensIn + r.tokensOut).padStart(9)} tokens ${money(r.cost).padStart(10)}`);
62
+ }
63
+ catch (err) {
64
+ // One unreadable transcript should not abandon the rest of the import.
65
+ console.log(` ${t.sessionId.slice(0, 8)} skipped: ${err.message}`);
66
+ }
67
+ }
68
+ // Break the cost down. On a long session the cache-read line dominates -
69
+ // the whole context is replayed on every turn, so it can reach billions of
70
+ // tokens. That is genuine billing, but a single unexplained total looks
71
+ // like a bug, so show where it comes from.
72
+ console.log(`
73
+ ${selected.length} session(s) · ${calls} tool calls
74
+
75
+ Fresh input ${tokensIn.toLocaleString().padStart(15)} tokens
76
+ Output ${tokensOut.toLocaleString().padStart(15)} tokens
77
+ Cache write ${cacheWrite.toLocaleString().padStart(15)} tokens (billed 1.25x input)
78
+ Cache read ${cacheRead.toLocaleString().padStart(15)} tokens (billed 0.10x input)
79
+ ${'-'.repeat(52)}
80
+ Estimated cost ${money(cost).padStart(15)}
81
+
82
+ Cache reads replay the conversation context on every turn, so a long
83
+ session accumulates far more of them than fresh tokens - that line
84
+ usually dominates the total. Prices come from ~/.agentobs/pricing.json;
85
+ edit them if yours differ.
86
+
87
+ Run "agentobs stats --today" or "agentobs dashboard" to see it.
88
+
89
+ Note: imported data is historical, so guardrails cannot block anything
90
+ retroactively. Blocking still requires the PreToolUse hook.`);
91
+ }
92
+ //# sourceMappingURL=import.js.map
@@ -3,6 +3,30 @@
3
3
  */
4
4
  import { watchJsonl } from '../adapters/jsonl-watcher.js';
5
5
  export async function watch(file, opts = {}) {
6
+ if (!file) {
7
+ console.error(`No file given.
8
+
9
+ Usage: agentobs watch <file.jsonl>
10
+
11
+ Tails a newline-delimited JSON log and records what it describes. Each
12
+ line is one JSON object with a "type" field:
13
+
14
+ {"type":"session_start","session_id":"s1","agent":"my-agent"}
15
+ {"type":"tool_call_start","session_id":"s1","id":"t1","tool":"Bash",
16
+ "input":{"command":"npm test"}}
17
+ {"type":"tool_call_end","id":"t1","status":"success",
18
+ "tokens_in":1200,"tokens_out":300,"model":"claude-sonnet-4"}
19
+ {"type":"session_end","session_id":"s1","exit_code":0}
20
+
21
+ Examples:
22
+ agentobs watch ./agent.jsonl
23
+ agentobs watch ./agent.jsonl --agent my-agent --no-follow
24
+
25
+ Unlike "agentobs run", this records full per-tool-call detail: tool
26
+ names, inputs, tokens and cost.`);
27
+ process.exitCode = 2;
28
+ return;
29
+ }
6
30
  let seen = 0;
7
31
  console.log(`Watching ${file} (agent: ${opts.agent ?? 'generic'}) — Ctrl-C to stop.`);
8
32
  await watchJsonl(file, {
@@ -22,6 +22,9 @@ export const DEFAULT_PRICING = {
22
22
  _comment: 'Prices in USD per 1,000,000 tokens. Edit freely - AgentObs reads this file at runtime. A model missing here shows cost as blank rather than a guess.',
23
23
  updated: '2026-08-29',
24
24
  models: {
25
+ 'claude-opus-5': { input_per_mtok: 15, output_per_mtok: 75 },
26
+ 'claude-sonnet-5': { input_per_mtok: 3, output_per_mtok: 15 },
27
+ 'claude-fable-5': { input_per_mtok: 3, output_per_mtok: 15 },
25
28
  'claude-opus-4': { input_per_mtok: 15, output_per_mtok: 75 },
26
29
  'claude-sonnet-4': { input_per_mtok: 3, output_per_mtok: 15 },
27
30
  'claude-haiku-4-5': { input_per_mtok: 1, output_per_mtok: 5 },
@@ -57,6 +57,15 @@ export function getSummary(db, range) {
57
57
  FROM tool_calls ${where}`)
58
58
  .get(...args);
59
59
  const sessions = db.prepare(`SELECT COUNT(*) AS n FROM sessions ${where}`).get(...args);
60
+ // Session-level totals. Transcript imports record tokens per session (usage
61
+ // is reported per assistant message, not per tool call), so summing the
62
+ // per-call columns alone would report a fully-imported session as zero.
63
+ const sessionTotals = db
64
+ .prepare(`SELECT COALESCE(SUM(total_tokens_in), 0) AS tokens_in,
65
+ COALESCE(SUM(total_tokens_out), 0) AS tokens_out,
66
+ SUM(total_cost_usd) AS cost
67
+ FROM sessions ${where}`)
68
+ .get(...args);
60
69
  const byFidelity = db
61
70
  .prepare(`SELECT
62
71
  COALESCE(SUM(CASE WHEN fidelity = 'coarse' THEN 1 ELSE 0 END), 0) AS coarse,
@@ -68,15 +77,19 @@ export function getSummary(db, range) {
68
77
  return {
69
78
  range,
70
79
  since,
71
- total_cost_usd: calls.total_cost_usd === null ? null : Number(calls.total_cost_usd),
80
+ total_cost_usd: calls.total_cost_usd === null && sessionTotals.cost === null
81
+ ? null
82
+ : Math.max(Number(calls.total_cost_usd ?? 0), Number(sessionTotals.cost ?? 0)),
72
83
  uncosted_calls: Number(calls.uncosted_calls ?? 0),
73
84
  tool_calls: toolCalls,
74
85
  sessions: Number(sessions.n ?? 0),
75
86
  errors,
76
87
  blocked: Number(calls.blocked ?? 0),
77
88
  error_rate: toolCalls === 0 ? 0 : errors / toolCalls,
78
- tokens_in: Number(calls.tokens_in ?? 0),
79
- tokens_out: Number(calls.tokens_out ?? 0),
89
+ // Prefer whichever source actually has data: hook/JSONL data lands on the
90
+ // tool-call rows, transcript imports on the session rows.
91
+ tokens_in: Math.max(Number(calls.tokens_in ?? 0), Number(sessionTotals.tokens_in ?? 0)),
92
+ tokens_out: Math.max(Number(calls.tokens_out ?? 0), Number(sessionTotals.tokens_out ?? 0)),
80
93
  avg_duration_ms: calls.avg_duration_ms === null ? null : Number(calls.avg_duration_ms),
81
94
  coarse_sessions: Number(byFidelity.coarse ?? 0),
82
95
  rich_sessions: Number(byFidelity.rich ?? 0),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klars/agentobs",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "Observability and control layer for AI coding agents - see every tool call, token, and dollar your agents spend, and stop them before they do something risky.",
5
5
  "license": "MIT",
6
6
  "author": "Klars AI",