claude-roi 0.1.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,462 @@
1
+ import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import path from 'node:path';
4
+
5
+ // Pricing per million tokens — from https://docs.anthropic.com/en/docs/about-claude/pricing
6
+ // Cache reads = 0.1x base input, Cache writes (5min) = 1.25x base input
7
+ const PRICING = {
8
+ // Opus 4.5 / 4.6: $5 input, $25 output
9
+ 'opus-new': { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
10
+ // Opus 4.0 / 4.1: $15 input, $75 output
11
+ 'opus-old': { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
12
+ // Sonnet 4.0 / 4.5 / 4.6: $3 input, $15 output
13
+ sonnet: { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
14
+ // Haiku 4.5: $1 input, $5 output
15
+ 'haiku-new': { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
16
+ // Haiku 3.5: $0.80 input, $4 output
17
+ 'haiku-35': { input: 0.80, output: 4, cacheRead: 0.08, cacheWrite: 1.00 },
18
+ // Haiku 3: $0.25 input, $1.25 output
19
+ 'haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.30 },
20
+ };
21
+
22
+ const PER_MIL = 1_000_000;
23
+
24
+ function getModelFamily(modelName) {
25
+ if (!modelName) return null;
26
+ const lower = modelName.toLowerCase();
27
+ if (lower.includes('opus')) return 'opus';
28
+ if (lower.includes('sonnet')) return 'sonnet';
29
+ if (lower.includes('haiku')) return 'haiku';
30
+ return null;
31
+ }
32
+
33
+ function getPricingTier(modelName) {
34
+ if (!modelName) return null;
35
+ const lower = modelName.toLowerCase();
36
+ // Opus 4.5/4.6 = new pricing, Opus 4.0/4.1 = old pricing
37
+ if (lower.includes('opus')) {
38
+ if (lower.includes('4-5') || lower.includes('4-6') || lower.includes('4.5') || lower.includes('4.6')) return 'opus-new';
39
+ return 'opus-old';
40
+ }
41
+ // All Sonnet versions (3.7, 4.0, 4.5, 4.6) share $3/$15 pricing
42
+ if (lower.includes('sonnet')) return 'sonnet';
43
+ // Haiku version detection
44
+ if (lower.includes('haiku')) {
45
+ if (lower.includes('4-5') || lower.includes('4.5') || lower.includes('4-6') || lower.includes('4.6')) return 'haiku-new';
46
+ if (lower.includes('3-5') || lower.includes('3.5')) return 'haiku-35';
47
+ return 'haiku-3'; // Haiku 3 (claude-3-haiku)
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function calculateCost(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, modelName) {
53
+ const tier = getPricingTier(modelName);
54
+ if (!tier) return 0;
55
+ const p = PRICING[tier];
56
+ return (
57
+ (inputTokens * p.input / PER_MIL) +
58
+ (outputTokens * p.output / PER_MIL) +
59
+ (cacheReadTokens * p.cacheRead / PER_MIL) +
60
+ (cacheCreationTokens * p.cacheWrite / PER_MIL)
61
+ );
62
+ }
63
+
64
+ function calculateCostBreakdown(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, modelName) {
65
+ const tier = getPricingTier(modelName);
66
+ if (!tier) return { inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheCreationCost: 0, totalCost: 0 };
67
+ const p = PRICING[tier];
68
+ const inputCost = inputTokens * p.input / PER_MIL;
69
+ const outputCost = outputTokens * p.output / PER_MIL;
70
+ const cacheReadCost = cacheReadTokens * p.cacheRead / PER_MIL;
71
+ const cacheCreationCost = cacheCreationTokens * p.cacheWrite / PER_MIL;
72
+ return {
73
+ inputCost,
74
+ outputCost,
75
+ cacheReadCost,
76
+ cacheCreationCost,
77
+ totalCost: inputCost + outputCost + cacheReadCost + cacheCreationCost,
78
+ };
79
+ }
80
+
81
+ function toRelativePath(absolutePath, repoPath) {
82
+ if (!absolutePath) return null;
83
+ // Handle worktree paths: .claude/worktrees/<name>/src/file.js → src/file.js
84
+ const wtMatch = absolutePath.match(/\.claude\/worktrees\/[^/]+\/(.+)/);
85
+ if (wtMatch) return wtMatch[1];
86
+ // Normal: strip repo root prefix
87
+ if (repoPath && absolutePath.startsWith(repoPath)) {
88
+ let rel = absolutePath.slice(repoPath.length);
89
+ if (rel.startsWith('/')) rel = rel.slice(1);
90
+ return rel;
91
+ }
92
+ // Fallback: return just the filename
93
+ return absolutePath.split('/').pop();
94
+ }
95
+
96
+ function extractToolUse(session, msg) {
97
+ const content = msg.content;
98
+ if (!Array.isArray(content)) return;
99
+
100
+ for (const block of content) {
101
+ if (block.type !== 'tool_use') continue;
102
+ const toolName = block.name;
103
+ if (!toolName) continue;
104
+
105
+ // Count tool calls
106
+ session.toolCalls[toolName] = (session.toolCalls[toolName] || 0) + 1;
107
+
108
+ // Track files written/read
109
+ const filePath = block.input?.file_path;
110
+ if (!filePath) continue;
111
+
112
+ if (toolName === 'Write' || toolName === 'Edit') {
113
+ if (!session.filesWritten.includes(filePath)) {
114
+ session.filesWritten.push(filePath);
115
+ }
116
+ } else if (toolName === 'Read') {
117
+ if (!session.filesRead.includes(filePath)) {
118
+ session.filesRead.push(filePath);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ function createEmptySession(sessionId) {
125
+ return {
126
+ sessionId,
127
+ repoPath: null,
128
+ projectName: null,
129
+ gitBranch: null,
130
+ startTime: null,
131
+ endTime: null,
132
+ durationMinutes: 0,
133
+ totalInputTokens: 0,
134
+ totalOutputTokens: 0,
135
+ cacheCreationTokens: 0,
136
+ cacheReadTokens: 0,
137
+ cost: { inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheCreationCost: 0, totalCost: 0 },
138
+ model: null,
139
+ modelBreakdown: {},
140
+ toolCalls: {},
141
+ filesWritten: [],
142
+ filesRead: [],
143
+ userMessageCount: 0,
144
+ assistantMessageCount: 0,
145
+ };
146
+ }
147
+
148
+ async function parseSessionFile(filePath) {
149
+ const sessionId = path.basename(filePath, '.jsonl');
150
+ const session = createEmptySession(sessionId);
151
+ const seenRequestIds = new Set();
152
+ const modelTokens = {}; // model -> { input, output, cacheRead, cacheCreate }
153
+
154
+ const rl = createInterface({
155
+ input: createReadStream(filePath),
156
+ crlfDelay: Infinity,
157
+ });
158
+
159
+ for await (const line of rl) {
160
+ if (!line.trim()) continue;
161
+
162
+ let obj;
163
+ try {
164
+ obj = JSON.parse(line);
165
+ } catch {
166
+ continue; // skip malformed lines
167
+ }
168
+
169
+ if (obj.type === 'user' && obj.message) {
170
+ // Extract repo path from cwd (most reliable source)
171
+ if (!session.repoPath && obj.cwd) {
172
+ session.repoPath = obj.cwd;
173
+ }
174
+ if (!session.gitBranch && obj.gitBranch) {
175
+ session.gitBranch = obj.gitBranch;
176
+ }
177
+ if (obj.sessionId) {
178
+ session.sessionId = obj.sessionId;
179
+ }
180
+
181
+ // Track timestamps
182
+ if (obj.timestamp) {
183
+ if (!session.startTime || obj.timestamp < session.startTime) {
184
+ session.startTime = obj.timestamp;
185
+ }
186
+ if (!session.endTime || obj.timestamp > session.endTime) {
187
+ session.endTime = obj.timestamp;
188
+ }
189
+ }
190
+
191
+ // Count user messages (only actual user content, not tool results)
192
+ const content = obj.message.content;
193
+ if (Array.isArray(content)) {
194
+ const hasUserText = content.some(b => b.type === 'text');
195
+ if (hasUserText) session.userMessageCount++;
196
+ } else if (typeof content === 'string') {
197
+ session.userMessageCount++;
198
+ }
199
+
200
+ continue;
201
+ }
202
+
203
+ if (obj.type !== 'assistant' || !obj.message) continue;
204
+
205
+ const msg = obj.message;
206
+
207
+ // Skip synthetic/error messages
208
+ if (msg.model === '<synthetic>') continue;
209
+
210
+ // Track timestamps
211
+ if (obj.timestamp) {
212
+ if (!session.startTime || obj.timestamp < session.startTime) {
213
+ session.startTime = obj.timestamp;
214
+ }
215
+ if (!session.endTime || obj.timestamp > session.endTime) {
216
+ session.endTime = obj.timestamp;
217
+ }
218
+ }
219
+
220
+ // Deduplicate by requestId to avoid double-counting tokens
221
+ const requestId = obj.requestId;
222
+ const isNewRequest = requestId && !seenRequestIds.has(requestId);
223
+ if (requestId) seenRequestIds.add(requestId);
224
+
225
+ // Accumulate usage only for new requests
226
+ if (isNewRequest || !requestId) {
227
+ const usage = msg.usage;
228
+ if (usage) {
229
+ const input = usage.input_tokens || 0;
230
+ const output = usage.output_tokens || 0;
231
+ const cacheRead = usage.cache_read_input_tokens || 0;
232
+ const cacheCreate = usage.cache_creation_input_tokens || 0;
233
+ const model = msg.model || 'unknown';
234
+
235
+ session.totalInputTokens += input;
236
+ session.totalOutputTokens += output;
237
+ session.cacheReadTokens += cacheRead;
238
+ session.cacheCreationTokens += cacheCreate;
239
+
240
+ // Track per-model breakdown
241
+ if (!modelTokens[model]) {
242
+ modelTokens[model] = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
243
+ }
244
+ modelTokens[model].input += input;
245
+ modelTokens[model].output += output;
246
+ modelTokens[model].cacheRead += cacheRead;
247
+ modelTokens[model].cacheCreate += cacheCreate;
248
+ }
249
+
250
+ session.assistantMessageCount++;
251
+ }
252
+
253
+ // Always extract tool use info (different content blocks can appear in split messages)
254
+ extractToolUse(session, msg);
255
+ }
256
+
257
+ // Compute costs from model breakdown
258
+ let maxTokens = 0;
259
+ let primaryModel = null;
260
+
261
+ for (const [model, tokens] of Object.entries(modelTokens)) {
262
+ const cost = calculateCost(tokens.input, tokens.output, tokens.cacheRead, tokens.cacheCreate, model);
263
+ const totalTokens = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheCreate;
264
+ session.modelBreakdown[model] = { tokens: totalTokens, cost };
265
+
266
+ if (totalTokens > maxTokens) {
267
+ maxTokens = totalTokens;
268
+ primaryModel = model;
269
+ }
270
+ }
271
+ session.model = primaryModel;
272
+
273
+ // Calculate total cost breakdown
274
+ session.cost = calculateCostBreakdown(
275
+ session.totalInputTokens,
276
+ session.totalOutputTokens,
277
+ session.cacheReadTokens,
278
+ session.cacheCreationTokens,
279
+ primaryModel
280
+ );
281
+
282
+ // If multiple models used, recalculate cost from per-model breakdown for accuracy
283
+ if (Object.keys(modelTokens).length > 1) {
284
+ let totalCost = 0;
285
+ let inputCost = 0;
286
+ let outputCost = 0;
287
+ let cacheReadCost = 0;
288
+ let cacheCreationCost = 0;
289
+
290
+ for (const [model, tokens] of Object.entries(modelTokens)) {
291
+ const breakdown = calculateCostBreakdown(tokens.input, tokens.output, tokens.cacheRead, tokens.cacheCreate, model);
292
+ inputCost += breakdown.inputCost;
293
+ outputCost += breakdown.outputCost;
294
+ cacheReadCost += breakdown.cacheReadCost;
295
+ cacheCreationCost += breakdown.cacheCreationCost;
296
+ totalCost += breakdown.totalCost;
297
+ }
298
+
299
+ session.cost = { inputCost, outputCost, cacheReadCost, cacheCreationCost, totalCost };
300
+ }
301
+
302
+ // Calculate duration
303
+ if (session.startTime && session.endTime) {
304
+ const start = new Date(session.startTime).getTime();
305
+ const end = new Date(session.endTime).getTime();
306
+ session.durationMinutes = Math.round((end - start) / 60000 * 10) / 10;
307
+ }
308
+
309
+ // Normalize filesWritten to relative paths (for file-based commit correlation)
310
+ // Resolve the actual git root from repoPath (which may be a worktree path)
311
+ let gitRoot = session.repoPath;
312
+ if (gitRoot) {
313
+ const wtRootMatch = gitRoot.match(/^(.+?)\/\.claude\/worktrees\/[^/]+$/);
314
+ if (wtRootMatch) gitRoot = wtRootMatch[1];
315
+ }
316
+ if (gitRoot) {
317
+ session.filesWritten = session.filesWritten
318
+ .map(fp => toRelativePath(fp, gitRoot))
319
+ .filter(Boolean);
320
+ session.filesRead = session.filesRead
321
+ .map(fp => toRelativePath(fp, gitRoot))
322
+ .filter(Boolean);
323
+ // Also normalize repoPath to the actual git root
324
+ session.repoPath = gitRoot;
325
+ }
326
+
327
+ return session;
328
+ }
329
+
330
+ async function parseSessionWithSubagents(projectDir, sessionId) {
331
+ const mainFile = path.join(projectDir, `${sessionId}.jsonl`);
332
+ const session = await parseSessionFile(mainFile);
333
+
334
+ // Check for subagent directory
335
+ const subagentDir = path.join(projectDir, sessionId, 'subagents');
336
+ if (existsSync(subagentDir)) {
337
+ try {
338
+ const agentFiles = readdirSync(subagentDir).filter(f => f.endsWith('.jsonl'));
339
+ for (const af of agentFiles) {
340
+ const subSession = await parseSessionFile(path.join(subagentDir, af));
341
+ mergeSubagentIntoSession(session, subSession);
342
+ }
343
+ } catch {
344
+ // Skip if subagent directory is unreadable
345
+ }
346
+ }
347
+
348
+ return session;
349
+ }
350
+
351
+ function mergeSubagentIntoSession(parent, sub) {
352
+ parent.totalInputTokens += sub.totalInputTokens;
353
+ parent.totalOutputTokens += sub.totalOutputTokens;
354
+ parent.cacheCreationTokens += sub.cacheCreationTokens;
355
+ parent.cacheReadTokens += sub.cacheReadTokens;
356
+
357
+ parent.cost.inputCost += sub.cost.inputCost;
358
+ parent.cost.outputCost += sub.cost.outputCost;
359
+ parent.cost.cacheReadCost += sub.cost.cacheReadCost;
360
+ parent.cost.cacheCreationCost += sub.cost.cacheCreationCost;
361
+ parent.cost.totalCost += sub.cost.totalCost;
362
+
363
+ parent.assistantMessageCount += sub.assistantMessageCount;
364
+ parent.userMessageCount += sub.userMessageCount;
365
+
366
+ // Merge model breakdown
367
+ for (const [model, data] of Object.entries(sub.modelBreakdown)) {
368
+ if (!parent.modelBreakdown[model]) {
369
+ parent.modelBreakdown[model] = { tokens: 0, cost: 0 };
370
+ }
371
+ parent.modelBreakdown[model].tokens += data.tokens;
372
+ parent.modelBreakdown[model].cost += data.cost;
373
+ }
374
+
375
+ // Merge tool calls
376
+ for (const [tool, count] of Object.entries(sub.toolCalls)) {
377
+ parent.toolCalls[tool] = (parent.toolCalls[tool] || 0) + count;
378
+ }
379
+
380
+ // Merge files
381
+ for (const f of sub.filesWritten) {
382
+ if (!parent.filesWritten.includes(f)) parent.filesWritten.push(f);
383
+ }
384
+ for (const f of sub.filesRead) {
385
+ if (!parent.filesRead.includes(f)) parent.filesRead.push(f);
386
+ }
387
+ }
388
+
389
+ export async function parseAllProjects(claudeDir, days, projectFilter) {
390
+ if (!existsSync(claudeDir)) {
391
+ return [];
392
+ }
393
+
394
+ const cutoffDate = new Date();
395
+ cutoffDate.setDate(cutoffDate.getDate() - days);
396
+ const cutoffMs = cutoffDate.getTime();
397
+
398
+ const sessions = [];
399
+ const fileIndex = {};
400
+ const projectFolders = readdirSync(claudeDir).filter(f => {
401
+ if (f.startsWith('.')) return false;
402
+ const fullPath = path.join(claudeDir, f);
403
+ return statSync(fullPath).isDirectory();
404
+ });
405
+
406
+ for (const folder of projectFolders) {
407
+ // Apply project filter if specified
408
+ if (projectFilter) {
409
+ const folderLower = folder.toLowerCase();
410
+ if (!folderLower.includes(projectFilter.toLowerCase())) continue;
411
+ }
412
+
413
+ const projectDir = path.join(claudeDir, folder);
414
+ const projectName = folder.split('-').pop() || folder;
415
+
416
+ let files;
417
+ try {
418
+ files = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
419
+ } catch {
420
+ continue;
421
+ }
422
+
423
+ for (const file of files) {
424
+ const filePath = path.join(projectDir, file);
425
+
426
+ // Quick filter by mtime
427
+ try {
428
+ const stat = statSync(filePath);
429
+ if (stat.mtimeMs < cutoffMs) continue;
430
+ fileIndex[filePath] = stat.mtimeMs;
431
+ } catch {
432
+ continue;
433
+ }
434
+
435
+ const sessionId = path.basename(file, '.jsonl');
436
+
437
+ try {
438
+ const session = await parseSessionWithSubagents(projectDir, sessionId);
439
+
440
+ // Skip empty sessions (no messages)
441
+ if (!session.startTime || (session.userMessageCount === 0 && session.assistantMessageCount === 0)) {
442
+ continue;
443
+ }
444
+
445
+ // Apply date filter on session start time
446
+ if (new Date(session.startTime).getTime() < cutoffMs) continue;
447
+
448
+ session.projectName = projectName;
449
+ sessions.push(session);
450
+ } catch (err) {
451
+ process.stderr.write(`Warning: Failed to parse ${filePath}: ${err.message}\n`);
452
+ }
453
+ }
454
+ }
455
+
456
+ // Sort by start time descending
457
+ sessions.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
458
+
459
+ return { sessions, fileIndex };
460
+ }
461
+
462
+ export { calculateCost, getModelFamily, PRICING };
@@ -0,0 +1,103 @@
1
+ const FALLBACK_BUFFER_MS = 2 * 60 * 60 * 1000; // 2 hours for time-only fallback
2
+
3
+ /**
4
+ * Correlate sessions to commits using file-based matching.
5
+ *
6
+ * Primary: match commits whose changed files overlap with session.filesWritten.
7
+ * Time constraint: commit is on the same calendar day or the next day.
8
+ * Fallback: for sessions with no filesWritten (chat-only), use time window
9
+ * [sessionStart, sessionEnd + 2 hours].
10
+ */
11
+ export function correlateSessions(sessions, commitsByRepo) {
12
+ const result = [];
13
+ const claimedCommits = new Set();
14
+
15
+ // Sort sessions by end time so earlier sessions claim commits first
16
+ const sorted = [...sessions].sort(
17
+ (a, b) => new Date(a.endTime).getTime() - new Date(b.endTime).getTime()
18
+ );
19
+
20
+ for (const session of sorted) {
21
+ const repoAnalysis = commitsByRepo[session.repoPath];
22
+ const repoCommits = repoAnalysis?.commits || [];
23
+
24
+ const sessionStart = new Date(session.startTime).getTime();
25
+ const sessionEnd = new Date(session.endTime).getTime();
26
+ const sessionFiles = new Set(session.filesWritten || []);
27
+
28
+ let matched;
29
+
30
+ if (sessionFiles.size > 0) {
31
+ // PRIMARY: File-based correlation
32
+ // Time window: same calendar day as session, or next calendar day
33
+ const sessionDay = new Date(session.startTime);
34
+ const dayStart = new Date(sessionDay.getFullYear(), sessionDay.getMonth(), sessionDay.getDate()).getTime();
35
+ const dayEnd = dayStart + 2 * 24 * 60 * 60 * 1000; // end of next calendar day
36
+
37
+ matched = repoCommits.filter(c => {
38
+ if (claimedCommits.has(c.hash)) return false;
39
+ if (c.timestampMs < dayStart || c.timestampMs >= dayEnd) return false;
40
+ // Check file overlap: any commit file matches a session file
41
+ return c.files.some(f => sessionFiles.has(f.path));
42
+ });
43
+ } else {
44
+ // FALLBACK: Time-based for chat-only sessions (no files written)
45
+ const windowEnd = sessionEnd + FALLBACK_BUFFER_MS;
46
+ matched = repoCommits.filter(c =>
47
+ c.timestampMs >= sessionStart &&
48
+ c.timestampMs <= windowEnd &&
49
+ !claimedCommits.has(c.hash)
50
+ );
51
+ }
52
+
53
+ // Claim matched commits
54
+ for (const c of matched) {
55
+ claimedCommits.add(c.hash);
56
+ }
57
+
58
+ const linesAdded = matched.reduce((s, c) => s + c.totalAdded, 0);
59
+ const linesDeleted = matched.reduce((s, c) => s + c.totalDeleted, 0);
60
+ const netLines = linesAdded - linesDeleted;
61
+ const filesChanged = new Set(matched.flatMap(c => c.files.map(f => f.path))).size;
62
+ const commitsOnMain = matched.filter(c => c.onMain).length;
63
+
64
+ const messageCount = session.userMessageCount + session.assistantMessageCount;
65
+ const isOrphaned = messageCount > 10 && matched.length === 0;
66
+
67
+ // Calculate which session files were committed vs not
68
+ const committedFiles = new Set(matched.flatMap(c => c.files.map(f => f.path)));
69
+ const uncommittedFiles = [...sessionFiles].filter(f => !committedFiles.has(f));
70
+
71
+ result.push({
72
+ ...session,
73
+ commits: matched,
74
+ commitCount: matched.length,
75
+ commitsOnMain,
76
+ linesAdded,
77
+ linesDeleted,
78
+ netLines,
79
+ filesChanged,
80
+ isOrphaned,
81
+ matchedByFiles: sessionFiles.size > 0,
82
+ uncommittedFiles,
83
+ costPerCommit: matched.length > 0 ? session.cost.totalCost / matched.length : null,
84
+ costPerLine: linesAdded > 0 ? session.cost.totalCost / linesAdded : null,
85
+ costPerNetLine: netLines > 0 ? session.cost.totalCost / netLines : null,
86
+ });
87
+ }
88
+
89
+ // Identify organic commits (not claimed by any session)
90
+ const organicCommits = [];
91
+ for (const [, analysis] of Object.entries(commitsByRepo)) {
92
+ for (const commit of analysis.commits) {
93
+ if (!claimedCommits.has(commit.hash)) {
94
+ organicCommits.push({ ...commit });
95
+ }
96
+ }
97
+ }
98
+
99
+ // Re-sort result by start time descending (most recent first for display)
100
+ result.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
101
+
102
+ return { correlatedSessions: result, organicCommits };
103
+ }