agent-optic 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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +337 -0
  3. package/examples/commit-tracker.ts +389 -0
  4. package/examples/cost-per-feature.ts +182 -0
  5. package/examples/match-git-commits.ts +171 -0
  6. package/examples/model-costs.ts +131 -0
  7. package/examples/pipe-match.ts +177 -0
  8. package/examples/prompt-history.ts +119 -0
  9. package/examples/session-digest.ts +89 -0
  10. package/examples/timesheet.ts +127 -0
  11. package/examples/work-patterns.ts +124 -0
  12. package/package.json +41 -0
  13. package/src/agent-optic.ts +325 -0
  14. package/src/aggregations/daily.ts +90 -0
  15. package/src/aggregations/project.ts +71 -0
  16. package/src/aggregations/time.ts +44 -0
  17. package/src/aggregations/tools.ts +60 -0
  18. package/src/claude-optic.ts +7 -0
  19. package/src/cli/index.ts +407 -0
  20. package/src/index.ts +69 -0
  21. package/src/parsers/content-blocks.ts +58 -0
  22. package/src/parsers/session-detail.ts +323 -0
  23. package/src/parsers/tool-categories.ts +86 -0
  24. package/src/pricing.ts +62 -0
  25. package/src/privacy/config.ts +67 -0
  26. package/src/privacy/redact.ts +99 -0
  27. package/src/readers/codex-rollout-reader.ts +145 -0
  28. package/src/readers/history-reader.ts +205 -0
  29. package/src/readers/plan-reader.ts +60 -0
  30. package/src/readers/project-reader.ts +101 -0
  31. package/src/readers/session-reader.ts +280 -0
  32. package/src/readers/skill-reader.ts +28 -0
  33. package/src/readers/stats-reader.ts +12 -0
  34. package/src/readers/task-reader.ts +117 -0
  35. package/src/types/aggregations.ts +47 -0
  36. package/src/types/plan.ts +6 -0
  37. package/src/types/privacy.ts +18 -0
  38. package/src/types/project.ts +13 -0
  39. package/src/types/provider.ts +9 -0
  40. package/src/types/session.ts +56 -0
  41. package/src/types/stats.ts +15 -0
  42. package/src/types/task.ts +16 -0
  43. package/src/types/transcript.ts +36 -0
  44. package/src/utils/dates.ts +40 -0
  45. package/src/utils/jsonl.ts +83 -0
  46. package/src/utils/paths.ts +57 -0
  47. package/src/utils/providers.ts +30 -0
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * commit-tracker.ts — Post-commit hook that tracks AI usage per commit.
4
+ *
5
+ * Usage:
6
+ * bun examples/commit-tracker.ts install — Install post-commit git hook
7
+ * bun examples/commit-tracker.ts uninstall — Remove the hook
8
+ * bun examples/commit-tracker.ts run — Called by hook after each commit
9
+ * bun examples/commit-tracker.ts init — Backfill .ai-usage.jsonl for existing commits
10
+ *
11
+ * Appends a JSONL record to .ai-usage.jsonl for each commit that matches
12
+ * a Claude session (within a configurable time window).
13
+ */
14
+
15
+ import { createClaudeHistory, estimateCost, projectName, type SessionMeta } from "../src/index.js";
16
+ import { resolve, join } from "node:path";
17
+ import { existsSync } from "node:fs";
18
+
19
+ const MARKER_START = "# claude-optic: ai-usage-tracker";
20
+ const MARKER_END = "# end claude-optic";
21
+ const TRACKING_FILE = ".ai-usage.jsonl";
22
+ const WINDOW_MINUTES = 30;
23
+
24
+ // ── Git helpers ──────────────────────────────────────────────────────
25
+
26
+ async function git(...args: string[]): Promise<string> {
27
+ const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "pipe" });
28
+ const text = await new Response(proc.stdout).text();
29
+ await proc.exited;
30
+ return text.trim();
31
+ }
32
+
33
+ async function getRepoRoot(): Promise<string> {
34
+ return git("rev-parse", "--show-toplevel");
35
+ }
36
+
37
+ // ── Install / Uninstall ──────────────────────────────────────────────
38
+
39
+ async function install() {
40
+ const repoRoot = await getRepoRoot();
41
+ const hooksDir = join(repoRoot, ".git", "hooks");
42
+ const hookPath = join(hooksDir, "post-commit");
43
+ const scriptPath = resolve(import.meta.dir, "commit-tracker.ts");
44
+
45
+ const hookBlock = [
46
+ MARKER_START,
47
+ `bun ${scriptPath} run 2>/dev/null || true`,
48
+ MARKER_END,
49
+ ].join("\n");
50
+
51
+ if (existsSync(hookPath)) {
52
+ const existing = await Bun.file(hookPath).text();
53
+
54
+ if (existing.includes(MARKER_START)) {
55
+ console.log("Hook already installed.");
56
+ return;
57
+ }
58
+
59
+ // Append to existing hook
60
+ await Bun.write(hookPath, existing.trimEnd() + "\n\n" + hookBlock + "\n");
61
+ } else {
62
+ await Bun.write(hookPath, "#!/bin/sh\n\n" + hookBlock + "\n");
63
+ }
64
+
65
+ // Ensure executable
66
+ await Bun.spawn(["chmod", "+x", hookPath]).exited;
67
+
68
+ console.log(`Installed post-commit hook → ${hookPath}`);
69
+ console.log(`Tracking file: ${join(repoRoot, TRACKING_FILE)}`);
70
+ }
71
+
72
+ async function uninstall() {
73
+ const repoRoot = await getRepoRoot();
74
+ const hookPath = join(repoRoot, ".git", "hooks", "post-commit");
75
+
76
+ if (!existsSync(hookPath)) {
77
+ console.log("No post-commit hook found.");
78
+ return;
79
+ }
80
+
81
+ const existing = await Bun.file(hookPath).text();
82
+ if (!existing.includes(MARKER_START)) {
83
+ console.log("Hook not installed by commit-tracker.");
84
+ return;
85
+ }
86
+
87
+ // Remove lines between markers (inclusive)
88
+ const lines = existing.split("\n");
89
+ const filtered: string[] = [];
90
+ let inside = false;
91
+ for (const line of lines) {
92
+ if (line.trim() === MARKER_START) { inside = true; continue; }
93
+ if (line.trim() === MARKER_END) { inside = false; continue; }
94
+ if (!inside) filtered.push(line);
95
+ }
96
+
97
+ const remaining = filtered.join("\n").trim();
98
+ if (remaining === "#!/bin/sh" || remaining === "") {
99
+ // Nothing left — remove the file
100
+ await Bun.spawn(["rm", hookPath]).exited;
101
+ console.log("Removed post-commit hook (no other hooks remained).");
102
+ } else {
103
+ await Bun.write(hookPath, remaining + "\n");
104
+ console.log("Removed commit-tracker from post-commit hook.");
105
+ }
106
+ }
107
+
108
+ // ── Run (called by hook) ─────────────────────────────────────────────
109
+
110
+ interface CommitInfo {
111
+ hash: string;
112
+ timestamp: number;
113
+ branch: string;
114
+ author: string;
115
+ message: string;
116
+ filesChanged: number;
117
+ }
118
+
119
+ async function getCommitInfo(): Promise<CommitInfo> {
120
+ const [hash, timestampStr, branch, author, message, statText] = await Promise.all([
121
+ git("rev-parse", "HEAD"),
122
+ git("log", "-1", "--format=%at"),
123
+ git("rev-parse", "--abbrev-ref", "HEAD"),
124
+ git("log", "-1", "--format=%an"),
125
+ git("log", "-1", "--format=%s"),
126
+ git("diff", "--stat", "HEAD~1..HEAD"),
127
+ ]);
128
+
129
+ const filesMatch = statText.match(/(\d+) files? changed/);
130
+ const filesChanged = filesMatch ? parseInt(filesMatch[1]) : 0;
131
+
132
+ return {
133
+ hash: hash.slice(0, 7),
134
+ timestamp: parseInt(timestampStr) * 1000,
135
+ branch,
136
+ author,
137
+ message,
138
+ filesChanged,
139
+ };
140
+ }
141
+
142
+ function isProjectMatch(session: SessionMeta, repoRoot: string, repoName: string): boolean {
143
+ const sp = session.project.toLowerCase();
144
+ const rp = repoRoot.toLowerCase();
145
+ return sp === rp || sp.startsWith(rp + "/") || session.projectName?.toLowerCase() === repoName.toLowerCase();
146
+ }
147
+
148
+ function findMatchingSessions(commitTimestamp: number, sessions: SessionMeta[]): SessionMeta[] {
149
+ const windowMs = WINDOW_MINUTES * 60 * 1000;
150
+ return sessions.filter((s) => {
151
+ return s.timeRange.start <= commitTimestamp + windowMs && s.timeRange.end >= commitTimestamp - windowMs;
152
+ });
153
+ }
154
+
155
+ async function run() {
156
+ const repoRoot = await getRepoRoot();
157
+ const repoName = projectName(repoRoot);
158
+ const commit = await getCommitInfo();
159
+
160
+ // Get today's sessions for this project
161
+ const today = new Date().toISOString().slice(0, 10);
162
+ const ch = createClaudeHistory();
163
+ const allSessions = await ch.sessions.listWithMeta({ from: today });
164
+
165
+ // Filter to matching project
166
+ const projectSessions = allSessions.filter((s) => isProjectMatch(s, repoRoot, repoName));
167
+
168
+ // Find sessions active around commit time
169
+ const matched = findMatchingSessions(commit.timestamp, projectSessions);
170
+ if (matched.length === 0) return; // No AI involvement — skip silently
171
+
172
+ // Aggregate tokens and cost
173
+ const tokens = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
174
+ let messages = 0;
175
+ const models = new Set<string>();
176
+
177
+ for (const s of matched) {
178
+ tokens.input += s.totalInputTokens;
179
+ tokens.output += s.totalOutputTokens;
180
+ tokens.cache_read += s.cacheReadInputTokens;
181
+ tokens.cache_write += s.cacheCreationInputTokens;
182
+ messages += s.messageCount;
183
+ if (s.model) models.add(s.model);
184
+ }
185
+
186
+ const costUsd = matched.reduce((sum, s) => sum + estimateCost(s), 0);
187
+
188
+ const record = {
189
+ commit: commit.hash,
190
+ timestamp: new Date(commit.timestamp).toISOString(),
191
+ branch: commit.branch,
192
+ author: commit.author,
193
+ session_ids: matched.map((s) => s.sessionId),
194
+ tokens,
195
+ cost_usd: Math.round(costUsd * 100) / 100,
196
+ models: [...models],
197
+ messages,
198
+ files_changed: commit.filesChanged,
199
+ };
200
+
201
+ // Append to tracking file
202
+ const trackingPath = join(repoRoot, TRACKING_FILE);
203
+ await Bun.write(trackingPath, (existsSync(trackingPath) ? await Bun.file(trackingPath).text() : "") + JSON.stringify(record) + "\n");
204
+ }
205
+
206
+ // ── Init (backfill) ──────────────────────────────────────────────────
207
+
208
+ async function getCommitHistory(opts: { from?: string; to?: string } = {}): Promise<CommitInfo[]> {
209
+ const since = opts.from ?? new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
210
+ const args = ["log", "--all", `--since=${since}`, "--format=%H\t%an\t%aI\t%at\t%s", "--shortstat"];
211
+ if (opts.to) args.push(`--until=${opts.to}`);
212
+
213
+ const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "pipe" });
214
+ const text = await new Response(proc.stdout).text();
215
+ await proc.exited;
216
+
217
+ const branch = await git("rev-parse", "--abbrev-ref", "HEAD");
218
+ const commits: CommitInfo[] = [];
219
+ const lines = text.trim().split("\n");
220
+
221
+ for (let i = 0; i < lines.length; i++) {
222
+ const line = lines[i].trim();
223
+ if (!line || !line.includes("\t")) continue;
224
+
225
+ const parts = line.split("\t");
226
+ if (parts.length < 5) continue;
227
+
228
+ const [hash, author, , timestamp, message] = parts;
229
+
230
+ let filesChanged = 0;
231
+ // --shortstat puts a blank line between format and stat lines
232
+ for (let j = 1; j <= 2; j++) {
233
+ const peek = lines[i + j]?.trim() ?? "";
234
+ const match = peek.match(/(\d+) files? changed/);
235
+ if (match) {
236
+ filesChanged = parseInt(match[1]);
237
+ i += j;
238
+ break;
239
+ }
240
+ }
241
+
242
+ commits.push({
243
+ hash: hash.slice(0, 7),
244
+ timestamp: parseInt(timestamp) * 1000,
245
+ branch,
246
+ author,
247
+ message,
248
+ filesChanged,
249
+ });
250
+ }
251
+
252
+ return commits;
253
+ }
254
+
255
+ async function init(opts: { from?: string; to?: string } = {}) {
256
+ const repoRoot = await getRepoRoot();
257
+ const repoName = projectName(repoRoot);
258
+ const trackingPath = join(repoRoot, TRACKING_FILE);
259
+
260
+ // Load existing records to skip duplicates
261
+ const existingHashes = new Set<string>();
262
+ if (existsSync(trackingPath)) {
263
+ const content = await Bun.file(trackingPath).text();
264
+ for (const line of content.trim().split("\n")) {
265
+ if (!line) continue;
266
+ try {
267
+ const rec = JSON.parse(line);
268
+ if (rec.commit) existingHashes.add(rec.commit);
269
+ } catch {}
270
+ }
271
+ }
272
+
273
+ const commits = await getCommitHistory(opts);
274
+ if (commits.length === 0) {
275
+ console.log("No commits found in range.");
276
+ return;
277
+ }
278
+
279
+ // Load sessions covering full commit range
280
+ const earliest = new Date(Math.min(...commits.map((c) => c.timestamp)));
281
+ const from = new Date(earliest.getTime() - 86400000).toISOString().slice(0, 10);
282
+ const ch = createClaudeHistory();
283
+ const allSessions = await ch.sessions.listWithMeta({ from });
284
+
285
+ // Filter to project
286
+ const projectSessions = allSessions.filter((s) => isProjectMatch(s, repoRoot, repoName));
287
+
288
+ // Count how many commits each session matches (for fair cost splitting)
289
+ const sessionCommitCount = new Map<string, number>();
290
+ const commitMatches = new Map<string, SessionMeta[]>();
291
+ for (const commit of commits) {
292
+ if (existingHashes.has(commit.hash)) continue;
293
+ const matched = findMatchingSessions(commit.timestamp, projectSessions);
294
+ if (matched.length === 0) continue;
295
+ commitMatches.set(commit.hash, matched);
296
+ for (const s of matched) {
297
+ sessionCommitCount.set(s.sessionId, (sessionCommitCount.get(s.sessionId) ?? 0) + 1);
298
+ }
299
+ }
300
+
301
+ const newRecords: string[] = [];
302
+ let skippedNoAI = 0;
303
+ let skippedExisting = 0;
304
+
305
+ for (const commit of commits) {
306
+ if (existingHashes.has(commit.hash)) {
307
+ skippedExisting++;
308
+ continue;
309
+ }
310
+
311
+ const matched = commitMatches.get(commit.hash);
312
+ if (!matched) {
313
+ skippedNoAI++;
314
+ continue;
315
+ }
316
+
317
+ const tokens = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
318
+ let messages = 0;
319
+ const models = new Set<string>();
320
+
321
+ for (const s of matched) {
322
+ const share = sessionCommitCount.get(s.sessionId) ?? 1;
323
+ tokens.input += Math.round(s.totalInputTokens / share);
324
+ tokens.output += Math.round(s.totalOutputTokens / share);
325
+ tokens.cache_read += Math.round(s.cacheReadInputTokens / share);
326
+ tokens.cache_write += Math.round(s.cacheCreationInputTokens / share);
327
+ messages += Math.round(s.messageCount / share);
328
+ if (s.model) models.add(s.model);
329
+ }
330
+
331
+ const costUsd = matched.reduce((sum, s) => {
332
+ const share = sessionCommitCount.get(s.sessionId) ?? 1;
333
+ return sum + estimateCost(s) / share;
334
+ }, 0);
335
+
336
+ newRecords.push(JSON.stringify({
337
+ commit: commit.hash,
338
+ timestamp: new Date(commit.timestamp).toISOString(),
339
+ branch: commit.branch,
340
+ author: commit.author,
341
+ session_ids: matched.map((s) => s.sessionId),
342
+ tokens,
343
+ cost_usd: Math.round(costUsd * 100) / 100,
344
+ models: [...models],
345
+ messages,
346
+ files_changed: commit.filesChanged,
347
+ }));
348
+ }
349
+
350
+ // Append all new records at once
351
+ if (newRecords.length > 0) {
352
+ const existing = existsSync(trackingPath) ? await Bun.file(trackingPath).text() : "";
353
+ await Bun.write(trackingPath, existing + newRecords.join("\n") + "\n");
354
+ }
355
+
356
+ console.log(`${newRecords.length} commits tracked, ${skippedNoAI} skipped (no AI), ${skippedExisting} already tracked`);
357
+ }
358
+
359
+ // ── CLI ──────────────────────────────────────────────────────────────
360
+
361
+ const argv = process.argv.slice(2);
362
+ const command = argv[0];
363
+
364
+ function getArg(name: string): string | undefined {
365
+ const idx = argv.indexOf(name);
366
+ return idx !== -1 ? argv[idx + 1] : undefined;
367
+ }
368
+
369
+ switch (command) {
370
+ case "install":
371
+ await install();
372
+ break;
373
+ case "uninstall":
374
+ await uninstall();
375
+ break;
376
+ case "run":
377
+ await run();
378
+ break;
379
+ case "init":
380
+ await init({ from: getArg("--from"), to: getArg("--to") });
381
+ break;
382
+ default:
383
+ console.log(`Usage: bun examples/commit-tracker.ts <install|uninstall|run|init>
384
+
385
+ install Install post-commit git hook in current repo
386
+ uninstall Remove the hook
387
+ run Record AI usage for the latest commit (called by hook)
388
+ init [--from] [--to] Backfill .ai-usage.jsonl for existing commits (default: last 30 days)`);
389
+ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * cost-per-feature.ts — Match Claude sessions to git branches and calculate cost per feature.
4
+ *
5
+ * Usage:
6
+ * bun examples/cost-per-feature.ts [--repo /path/to/repo] [--from YYYY-MM-DD] [--to YYYY-MM-DD]
7
+ *
8
+ * Reads git log from the specified repo (or cwd) and matches branches to sessions
9
+ * that were active on those branches. Outputs a cost breakdown per feature/branch.
10
+ */
11
+
12
+ import { createClaudeHistory, estimateCost, type SessionMeta } from "../src/index.js";
13
+
14
+ const args = process.argv.slice(2);
15
+ function getArg(name: string): string | undefined {
16
+ const idx = args.indexOf(name);
17
+ return idx !== -1 ? args[idx + 1] : undefined;
18
+ }
19
+
20
+ const repoPath = getArg("--repo") ?? process.cwd();
21
+ const from = getArg("--from") ?? new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
22
+ const to = getArg("--to");
23
+
24
+ interface BranchInfo {
25
+ branch: string;
26
+ commits: number;
27
+ lastCommit: string;
28
+ }
29
+
30
+ async function getGitBranches(): Promise<BranchInfo[]> {
31
+ const proc = Bun.spawn(
32
+ ["git", "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)\t%(committerdate:iso)", "refs/heads/"],
33
+ { cwd: repoPath, stdout: "pipe", stderr: "pipe" },
34
+ );
35
+ const text = await new Response(proc.stdout).text();
36
+ await proc.exited;
37
+
38
+ const branches: BranchInfo[] = [];
39
+ for (const line of text.trim().split("\n")) {
40
+ if (!line) continue;
41
+ const [branch, lastCommit] = line.split("\t");
42
+
43
+ const base = await getDefaultBranch();
44
+ const countProc = Bun.spawn(
45
+ ["git", "rev-list", "--count", `${base}..${branch}`],
46
+ { cwd: repoPath, stdout: "pipe", stderr: "pipe" },
47
+ );
48
+ const countText = await new Response(countProc.stdout).text();
49
+ await countProc.exited;
50
+ const commits = parseInt(countText.trim()) || 0;
51
+
52
+ branches.push({ branch, commits, lastCommit: lastCommit?.trim() ?? "" });
53
+ }
54
+ return branches;
55
+ }
56
+
57
+ async function getDefaultBranch(): Promise<string> {
58
+ const proc = Bun.spawn(
59
+ ["git", "symbolic-ref", "--short", "HEAD"],
60
+ { cwd: repoPath, stdout: "pipe", stderr: "pipe" },
61
+ );
62
+ const text = await new Response(proc.stdout).text();
63
+ await proc.exited;
64
+
65
+ const current = text.trim();
66
+ for (const candidate of ["main", "master"]) {
67
+ const check = Bun.spawn(
68
+ ["git", "rev-parse", "--verify", candidate],
69
+ { cwd: repoPath, stdout: "pipe", stderr: "pipe" },
70
+ );
71
+ await check.exited;
72
+ if (check.exitCode === 0) return candidate;
73
+ }
74
+ return current;
75
+ }
76
+
77
+ async function main() {
78
+ const ch = createClaudeHistory();
79
+ const sessions = await ch.sessions.listWithMeta({ from, to });
80
+
81
+ const byBranch = new Map<string, SessionMeta[]>();
82
+ const unmatched: SessionMeta[] = [];
83
+
84
+ for (const s of sessions) {
85
+ const branch = s.gitBranch ?? "unknown";
86
+ if (branch === "unknown") {
87
+ unmatched.push(s);
88
+ continue;
89
+ }
90
+ const list = byBranch.get(branch) ?? [];
91
+ list.push(s);
92
+ byBranch.set(branch, list);
93
+ }
94
+
95
+ let gitBranches: BranchInfo[] = [];
96
+ try {
97
+ gitBranches = await getGitBranches();
98
+ } catch {
99
+ // Not in a git repo
100
+ }
101
+ const commitMap = new Map(gitBranches.map((b) => [b.branch, b.commits]));
102
+
103
+ interface FeatureCost {
104
+ branch: string;
105
+ sessions: number;
106
+ inputTokens: number;
107
+ outputTokens: number;
108
+ cacheTokens: number;
109
+ cost: number;
110
+ commits: number;
111
+ }
112
+
113
+ const features: FeatureCost[] = [];
114
+
115
+ for (const [branch, branchSessions] of byBranch) {
116
+ const cost = branchSessions.reduce((sum, s) => sum + estimateCost(s), 0);
117
+ features.push({
118
+ branch,
119
+ sessions: branchSessions.length,
120
+ inputTokens: branchSessions.reduce((s, x) => s + x.totalInputTokens, 0),
121
+ outputTokens: branchSessions.reduce((s, x) => s + x.totalOutputTokens, 0),
122
+ cacheTokens: branchSessions.reduce((s, x) => s + x.cacheCreationInputTokens + x.cacheReadInputTokens, 0),
123
+ cost,
124
+ commits: commitMap.get(branch) ?? 0,
125
+ });
126
+ }
127
+
128
+ features.sort((a, b) => b.cost - a.cost);
129
+
130
+ const totalCost = features.reduce((s, f) => s + f.cost, 0);
131
+ const unmatchedCost = unmatched.reduce((s, x) => s + estimateCost(x), 0);
132
+
133
+ console.log("Cost per Feature / Branch");
134
+ console.log("=".repeat(90));
135
+ console.log(
136
+ "Feature/Branch".padEnd(35),
137
+ "Sessions".padStart(10),
138
+ "Tokens".padStart(12),
139
+ "Est. Cost".padStart(12),
140
+ "Commits".padStart(10),
141
+ );
142
+ console.log("-".repeat(90));
143
+
144
+ for (const f of features) {
145
+ const tokens = f.inputTokens + f.outputTokens + f.cacheTokens;
146
+ console.log(
147
+ f.branch.slice(0, 34).padEnd(35),
148
+ String(f.sessions).padStart(10),
149
+ formatTokens(tokens).padStart(12),
150
+ `$${f.cost.toFixed(2)}`.padStart(12),
151
+ String(f.commits).padStart(10),
152
+ );
153
+ }
154
+
155
+ if (unmatched.length > 0) {
156
+ const tokens = unmatched.reduce((s, x) => s + x.totalInputTokens + x.totalOutputTokens + x.cacheCreationInputTokens + x.cacheReadInputTokens, 0);
157
+ console.log(
158
+ "(no branch)".padEnd(35),
159
+ String(unmatched.length).padStart(10),
160
+ formatTokens(tokens).padStart(12),
161
+ `$${unmatchedCost.toFixed(2)}`.padStart(12),
162
+ "-".padStart(10),
163
+ );
164
+ }
165
+
166
+ console.log("-".repeat(90));
167
+ console.log(
168
+ "TOTAL".padEnd(35),
169
+ String(sessions.length).padStart(10),
170
+ "".padStart(12),
171
+ `$${(totalCost + unmatchedCost).toFixed(2)}`.padStart(12),
172
+ "".padStart(10),
173
+ );
174
+ }
175
+
176
+ function formatTokens(n: number): string {
177
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
178
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
179
+ return String(n);
180
+ }
181
+
182
+ main().catch(console.error);