cchubber 0.5.4 → 0.5.6

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,13 +1,15 @@
1
1
  {
2
2
  "name": "cchubber",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "What you spent. Why you spent it. Is that normal. — Claude Code usage diagnosis with beautiful HTML reports.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cchubber": "./src/cli/index.js"
8
8
  },
9
9
  "scripts": {
10
- "start": "node src/cli/index.js"
10
+ "start": "node src/cli/index.js",
11
+ "test": "node test/smoke.js",
12
+ "release": "bash release.sh"
11
13
  },
12
14
  "keywords": [
13
15
  "claude",
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Value Tracker — measures whether you're getting less for your money over time.
3
+ *
4
+ * Two core metrics:
5
+ * - Output per message: are Claude's responses getting shorter? (model degradation)
6
+ * - Output per dollar: are you getting less work per dollar? (pricing/caching changes)
7
+ *
8
+ * Uses z-score anomaly detection (2 SD) to flag days where value drops significantly.
9
+ */
10
+
11
+ export function analyzeValueTrend(dailyFromJSONL) {
12
+ if (!dailyFromJSONL || dailyFromJSONL.length < 3) {
13
+ return { available: false };
14
+ }
15
+
16
+ // Calculate per-day metrics
17
+ const daily = dailyFromJSONL
18
+ .filter(d => d.outputTokens > 0 && d.messageCount > 0)
19
+ .map(d => ({
20
+ date: d.date,
21
+ outputPerMsg: Math.round(d.outputTokens / d.messageCount),
22
+ outputPerDollar: d.cost > 0 ? Math.round(d.outputTokens / d.cost) : 0,
23
+ outputTokens: d.outputTokens,
24
+ messageCount: d.messageCount,
25
+ cost: d.cost || 0,
26
+ }));
27
+
28
+ if (daily.length < 3) return { available: false };
29
+
30
+ // Overall averages
31
+ const avgOutputPerMsg = Math.round(daily.reduce((s, d) => s + d.outputPerMsg, 0) / daily.length);
32
+ const avgOutputPerDollar = daily.filter(d => d.outputPerDollar > 0).length > 0
33
+ ? Math.round(daily.filter(d => d.outputPerDollar > 0).reduce((s, d) => s + d.outputPerDollar, 0) / daily.filter(d => d.outputPerDollar > 0).length)
34
+ : 0;
35
+
36
+ // Recent 7 days vs older — is value declining?
37
+ const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date));
38
+ const recent = sorted.slice(-7);
39
+ const older = sorted.slice(0, -7);
40
+
41
+ let trend = 'stable';
42
+ let trendDetail = '';
43
+
44
+ if (older.length >= 3) {
45
+ const recentAvgPerMsg = Math.round(recent.reduce((s, d) => s + d.outputPerMsg, 0) / recent.length);
46
+ const olderAvgPerMsg = Math.round(older.reduce((s, d) => s + d.outputPerMsg, 0) / older.length);
47
+
48
+ const recentAvgPerDollar = recent.filter(d => d.outputPerDollar > 0).length > 0
49
+ ? Math.round(recent.filter(d => d.outputPerDollar > 0).reduce((s, d) => s + d.outputPerDollar, 0) / recent.filter(d => d.outputPerDollar > 0).length)
50
+ : 0;
51
+ const olderAvgPerDollar = older.filter(d => d.outputPerDollar > 0).length > 0
52
+ ? Math.round(older.filter(d => d.outputPerDollar > 0).reduce((s, d) => s + d.outputPerDollar, 0) / older.filter(d => d.outputPerDollar > 0).length)
53
+ : 0;
54
+
55
+ const msgChange = olderAvgPerMsg > 0 ? ((recentAvgPerMsg - olderAvgPerMsg) / olderAvgPerMsg * 100) : 0;
56
+ const dollarChange = olderAvgPerDollar > 0 ? ((recentAvgPerDollar - olderAvgPerDollar) / olderAvgPerDollar * 100) : 0;
57
+
58
+ if (msgChange < -20) {
59
+ trend = 'declining';
60
+ trendDetail = `Output per message dropped ${Math.abs(Math.round(msgChange))}% recently (${olderAvgPerMsg} → ${recentAvgPerMsg} tokens/msg)`;
61
+ } else if (msgChange > 20) {
62
+ trend = 'improving';
63
+ trendDetail = `Output per message increased ${Math.round(msgChange)}% recently (${olderAvgPerMsg} → ${recentAvgPerMsg} tokens/msg)`;
64
+ } else {
65
+ trendDetail = `Output per message stable (${recentAvgPerMsg} tokens/msg, was ${olderAvgPerMsg})`;
66
+ }
67
+ }
68
+
69
+ // Z-score anomaly detection — flag days where value drops >2 SD
70
+ const values = daily.map(d => d.outputPerMsg);
71
+ const mean = values.reduce((s, v) => s + v, 0) / values.length;
72
+ const variance = values.reduce((s, v) => s + Math.pow(v - mean, 2), 0) / values.length;
73
+ const stdDev = Math.sqrt(variance);
74
+
75
+ const anomalies = [];
76
+ if (stdDev > 0) {
77
+ for (const d of daily) {
78
+ const z = (d.outputPerMsg - mean) / stdDev;
79
+ if (z < -2) {
80
+ anomalies.push({
81
+ date: d.date,
82
+ type: 'low_output',
83
+ outputPerMsg: d.outputPerMsg,
84
+ expected: avgOutputPerMsg,
85
+ deviation: Math.abs(Math.round(z * 10) / 10),
86
+ detail: `${d.outputPerMsg} tokens/msg vs ${avgOutputPerMsg} avg (${Math.abs(Math.round(z * 10) / 10)}σ below)`,
87
+ });
88
+ }
89
+ }
90
+ }
91
+
92
+ return {
93
+ available: true,
94
+ daily: daily.map(d => ({ date: d.date, outputPerMsg: d.outputPerMsg, outputPerDollar: d.outputPerDollar })),
95
+ avgOutputPerMsg,
96
+ avgOutputPerDollar,
97
+ trend,
98
+ trendDetail,
99
+ anomalies,
100
+ dayCount: daily.length,
101
+ };
102
+ }
package/src/cli/index.js CHANGED
@@ -25,6 +25,7 @@ import { generateRecommendations } from '../analyzers/recommendations.js';
25
25
  import { detectInflectionPoints } from '../analyzers/inflection-detector.js';
26
26
  import { analyzeSessionIntelligence } from '../analyzers/session-intelligence.js';
27
27
  import { analyzeModelRouting } from '../analyzers/model-routing.js';
28
+ import { analyzeValueTrend } from '../analyzers/value-tracker.js';
28
29
  import { renderHTML } from '../renderers/html-report.js';
29
30
  import { renderTerminal } from '../renderers/terminal-summary.js';
30
31
  import { shouldSendTelemetry, sendTelemetry } from '../telemetry.js';
@@ -134,6 +135,9 @@ async function main() {
134
135
  const inflection = detectInflectionPoints(dailyFromJSONL);
135
136
  const sessionIntel = analyzeSessionIntelligence(sessionMeta, jsonlEntries);
136
137
  const modelRouting = analyzeModelRouting(costAnalysis, jsonlEntries);
138
+ const valueTrend = analyzeValueTrend(dailyFromJSONL);
139
+ if (valueTrend.available) console.log(` ✓ Value trend: ${valueTrend.avgOutputPerMsg} tokens/msg avg (${valueTrend.trend})`);
140
+
137
141
  const recommendations = generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown);
138
142
 
139
143
  if (inflection) console.log(` ✓ Inflection: ${inflection.summary}`);
@@ -166,6 +170,7 @@ async function main() {
166
170
  claudeMdStack,
167
171
  oauthUsage,
168
172
  recommendations,
173
+ valueTrend,
169
174
  communityStats,
170
175
  history: getHistory(),
171
176
  };
@@ -372,6 +372,39 @@ ${inflection && inflection.multiplier >= 1.5 ? `
372
372
  <svg id="cost-chart-svg" viewBox="0 0 900 200" preserveAspectRatio="xMidYMid meet"></svg>
373
373
  </section>
374
374
 
375
+ ${report.valueTrend?.available ? `
376
+ <!-- VALUE TREND -->
377
+ <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
378
+ <div class="flex items-center justify-between mb-6">
379
+ <h3 class="text-xl font-bold text-[#e3e2e3]">Output Value</h3>
380
+ <span class="text-[10px] font-mono text-[#908fa0]">${report.valueTrend.trend === 'declining' ? 'DECLINING' : report.valueTrend.trend === 'improving' ? 'IMPROVING' : 'STABLE'}</span>
381
+ </div>
382
+ <div class="grid grid-cols-2 gap-6 mb-6">
383
+ <div class="bg-[#0d0e0f] p-5 rounded-lg">
384
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-2">Tokens per Message</span>
385
+ <span class="font-mono text-2xl font-bold text-[#e3e2e3]">${report.valueTrend.avgOutputPerMsg.toLocaleString()}</span>
386
+ <span class="text-[10px] text-[#908fa0] block mt-1">avg output tokens per turn</span>
387
+ </div>
388
+ <div class="bg-[#0d0e0f] p-5 rounded-lg">
389
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-2">Tokens per Dollar</span>
390
+ <span class="font-mono text-2xl font-bold text-[#e3e2e3]">${report.valueTrend.avgOutputPerDollar.toLocaleString()}</span>
391
+ <span class="text-[10px] text-[#908fa0] block mt-1">output tokens per $1 spent</span>
392
+ </div>
393
+ </div>
394
+ ${report.valueTrend.trendDetail ? `<p class="text-[12px] text-[#908fa0] mb-4">${esc(report.valueTrend.trendDetail)}</p>` : ''}
395
+ ${report.valueTrend.anomalies?.length > 0 ? `
396
+ <div class="space-y-2">
397
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block">Low Output Days (2+ SD below average)</span>
398
+ ${report.valueTrend.anomalies.slice(0, 5).map(a => `
399
+ <div class="p-3 bg-[#0d0e0f] rounded-lg flex items-center justify-between">
400
+ <span class="text-[12px] font-mono text-[#908fa0]">${a.date}</span>
401
+ <span class="text-[12px] text-[#e3e2e3]">${a.outputPerMsg} tokens/msg</span>
402
+ <span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:rgba(255,180,171,0.15);color:#ffb4ab">${a.deviation}σ below</span>
403
+ </div>`).join('')}
404
+ </div>` : ''}
405
+ </section>
406
+ ` : ''}
407
+
375
408
  <!-- 5. SESSION INTELLIGENCE + MODEL DISTRIBUTION -->
376
409
  <section class="grid grid-cols-1 lg:grid-cols-2 gap-8">
377
410
 
@@ -1064,6 +1097,7 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
1064
1097
  // Community leaderboard — stats fetched at generation time, embedded as JSON
1065
1098
  var MY_RATIO = ${cacheHealth.efficiencyRatio || 0};
1066
1099
  var MY_GRADE = '${cacheHealth.grade?.letter || '?'}';
1100
+ var MY_COST = '${(() => { const c = totalCost; return c<10?'<10':c<50?'10-50':c<200?'50-200':c<500?'200-500':c<1000?'500-1K':c<5000?'1K-5K':'5K+'; })()}';
1067
1101
  var gradeColors = {A:'#10b981',B:'#22d3ee',C:'#f59e0b',D:'#f97316',F:'#ef4444'};
1068
1102
  var stats = ${JSON.stringify(report.communityStats || null)};
1069
1103
 
@@ -1125,20 +1159,24 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
1125
1159
  });
1126
1160
 
1127
1161
  // Leaderboard table — re-graded, with user's position injected
1162
+ // Filter: minimum activity to qualify (exclude <$10 and $10-50 users — too little data for meaningful grade)
1163
+ var validCosts = {'50-200':1,'200-500':1,'500-1K':1,'1K-5K':1,'5K+':1};
1128
1164
  var tbody = document.getElementById('leaderboard-body');
1129
- var sorted = recent.filter(function(r){return r.ratio}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
1165
+ var sorted = recent.filter(function(r){return r.ratio && (validCosts[r.cost] || r.cost===MY_COST)}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
1130
1166
 
1131
- // Find where the user would rank
1167
+ // Insert user's own entry into the sorted list at the right position
1168
+ var myEntry = {ratio:MY_RATIO, grade:MY_GRADE, cost:MY_COST, opus:${report.modelRouting?.opusPct ?? 'null'}, country:'You', os:'${process.platform}', isMe:true};
1132
1169
  var myRank = sorted.findIndex(function(e){return (e.ratio||0) >= MY_RATIO});
1133
1170
  if(myRank < 0) myRank = sorted.length;
1171
+ sorted.splice(myRank, 0, myEntry);
1134
1172
 
1135
1173
  // Show top 10 + user's position (if not in top 10)
1136
1174
  var showIndices = [];
1137
1175
  for(var si=0;si<Math.min(10,sorted.length);si++) showIndices.push(si);
1138
1176
  var userInTop = myRank < 10;
1139
- if(!userInTop && myRank < sorted.length){
1177
+ if(!userInTop){
1140
1178
  showIndices.push(-1); // separator
1141
- if(myRank > 0) showIndices.push(myRank-1);
1179
+ if(myRank > 1) showIndices.push(myRank-1);
1142
1180
  showIndices.push(myRank);
1143
1181
  if(myRank+1 < sorted.length) showIndices.push(myRank+1);
1144
1182
  }
@@ -1147,28 +1185,28 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
1147
1185
  for(var si2=0;si2<showIndices.length;si2++){
1148
1186
  var idx = showIndices[si2];
1149
1187
  if(idx === -1){
1150
- html += '<tr><td colspan="6" class="px-8 py-1 text-center text-[10px] text-[#908fa0]">···</td></tr>';
1188
+ html += '<tr><td colspan="6" class="px-8 py-1 text-center text-[10px] text-[#908fa0]">...</td></tr>';
1151
1189
  continue;
1152
1190
  }
1153
1191
  var entry = sorted[idx];
1154
1192
  if(!entry) continue;
1155
- var g = getGrade(entry);
1156
- var isMe = idx === myRank;
1193
+ var g = entry.isMe ? MY_GRADE : getGrade(entry);
1194
+ var isMe = entry.isMe;
1157
1195
  var rowStyle = isMe ? 'background:rgba(192,193,255,0.08);border-left:3px solid #c0c1ff;' : '';
1158
1196
  html += '<tr style="border-bottom:1px solid rgba(70,69,84,0.1);'+rowStyle+'">';
1159
1197
  html += '<td class="px-8 py-3 text-sm font-mono '+(isMe?'text-[#c0c1ff] font-bold':'text-[#908fa0]')+'">#'+(idx+1)+(isMe?' ← you':'')+'</td>';
1160
1198
  html += '<td class="px-4 py-3 text-sm font-bold text-center" style="color:'+gradeColors[g]+'">'+g+'</td>';
1161
1199
  html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.ratio||'?')+':1</td>';
1162
1200
  html += '<td class="px-4 py-3 text-sm font-mono text-[#908fa0]">'+(entry.cost||'?')+'</td>';
1163
- html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus||'?')+'%</td>';
1164
- html += '<td class="px-8 py-3 text-sm text-[#908fa0]">'+(entry.country||'?')+'</td>';
1201
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus!=null?entry.opus:'?')+'%</td>';
1202
+ html += '<td class="px-8 py-3 text-sm text-[#908fa0]">'+(isMe?'You':(entry.country||'?'))+'</td>';
1165
1203
  html += '</tr>';
1166
1204
  }
1167
1205
  tbody.innerHTML = html;
1168
1206
 
1169
1207
  // Update percentile text with rank
1170
1208
  document.getElementById('community-percentile').innerHTML =
1171
- "You are <strong style=\\"color:#c0c1ff\\">#"+(myRank+1)+" of "+sorted.length+"</strong> users by cache efficiency. Better than <strong style=\\"color:#c0c1ff\\">"+pctile+"%</strong>";
1209
+ "You are <strong style=\\"color:#c0c1ff\\">#"+(myRank+1)+" of "+(sorted.length)+"</strong> users by cache efficiency. Better than <strong style=\\"color:#c0c1ff\\">"+pctile+"%</strong>";
1172
1210
  })(stats);
1173
1211
  })();
1174
1212
  </script>