claude-roi 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-roi",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Correlate Claude Code token usage with git output to measure AI coding agent ROI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -360,8 +360,8 @@ function mergeSubagentIntoSession(parent, sub) {
360
360
  parent.cost.cacheCreationCost += sub.cost.cacheCreationCost;
361
361
  parent.cost.totalCost += sub.cost.totalCost;
362
362
 
363
- parent.assistantMessageCount += sub.assistantMessageCount;
364
- parent.userMessageCount += sub.userMessageCount;
363
+ // Message counts intentionally NOT merged — we only report
364
+ // the main-conversation messages, not internal subagent chatter.
365
365
 
366
366
  // Merge model breakdown
367
367
  for (const [model, data] of Object.entries(sub.modelBreakdown)) {
@@ -457,10 +457,28 @@ export async function parseAllProjects(claudeDir, days, projectFilter) {
457
457
  }
458
458
  }
459
459
 
460
+ // Deduplicate sessions by sessionId (same session can appear in
461
+ // multiple project dirs, e.g. main repo vs worktree paths)
462
+ const seen = new Map();
463
+ for (const s of sessions) {
464
+ const id = s.sessionId;
465
+ if (!seen.has(id)) {
466
+ seen.set(id, s);
467
+ } else {
468
+ const existing = seen.get(id);
469
+ const existingMsgs = existing.userMessageCount + existing.assistantMessageCount;
470
+ const newMsgs = s.userMessageCount + s.assistantMessageCount;
471
+ if (newMsgs > existingMsgs) {
472
+ seen.set(id, s);
473
+ }
474
+ }
475
+ }
476
+ const deduped = Array.from(seen.values());
477
+
460
478
  // Sort by start time descending
461
- sessions.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
479
+ deduped.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
462
480
 
463
- return { sessions, fileIndex };
481
+ return { sessions: deduped, fileIndex };
464
482
  }
465
483
 
466
484
  export { calculateCost, getModelFamily, PRICING };
package/src/correlator.js CHANGED
@@ -1,5 +1,19 @@
1
1
  const FALLBACK_BUFFER_MS = 2 * 60 * 60 * 1000; // 2 hours for time-only fallback
2
2
 
3
+ function computeOverlappingLines(commits, sessionFiles) {
4
+ let linesAdded = 0;
5
+ let linesDeleted = 0;
6
+ for (const c of commits) {
7
+ for (const f of c.files) {
8
+ if (sessionFiles.has(f.path)) {
9
+ linesAdded += f.added;
10
+ linesDeleted += f.deleted;
11
+ }
12
+ }
13
+ }
14
+ return { linesAdded, linesDeleted };
15
+ }
16
+
3
17
  /**
4
18
  * Correlate sessions to commits using file-based matching.
5
19
  *
@@ -55,10 +69,19 @@ export function correlateSessions(sessions, commitsByRepo) {
55
69
  claimedCommits.add(c.hash);
56
70
  }
57
71
 
58
- const linesAdded = matched.reduce((s, c) => s + c.totalAdded, 0);
59
- const linesDeleted = matched.reduce((s, c) => s + c.totalDeleted, 0);
72
+ let linesAdded, linesDeleted;
73
+ if (sessionFiles.size > 0) {
74
+ // Only count lines from files Claude actually edited
75
+ ({ linesAdded, linesDeleted } = computeOverlappingLines(matched, sessionFiles));
76
+ } else {
77
+ // Fallback (chat-only): count all lines in matched commits
78
+ linesAdded = matched.reduce((s, c) => s + c.totalAdded, 0);
79
+ linesDeleted = matched.reduce((s, c) => s + c.totalDeleted, 0);
80
+ }
60
81
  const netLines = linesAdded - linesDeleted;
61
- const filesChanged = new Set(matched.flatMap(c => c.files.map(f => f.path))).size;
82
+ const filesChanged = sessionFiles.size > 0
83
+ ? new Set(matched.flatMap(c => c.files.filter(f => sessionFiles.has(f.path)).map(f => f.path))).size
84
+ : new Set(matched.flatMap(c => c.files.map(f => f.path))).size;
62
85
  const commitsOnMain = matched.filter(c => c.onMain).length;
63
86
 
64
87
  const messageCount = session.userMessageCount + session.assistantMessageCount;
package/src/metrics.js CHANGED
@@ -406,6 +406,9 @@ export function computeMetrics(correlatedSessions, organicCommits, commitsByRepo
406
406
  // ---- Model breakdown ----
407
407
  const modelBreakdown = {};
408
408
  for (const session of correlatedSessions) {
409
+ const sessionTotalTokens = Object.values(session.modelBreakdown)
410
+ .reduce((s, d) => s + d.tokens, 0);
411
+
409
412
  for (const [model, data] of Object.entries(session.modelBreakdown)) {
410
413
  const family = getModelFamily(model) || 'unknown';
411
414
  if (!modelBreakdown[family]) {
@@ -413,16 +416,15 @@ export function computeMetrics(correlatedSessions, organicCommits, commitsByRepo
413
416
  }
414
417
  modelBreakdown[family].cost += data.cost;
415
418
  modelBreakdown[family].tokens += data.tokens;
419
+
420
+ // Distribute sessions and commits proportionally by token share
421
+ const share = sessionTotalTokens > 0 ? data.tokens / sessionTotalTokens : 0;
422
+ modelBreakdown[family].sessions += share;
423
+ modelBreakdown[family].commits += session.commitCount * share;
416
424
  }
417
- // Count sessions/commits per primary model
418
- const primaryFamily = getModelFamily(session.model) || 'unknown';
419
- if (!modelBreakdown[primaryFamily]) {
420
- modelBreakdown[primaryFamily] = { cost: 0, tokens: 0, sessions: 0, commits: 0, avgCostPerCommit: null };
421
- }
422
- modelBreakdown[primaryFamily].sessions++;
423
- modelBreakdown[primaryFamily].commits += session.commitCount;
424
425
  }
425
426
  for (const data of Object.values(modelBreakdown)) {
427
+ data.sessions = Math.round(data.sessions);
426
428
  data.avgCostPerCommit = data.commits > 0 ? data.cost / data.commits : null;
427
429
  data.tokensPerCommit = data.commits > 0 ? Math.round(data.tokens / data.commits) : null;
428
430
  }