@klars/agentobs 0.1.5 → 0.2.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.
- package/dist/adapters/claude-transcript.js +213 -0
- package/dist/cli.js +24 -1
- package/dist/commands/import.js +92 -0
- package/dist/commands/stats.js +32 -8
- package/dist/commands/watch.js +24 -0
- package/dist/core/pricing.js +3 -0
- package/dist/core/queries.js +24 -3
- package/dist/server/public/app.css +18 -0
- package/dist/server/public/app.js +22 -3
- package/package.json +1 -1
|
@@ -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('
|
|
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
|
package/dist/commands/stats.js
CHANGED
|
@@ -19,15 +19,39 @@ export async function stats(opts) {
|
|
|
19
19
|
console.log(JSON.stringify({ summary, tools }, null, 2));
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
|
-
console.log(`
|
|
23
|
-
AgentObs · ${range}
|
|
24
|
-
|
|
25
|
-
Cost ${money(summary.total_cost_usd)}
|
|
26
|
-
Tool calls ${summary.tool_calls}
|
|
27
|
-
Sessions ${summary.sessions}
|
|
28
|
-
Errors ${summary.errors} (${(summary.error_rate * 100).toFixed(1)}%)
|
|
29
|
-
Blocked ${summary.blocked}
|
|
22
|
+
console.log(`
|
|
23
|
+
AgentObs · ${range}
|
|
24
|
+
|
|
25
|
+
Cost ${money(summary.total_cost_usd)}
|
|
26
|
+
Tool calls ${summary.tool_calls}
|
|
27
|
+
Sessions ${summary.sessions}
|
|
28
|
+
Errors ${summary.errors} (${(summary.error_rate * 100).toFixed(1)}%)
|
|
29
|
+
Blocked ${summary.blocked}
|
|
30
30
|
Tokens ${summary.tokens_in.toLocaleString()} in / ${summary.tokens_out.toLocaleString()} out`);
|
|
31
|
+
// A zero tool-call count next to a non-zero session count is the single most
|
|
32
|
+
// confusing thing a new user sees - it reads as a broken install. Say which
|
|
33
|
+
// integration produced the data and what it can and cannot see.
|
|
34
|
+
if (summary.tool_calls === 0 && summary.coarse_sessions > 0) {
|
|
35
|
+
const n = summary.coarse_sessions;
|
|
36
|
+
console.log([
|
|
37
|
+
'',
|
|
38
|
+
` Why the zeros: all ${n} session${n === 1 ? '' : 's'} came from "agentobs run"`,
|
|
39
|
+
' (process-wrap), which observes a process from the outside - it records',
|
|
40
|
+
' duration and exit code, but cannot see individual tool calls or tokens.',
|
|
41
|
+
'',
|
|
42
|
+
' For per-tool-call detail and cost, use the Claude Code hook (see',
|
|
43
|
+
' "agentobs init") or ingest a structured log with "agentobs watch".',
|
|
44
|
+
].join(String.fromCharCode(10)));
|
|
45
|
+
}
|
|
46
|
+
else if (summary.coarse_sessions > 0 && summary.rich_sessions > 0) {
|
|
47
|
+
// Mixed data: the totals are real but under-count, since coarse sessions
|
|
48
|
+
// contribute no tool calls or tokens of their own.
|
|
49
|
+
console.log([
|
|
50
|
+
'',
|
|
51
|
+
` Note: ${summary.coarse_sessions} of ${summary.sessions} sessions are coarse (process-wrap),`,
|
|
52
|
+
' so they add no tool calls or tokens to these totals.',
|
|
53
|
+
].join(String.fromCharCode(10)));
|
|
54
|
+
}
|
|
31
55
|
// State plainly when the cost total is incomplete rather than letting a
|
|
32
56
|
// partial number read as the whole spend.
|
|
33
57
|
if (summary.uncosted_calls > 0) {
|
package/dist/commands/watch.js
CHANGED
|
@@ -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, {
|
package/dist/core/pricing.js
CHANGED
|
@@ -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 },
|
package/dist/core/queries.js
CHANGED
|
@@ -57,21 +57,42 @@ 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);
|
|
69
|
+
const byFidelity = db
|
|
70
|
+
.prepare(`SELECT
|
|
71
|
+
COALESCE(SUM(CASE WHEN fidelity = 'coarse' THEN 1 ELSE 0 END), 0) AS coarse,
|
|
72
|
+
COALESCE(SUM(CASE WHEN fidelity <> 'coarse' THEN 1 ELSE 0 END), 0) AS rich
|
|
73
|
+
FROM sessions ${where}`)
|
|
74
|
+
.get(...args);
|
|
60
75
|
const toolCalls = Number(calls.tool_calls ?? 0);
|
|
61
76
|
const errors = Number(calls.errors ?? 0);
|
|
62
77
|
return {
|
|
63
78
|
range,
|
|
64
79
|
since,
|
|
65
|
-
total_cost_usd: calls.total_cost_usd === null
|
|
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)),
|
|
66
83
|
uncosted_calls: Number(calls.uncosted_calls ?? 0),
|
|
67
84
|
tool_calls: toolCalls,
|
|
68
85
|
sessions: Number(sessions.n ?? 0),
|
|
69
86
|
errors,
|
|
70
87
|
blocked: Number(calls.blocked ?? 0),
|
|
71
88
|
error_rate: toolCalls === 0 ? 0 : errors / toolCalls,
|
|
72
|
-
|
|
73
|
-
|
|
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)),
|
|
74
93
|
avg_duration_ms: calls.avg_duration_ms === null ? null : Number(calls.avg_duration_ms),
|
|
94
|
+
coarse_sessions: Number(byFidelity.coarse ?? 0),
|
|
95
|
+
rich_sessions: Number(byFidelity.rich ?? 0),
|
|
75
96
|
previous: previousPeriod(db, range, since),
|
|
76
97
|
};
|
|
77
98
|
}
|
|
@@ -959,3 +959,21 @@ tbody td:first-child {
|
|
|
959
959
|
.tile[data-accent='sessions'] .tile-label .i { color: var(--series-3); }
|
|
960
960
|
.tile[data-accent='errors'] .tile-label .i { color: var(--status-critical); }
|
|
961
961
|
.tile[data-accent='blocked'] .tile-label .i { color: var(--status-serious); }
|
|
962
|
+
|
|
963
|
+
/* The hero note can run to a full explanatory sentence when the data is
|
|
964
|
+
coarse. Cap its width and keep it under the figure so it wraps tightly
|
|
965
|
+
instead of stretching the flex row and opening a gap beside the stats. */
|
|
966
|
+
.hero > div:first-child {
|
|
967
|
+
flex: 1 1 460px;
|
|
968
|
+
min-width: 0;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.hero-note {
|
|
972
|
+
max-width: 62ch;
|
|
973
|
+
line-height: 1.45;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.hero-side {
|
|
977
|
+
flex: 0 0 auto;
|
|
978
|
+
align-self: flex-start;
|
|
979
|
+
}
|
|
@@ -106,6 +106,7 @@ async function refresh() {
|
|
|
106
106
|
fetchJson('/api/sessions'),
|
|
107
107
|
]);
|
|
108
108
|
|
|
109
|
+
state.summary = summary;
|
|
109
110
|
renderSummary(summary);
|
|
110
111
|
state.timeline = timeline;
|
|
111
112
|
drawTimeline();
|
|
@@ -130,8 +131,16 @@ function renderSummary(s) {
|
|
|
130
131
|
// Say plainly when the cost figure is incomplete, rather than presenting a
|
|
131
132
|
// partial total as if it were the whole spend.
|
|
132
133
|
const note = document.getElementById('hero-note');
|
|
133
|
-
if (s.
|
|
134
|
+
if (s.sessions === 0) {
|
|
134
135
|
note.textContent = 'No activity recorded yet.';
|
|
136
|
+
} else if (s.tool_calls === 0 && s.coarse_sessions > 0) {
|
|
137
|
+
// Never say "no activity" while the Sessions tile shows a number - that
|
|
138
|
+
// contradiction reads as a broken install. Name the reason instead.
|
|
139
|
+
const n = s.coarse_sessions;
|
|
140
|
+
note.textContent =
|
|
141
|
+
`${count(n)} coarse session${n === 1 ? '' : 's'} from "agentobs run" — process-wrap sees duration and exit code, not tool calls or tokens. Connect the Claude Code hook for full detail.`;
|
|
142
|
+
} else if (s.coarse_sessions > 0 && s.rich_sessions > 0) {
|
|
143
|
+
note.textContent = `${count(s.coarse_sessions)} of ${count(s.sessions)} sessions are coarse, so they add no tool calls or tokens to these totals.`;
|
|
135
144
|
} else if (s.uncosted_calls > 0) {
|
|
136
145
|
note.textContent = `${count(s.uncosted_calls)} call${s.uncosted_calls === 1 ? '' : 's'} have no price for their model — add it to ~/.agentobs/pricing.json to include them.`;
|
|
137
146
|
} else {
|
|
@@ -182,7 +191,11 @@ function renderTools(rows) {
|
|
|
182
191
|
body.replaceChildren();
|
|
183
192
|
if (rows.length === 0) {
|
|
184
193
|
const tr = document.createElement('tr');
|
|
185
|
-
|
|
194
|
+
const msg =
|
|
195
|
+
state.summary && state.summary.coarse_sessions > 0
|
|
196
|
+
? 'Coarse sessions record no tool calls — connect the Claude Code hook for per-tool detail.'
|
|
197
|
+
: 'No tool calls recorded yet.';
|
|
198
|
+
tr.append(Object.assign(cell(msg, 'empty'), { colSpan: 5 }));
|
|
186
199
|
body.append(tr);
|
|
187
200
|
return;
|
|
188
201
|
}
|
|
@@ -328,7 +341,13 @@ function drawTimeline() {
|
|
|
328
341
|
ctx.fillStyle = muted;
|
|
329
342
|
ctx.font = '13px ' + cssVar('--font');
|
|
330
343
|
ctx.textAlign = 'center';
|
|
331
|
-
|
|
344
|
+
// Distinguish "nothing happened" from "the data here cannot have tool
|
|
345
|
+
// calls", which is what a coarse-only range actually means.
|
|
346
|
+
const msg =
|
|
347
|
+
state.summary && state.summary.coarse_sessions > 0
|
|
348
|
+
? 'Coarse sessions record no tool calls'
|
|
349
|
+
: 'No activity in this range';
|
|
350
|
+
ctx.fillText(msg, cssWidth / 2, cssHeight / 2);
|
|
332
351
|
return;
|
|
333
352
|
}
|
|
334
353
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klars/agentobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|