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.
- package/LICENSE +21 -0
- package/README.md +337 -0
- package/examples/commit-tracker.ts +389 -0
- package/examples/cost-per-feature.ts +182 -0
- package/examples/match-git-commits.ts +171 -0
- package/examples/model-costs.ts +131 -0
- package/examples/pipe-match.ts +177 -0
- package/examples/prompt-history.ts +119 -0
- package/examples/session-digest.ts +89 -0
- package/examples/timesheet.ts +127 -0
- package/examples/work-patterns.ts +124 -0
- package/package.json +41 -0
- package/src/agent-optic.ts +325 -0
- package/src/aggregations/daily.ts +90 -0
- package/src/aggregations/project.ts +71 -0
- package/src/aggregations/time.ts +44 -0
- package/src/aggregations/tools.ts +60 -0
- package/src/claude-optic.ts +7 -0
- package/src/cli/index.ts +407 -0
- package/src/index.ts +69 -0
- package/src/parsers/content-blocks.ts +58 -0
- package/src/parsers/session-detail.ts +323 -0
- package/src/parsers/tool-categories.ts +86 -0
- package/src/pricing.ts +62 -0
- package/src/privacy/config.ts +67 -0
- package/src/privacy/redact.ts +99 -0
- package/src/readers/codex-rollout-reader.ts +145 -0
- package/src/readers/history-reader.ts +205 -0
- package/src/readers/plan-reader.ts +60 -0
- package/src/readers/project-reader.ts +101 -0
- package/src/readers/session-reader.ts +280 -0
- package/src/readers/skill-reader.ts +28 -0
- package/src/readers/stats-reader.ts +12 -0
- package/src/readers/task-reader.ts +117 -0
- package/src/types/aggregations.ts +47 -0
- package/src/types/plan.ts +6 -0
- package/src/types/privacy.ts +18 -0
- package/src/types/project.ts +13 -0
- package/src/types/provider.ts +9 -0
- package/src/types/session.ts +56 -0
- package/src/types/stats.ts +15 -0
- package/src/types/task.ts +16 -0
- package/src/types/transcript.ts +36 -0
- package/src/utils/dates.ts +40 -0
- package/src/utils/jsonl.ts +83 -0
- package/src/utils/paths.ts +57 -0
- 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);
|