agent-optic 0.2.0 → 0.3.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,566 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * branch-report.ts — Generate a self-contained HTML report of token usage and cost per branch.
4
+ *
5
+ * Usage:
6
+ * bun examples/branch-report.ts [path-to-.ai-usage.jsonl] > report.html
7
+ *
8
+ * Defaults to .ai-usage.jsonl in cwd if no argument is given.
9
+ * Outputs a self-contained HTML file to stdout.
10
+ */
11
+
12
+ import { basename, resolve } from "node:path";
13
+
14
+ // ── Types ─────────────────────────────────────────────────────────────────────
15
+
16
+ interface UsageRecord {
17
+ commit: string;
18
+ timestamp: string;
19
+ branch: string;
20
+ author: string;
21
+ session_ids: string[];
22
+ tokens: {
23
+ input: number;
24
+ output: number;
25
+ cache_read: number;
26
+ cache_write: number;
27
+ };
28
+ cost_usd: number;
29
+ models: string[];
30
+ messages: number;
31
+ files_changed: number;
32
+ }
33
+
34
+ interface BranchStats {
35
+ branch: string;
36
+ costUsd: number;
37
+ commits: number;
38
+ filesChanged: number;
39
+ messages: number;
40
+ sessionIds: Set<string>;
41
+ tokens: {
42
+ input: number;
43
+ output: number;
44
+ cache_read: number;
45
+ cache_write: number;
46
+ };
47
+ models: Set<string>;
48
+ firstDate: string;
49
+ lastDate: string;
50
+ }
51
+
52
+ // ── Helpers ───────────────────────────────────────────────────────────────────
53
+
54
+ function fmtTokens(n: number): string {
55
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
56
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
57
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
58
+ return String(n);
59
+ }
60
+
61
+ function fmtCost(usd: number): string {
62
+ if (usd >= 100) return `$${usd.toFixed(0)}`;
63
+ if (usd >= 10) return `$${usd.toFixed(1)}`;
64
+ return `$${usd.toFixed(2)}`;
65
+ }
66
+
67
+ function isoToDate(iso: string): string {
68
+ return iso.slice(0, 10);
69
+ }
70
+
71
+ function costBarColor(cost: number, max: number): string {
72
+ const ratio = max > 0 ? cost / max : 0;
73
+ // green (0) → yellow (0.4) → orange (0.7) → red (1.0)
74
+ if (ratio < 0.4) {
75
+ const t = ratio / 0.4;
76
+ const r = Math.round(50 + t * 205);
77
+ const g = Math.round(180 - t * 40);
78
+ return `rgb(${r},${g},60)`;
79
+ }
80
+ if (ratio < 0.7) {
81
+ const t = (ratio - 0.4) / 0.3;
82
+ const r = Math.round(255);
83
+ const g = Math.round(140 - t * 60);
84
+ return `rgb(${r},${g},30)`;
85
+ }
86
+ const t = (ratio - 0.7) / 0.3;
87
+ const r = Math.round(255);
88
+ const g = Math.round(80 - t * 60);
89
+ return `rgb(${r},${g},20)`;
90
+ }
91
+
92
+ // ── Load and aggregate ────────────────────────────────────────────────────────
93
+
94
+ async function loadRecords(filePath: string): Promise<UsageRecord[]> {
95
+ const file = Bun.file(filePath);
96
+ if (!(await file.exists())) {
97
+ process.stderr.write(`Error: file not found: ${filePath}\n`);
98
+ process.exit(1);
99
+ }
100
+ const text = await file.text();
101
+ const records: UsageRecord[] = [];
102
+ for (const line of text.split("\n")) {
103
+ const trimmed = line.trim();
104
+ if (!trimmed) continue;
105
+ try {
106
+ records.push(JSON.parse(trimmed) as UsageRecord);
107
+ } catch {
108
+ // skip malformed lines
109
+ }
110
+ }
111
+ return records;
112
+ }
113
+
114
+ function aggregateByBranch(records: UsageRecord[]): BranchStats[] {
115
+ const map = new Map<string, BranchStats>();
116
+
117
+ for (const r of records) {
118
+ const branch = r.branch || "(unknown)";
119
+ if (!map.has(branch)) {
120
+ map.set(branch, {
121
+ branch,
122
+ costUsd: 0,
123
+ commits: 0,
124
+ filesChanged: 0,
125
+ messages: 0,
126
+ sessionIds: new Set(),
127
+ tokens: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
128
+ models: new Set(),
129
+ firstDate: isoToDate(r.timestamp),
130
+ lastDate: isoToDate(r.timestamp),
131
+ });
132
+ }
133
+
134
+ const s = map.get(branch)!;
135
+ s.costUsd += r.cost_usd ?? 0;
136
+ s.commits += 1;
137
+ s.filesChanged += r.files_changed ?? 0;
138
+ s.messages += r.messages ?? 0;
139
+ for (const id of r.session_ids ?? []) s.sessionIds.add(id);
140
+ s.tokens.input += r.tokens?.input ?? 0;
141
+ s.tokens.output += r.tokens?.output ?? 0;
142
+ s.tokens.cache_read += r.tokens?.cache_read ?? 0;
143
+ s.tokens.cache_write += r.tokens?.cache_write ?? 0;
144
+ for (const m of r.models ?? []) s.models.add(m);
145
+
146
+ const d = isoToDate(r.timestamp);
147
+ if (d < s.firstDate) s.firstDate = d;
148
+ if (d > s.lastDate) s.lastDate = d;
149
+ }
150
+
151
+ return [...map.values()].sort((a, b) => b.costUsd - a.costUsd);
152
+ }
153
+
154
+ // ── HTML generation ───────────────────────────────────────────────────────────
155
+
156
+ function escHtml(s: string): string {
157
+ return s
158
+ .replace(/&/g, "&amp;")
159
+ .replace(/</g, "&lt;")
160
+ .replace(/>/g, "&gt;")
161
+ .replace(/"/g, "&quot;");
162
+ }
163
+
164
+ function shortBranch(b: string): string {
165
+ // Show last two path segments to keep it readable
166
+ const parts = b.split("/");
167
+ if (parts.length > 2) return parts.slice(-2).join("/");
168
+ return b;
169
+ }
170
+
171
+ function buildHtml(branches: BranchStats[], projectTitle: string, records: UsageRecord[]): string {
172
+ const totalCost = branches.reduce((s, b) => s + b.costUsd, 0);
173
+ const totalTokens =
174
+ branches.reduce((s, b) => s + b.tokens.input + b.tokens.output + b.tokens.cache_read + b.tokens.cache_write, 0);
175
+ const totalCommits = branches.reduce((s, b) => s + b.commits, 0);
176
+ const branchCount = branches.length;
177
+ const maxCost = branches[0]?.costUsd ?? 1;
178
+
179
+ const generatedAt = new Date().toLocaleString();
180
+
181
+ // Build branch rows HTML
182
+ const branchRows = branches.map((b) => {
183
+ const barPct = maxCost > 0 ? (b.costUsd / maxCost) * 100 : 0;
184
+ const barColor = costBarColor(b.costUsd, maxCost);
185
+ const dateRange =
186
+ b.firstDate === b.lastDate ? b.firstDate : `${b.firstDate} → ${b.lastDate}`;
187
+ const totalTok = b.tokens.input + b.tokens.output + b.tokens.cache_read + b.tokens.cache_write;
188
+ const modelList = [...b.models].join(", ");
189
+ const modelLabel = modelList ? `<span class="tag">${escHtml(modelList)}</span>` : "";
190
+
191
+ return `
192
+ <div class="branch-row">
193
+ <div class="branch-sidebar">
194
+ <div class="branch-name" title="${escHtml(b.branch)}">${escHtml(shortBranch(b.branch))}</div>
195
+ <div class="branch-full" title="${escHtml(b.branch)}">${escHtml(b.branch)}</div>
196
+ </div>
197
+ <div class="branch-content">
198
+ <div class="branch-top">
199
+ <div class="cost-area">
200
+ <div class="cost-bar-wrap">
201
+ <div class="cost-bar" style="width:${barPct.toFixed(1)}%;background:${barColor}"></div>
202
+ </div>
203
+ <span class="cost-label">${escHtml(fmtCost(b.costUsd))}</span>
204
+ </div>
205
+ <div class="branch-meta">
206
+ <span class="meta-item" title="Commits"><svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="3"/><line x1="0" y1="8" x2="5" y2="8" stroke="currentColor" stroke-width="1.5"/><line x1="11" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="1.5"/></svg> ${b.commits} commit${b.commits !== 1 ? "s" : ""}</span>
207
+ <span class="meta-item" title="Files changed"><svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="1" width="9" height="14" rx="1"/><path d="M11 1l3 3v11"/><line x1="5" y1="6" x2="9" y2="6"/><line x1="5" y1="9" x2="9" y2="9"/></svg> ${b.filesChanged} file${b.filesChanged !== 1 ? "s" : ""}</span>
208
+ <span class="meta-item" title="Messages"><svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H2a1 1 0 00-1 1v8a1 1 0 001 1h3l3 3 3-3h3a1 1 0 001-1V3a1 1 0 00-1-1z"/></svg> ${b.messages} msg</span>
209
+ <span class="meta-item date">${escHtml(dateRange)}</span>
210
+ </div>
211
+ </div>
212
+ <div class="token-row">
213
+ <div class="token-block">
214
+ <div class="token-value">${escHtml(fmtTokens(b.tokens.output))}</div>
215
+ <div class="token-label">output</div>
216
+ </div>
217
+ <div class="token-block">
218
+ <div class="token-value dim">${escHtml(fmtTokens(b.tokens.cache_read))}</div>
219
+ <div class="token-label">cache read</div>
220
+ </div>
221
+ <div class="token-block">
222
+ <div class="token-value dim">${escHtml(fmtTokens(b.tokens.cache_write))}</div>
223
+ <div class="token-label">cache write</div>
224
+ </div>
225
+ <div class="token-block">
226
+ <div class="token-value dim">${escHtml(fmtTokens(b.tokens.input))}</div>
227
+ <div class="token-label">input</div>
228
+ </div>
229
+ <div class="token-block total">
230
+ <div class="token-value">${escHtml(fmtTokens(totalTok))}</div>
231
+ <div class="token-label">total tokens</div>
232
+ </div>
233
+ ${modelLabel ? `<div class="model-label">${modelLabel}</div>` : ""}
234
+ </div>
235
+ </div>
236
+ </div>`;
237
+ }).join("\n");
238
+
239
+ return `<!DOCTYPE html>
240
+ <html lang="en">
241
+ <head>
242
+ <meta charset="UTF-8">
243
+ <meta name="viewport" content="width=device-width, initial-scale=1">
244
+ <title>Branch AI Usage — ${escHtml(projectTitle)}</title>
245
+ <style>
246
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
247
+
248
+ :root {
249
+ --bg: #f7f8fa;
250
+ --surface: #ffffff;
251
+ --sidebar: #1e2230;
252
+ --sidebar-text: #e8eaf0;
253
+ --sidebar-sub: #8891aa;
254
+ --border: #e2e5ec;
255
+ --text: #1a1d2e;
256
+ --text-sub: #6b7280;
257
+ --accent: #4f6af0;
258
+ --summary-bg: #1e2230;
259
+ --summary-text: #e8eaf0;
260
+ --bar-bg: #e8eaf0;
261
+ --radius: 8px;
262
+ --shadow: 0 1px 4px rgba(0,0,0,.08);
263
+ }
264
+
265
+ body {
266
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
267
+ font-size: 14px;
268
+ line-height: 1.5;
269
+ background: var(--bg);
270
+ color: var(--text);
271
+ min-height: 100vh;
272
+ }
273
+
274
+ /* ── Header ── */
275
+ .page-header {
276
+ background: var(--summary-bg);
277
+ color: var(--summary-text);
278
+ padding: 28px 32px 24px;
279
+ }
280
+ .page-header h1 {
281
+ font-size: 22px;
282
+ font-weight: 600;
283
+ letter-spacing: -0.3px;
284
+ margin-bottom: 4px;
285
+ }
286
+ .page-header .subtitle {
287
+ font-size: 13px;
288
+ color: var(--sidebar-sub);
289
+ }
290
+
291
+ /* ── Summary bar ── */
292
+ .summary-bar {
293
+ display: flex;
294
+ gap: 0;
295
+ background: #161925;
296
+ border-top: 1px solid #2d3248;
297
+ padding: 0 32px;
298
+ overflow-x: auto;
299
+ }
300
+ .summary-item {
301
+ padding: 14px 28px 14px 0;
302
+ margin-right: 28px;
303
+ border-right: 1px solid #2d3248;
304
+ }
305
+ .summary-item:last-child { border-right: none; }
306
+ .summary-value {
307
+ font-size: 24px;
308
+ font-weight: 700;
309
+ color: #ffffff;
310
+ letter-spacing: -0.5px;
311
+ }
312
+ .summary-key {
313
+ font-size: 11px;
314
+ text-transform: uppercase;
315
+ letter-spacing: 0.5px;
316
+ color: var(--sidebar-sub);
317
+ margin-top: 2px;
318
+ }
319
+
320
+ /* ── Main content ── */
321
+ .main {
322
+ max-width: 1100px;
323
+ margin: 28px auto;
324
+ padding: 0 20px;
325
+ }
326
+
327
+ .section-title {
328
+ font-size: 12px;
329
+ font-weight: 600;
330
+ text-transform: uppercase;
331
+ letter-spacing: 0.7px;
332
+ color: var(--text-sub);
333
+ margin-bottom: 12px;
334
+ }
335
+
336
+ /* ── Branch rows ── */
337
+ .branch-list {
338
+ display: flex;
339
+ flex-direction: column;
340
+ gap: 10px;
341
+ }
342
+
343
+ .branch-row {
344
+ display: flex;
345
+ background: var(--surface);
346
+ border: 1px solid var(--border);
347
+ border-radius: var(--radius);
348
+ box-shadow: var(--shadow);
349
+ overflow: hidden;
350
+ }
351
+
352
+ .branch-sidebar {
353
+ background: var(--sidebar);
354
+ color: var(--sidebar-text);
355
+ width: 200px;
356
+ min-width: 160px;
357
+ padding: 14px 16px;
358
+ display: flex;
359
+ flex-direction: column;
360
+ justify-content: center;
361
+ word-break: break-all;
362
+ }
363
+ .branch-name {
364
+ font-size: 13px;
365
+ font-weight: 600;
366
+ color: #ffffff;
367
+ margin-bottom: 3px;
368
+ overflow: hidden;
369
+ text-overflow: ellipsis;
370
+ white-space: nowrap;
371
+ }
372
+ .branch-full {
373
+ font-size: 10px;
374
+ color: var(--sidebar-sub);
375
+ overflow: hidden;
376
+ text-overflow: ellipsis;
377
+ white-space: nowrap;
378
+ }
379
+
380
+ .branch-content {
381
+ flex: 1;
382
+ padding: 12px 18px 14px;
383
+ display: flex;
384
+ flex-direction: column;
385
+ gap: 10px;
386
+ min-width: 0;
387
+ }
388
+
389
+ /* ── Cost bar area ── */
390
+ .branch-top {
391
+ display: flex;
392
+ align-items: center;
393
+ gap: 16px;
394
+ flex-wrap: wrap;
395
+ }
396
+ .cost-area {
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 10px;
400
+ flex: 1;
401
+ min-width: 180px;
402
+ }
403
+ .cost-bar-wrap {
404
+ flex: 1;
405
+ height: 10px;
406
+ background: var(--bar-bg);
407
+ border-radius: 5px;
408
+ overflow: hidden;
409
+ }
410
+ .cost-bar {
411
+ height: 100%;
412
+ border-radius: 5px;
413
+ transition: width 0.3s ease;
414
+ min-width: 3px;
415
+ }
416
+ .cost-label {
417
+ font-size: 16px;
418
+ font-weight: 700;
419
+ color: var(--text);
420
+ min-width: 54px;
421
+ text-align: right;
422
+ }
423
+
424
+ /* ── Meta row ── */
425
+ .branch-meta {
426
+ display: flex;
427
+ gap: 14px;
428
+ flex-wrap: wrap;
429
+ align-items: center;
430
+ }
431
+ .meta-item {
432
+ display: flex;
433
+ align-items: center;
434
+ gap: 4px;
435
+ font-size: 12px;
436
+ color: var(--text-sub);
437
+ white-space: nowrap;
438
+ }
439
+ .meta-item.date { color: #9ba3b8; }
440
+ .meta-item svg { flex-shrink: 0; opacity: 0.7; }
441
+
442
+ /* ── Token row ── */
443
+ .token-row {
444
+ display: flex;
445
+ gap: 18px;
446
+ align-items: flex-end;
447
+ flex-wrap: wrap;
448
+ }
449
+ .token-block {
450
+ display: flex;
451
+ flex-direction: column;
452
+ min-width: 52px;
453
+ }
454
+ .token-value {
455
+ font-size: 14px;
456
+ font-weight: 600;
457
+ color: var(--text);
458
+ }
459
+ .token-value.dim {
460
+ color: var(--text-sub);
461
+ font-weight: 500;
462
+ }
463
+ .token-label {
464
+ font-size: 10px;
465
+ text-transform: uppercase;
466
+ letter-spacing: 0.4px;
467
+ color: #adb5c8;
468
+ margin-top: 1px;
469
+ }
470
+ .token-block.total .token-value {
471
+ color: var(--accent);
472
+ font-weight: 700;
473
+ }
474
+ .model-label {
475
+ margin-left: auto;
476
+ align-self: flex-end;
477
+ }
478
+ .tag {
479
+ display: inline-block;
480
+ font-size: 10px;
481
+ padding: 2px 7px;
482
+ border-radius: 10px;
483
+ background: #eef0f8;
484
+ color: #5a6480;
485
+ white-space: nowrap;
486
+ }
487
+
488
+ /* ── Footer ── */
489
+ .footer {
490
+ text-align: center;
491
+ font-size: 11px;
492
+ color: #adb5c8;
493
+ padding: 28px 20px;
494
+ }
495
+
496
+ @media (max-width: 680px) {
497
+ .page-header { padding: 20px 16px; }
498
+ .summary-bar { padding: 0 16px; }
499
+ .main { margin: 16px auto; padding: 0 10px; }
500
+ .branch-sidebar { width: 130px; min-width: 100px; padding: 10px 12px; }
501
+ .branch-content { padding: 10px 12px; }
502
+ .cost-label { font-size: 14px; }
503
+ .summary-value { font-size: 18px; }
504
+ }
505
+ </style>
506
+ </head>
507
+ <body>
508
+
509
+ <div class="page-header">
510
+ <h1>${escHtml(projectTitle)} — Branch AI Usage</h1>
511
+ <div class="subtitle">Generated ${escHtml(generatedAt)} &middot; ${totalCommits} commits across ${branchCount} branch${branchCount !== 1 ? "es" : ""}</div>
512
+ </div>
513
+
514
+ <div class="summary-bar">
515
+ <div class="summary-item">
516
+ <div class="summary-value">${escHtml(fmtCost(totalCost))}</div>
517
+ <div class="summary-key">Total cost</div>
518
+ </div>
519
+ <div class="summary-item">
520
+ <div class="summary-value">${escHtml(fmtTokens(totalTokens))}</div>
521
+ <div class="summary-key">Total tokens</div>
522
+ </div>
523
+ <div class="summary-item">
524
+ <div class="summary-value">${totalCommits}</div>
525
+ <div class="summary-key">Commits</div>
526
+ </div>
527
+ <div class="summary-item">
528
+ <div class="summary-value">${branchCount}</div>
529
+ <div class="summary-key">Branches</div>
530
+ </div>
531
+ </div>
532
+
533
+ <div class="main">
534
+ <div class="section-title">Branches by cost</div>
535
+ <div class="branch-list">
536
+ ${branchRows}
537
+ </div>
538
+ </div>
539
+
540
+ <div class="footer">agent-optic &middot; ${escHtml(records.length.toString())} records read</div>
541
+
542
+ </body>
543
+ </html>`;
544
+ }
545
+
546
+ // ── Main ──────────────────────────────────────────────────────────────────────
547
+
548
+ const args = process.argv.slice(2);
549
+ const jsonlPath = resolve(args[0] ?? ".ai-usage.jsonl");
550
+
551
+ // Derive project title from path: parent directory name
552
+ const projectTitle = basename(jsonlPath) === ".ai-usage.jsonl"
553
+ ? basename(resolve(jsonlPath, ".."))
554
+ : basename(jsonlPath).replace(/\.jsonl?$/, "");
555
+
556
+ const records = await loadRecords(jsonlPath);
557
+
558
+ if (records.length === 0) {
559
+ process.stderr.write(`No records found in ${jsonlPath}\n`);
560
+ process.exit(0);
561
+ }
562
+
563
+ const branches = aggregateByBranch(records);
564
+ const html = buildHtml(branches, projectTitle, records);
565
+
566
+ process.stdout.write(html + "\n");