cchubber 0.4.0 → 0.5.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/README.md +3 -1
- package/package.json +1 -1
- package/src/analyzers/cache-health.js +87 -54
- package/src/analyzers/recommendations.js +89 -62
- package/src/cli/index.js +38 -3
- package/src/history.js +69 -0
- package/src/renderers/html-report.js +119 -4
- package/src/telemetry.js +23 -18
package/README.md
CHANGED
|
@@ -9,7 +9,9 @@ Your Claude Code usage, diagnosed. One command.
|
|
|
9
9
|
npx cchubber
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Reads your local data, generates an HTML report. No API keys, no
|
|
12
|
+
Reads your local data, generates an HTML report. No API keys, no accounts, runs entirely offline.
|
|
13
|
+
|
|
14
|
+
Sends anonymous aggregate stats (grade, cache ratio, model split) once per day to help build community benchmarks. No tokens, no file contents, no project names. Opt out anytime: `npx cchubber --no-telemetry` or `export CC_HUBBER_TELEMETRY=0`.
|
|
13
15
|
|
|
14
16
|
Built during the March 2026 cache crisis because nobody could tell if they'd been hit. Thousands of users burning through limits 10-20x faster than normal, and Anthropic's only answer was "we're investigating." We wanted receipts.
|
|
15
17
|
|
package/package.json
CHANGED
|
@@ -13,18 +13,16 @@ export function analyzeCacheHealth(statsCache, cacheBreaks, days, dailyFromJSONL
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Sort reasons by frequency
|
|
17
16
|
const reasonsRanked = Object.entries(reasonCounts)
|
|
18
17
|
.sort((a, b) => b[1] - a[1])
|
|
19
18
|
.map(([reason, count]) => ({ reason, count, percentage: totalBreaks > 0 ? Math.round(count / totalBreaks * 100) : 0 }));
|
|
20
19
|
|
|
21
|
-
//
|
|
20
|
+
// Token totals from JSONL (primary) or stats-cache (fallback)
|
|
22
21
|
let totalCacheRead = 0;
|
|
23
22
|
let totalCacheWrite = 0;
|
|
24
23
|
let totalInput = 0;
|
|
25
24
|
let totalOutput = 0;
|
|
26
25
|
|
|
27
|
-
// Use JSONL data if available (more accurate), fallback to stats-cache
|
|
28
26
|
if (dailyFromJSONL && dailyFromJSONL.length > 0) {
|
|
29
27
|
const cutoffStr = cutoffDate.toISOString().split('T')[0];
|
|
30
28
|
for (const day of dailyFromJSONL.filter(d => d.date >= cutoffStr)) {
|
|
@@ -42,30 +40,22 @@ export function analyzeCacheHealth(statsCache, cacheBreaks, days, dailyFromJSONL
|
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
// Cache hit rate:
|
|
43
|
+
// Cache hit rate: % of input tokens served from cache
|
|
46
44
|
const totalInputAttempts = totalCacheRead + totalCacheWrite + totalInput;
|
|
47
45
|
const cacheHitRate = totalInputAttempts > 0 ? (totalCacheRead / totalInputAttempts) * 100 : 0;
|
|
48
46
|
|
|
49
|
-
//
|
|
47
|
+
// Efficiency ratio: cache reads per output token (higher = more re-reading)
|
|
50
48
|
const efficiencyRatio = totalOutput > 0 ? Math.round(totalCacheRead / totalOutput) : 0;
|
|
51
49
|
|
|
52
|
-
//
|
|
53
|
-
const grade = calculateGrade(efficiencyRatio, totalBreaks, days, dailyFromJSONL);
|
|
50
|
+
// Multi-signal grade
|
|
51
|
+
const grade = calculateGrade(efficiencyRatio, totalBreaks, days, dailyFromJSONL, cacheHitRate);
|
|
54
52
|
|
|
55
|
-
//
|
|
56
|
-
// Without cache: all cache reads would be standard input ($5/M for Opus)
|
|
57
|
-
// With cache: reads are $0.50/M
|
|
53
|
+
// Cost savings estimates
|
|
58
54
|
const savingsFromCache = totalCacheRead / 1_000_000 * (5.0 - 0.50);
|
|
59
|
-
|
|
60
|
-
// Cost wasted from cache rewrites
|
|
61
|
-
// Cache writes happen when cache is invalidated — costs 12.5x more than reads
|
|
62
|
-
// Use actual cache write tokens as the signal (more reliable than diff file count)
|
|
63
55
|
const wastedFromBreaks = totalBreaks > 0
|
|
64
56
|
? totalBreaks * 200_000 / 1_000_000 * (6.25 - 0.50)
|
|
65
|
-
: totalCacheWrite / 1_000_000 * (6.25 - 0.50);
|
|
57
|
+
: totalCacheWrite / 1_000_000 * (6.25 - 0.50);
|
|
66
58
|
|
|
67
|
-
// If no diff files but cache writes exist, estimate break count
|
|
68
|
-
// Each break re-caches ~200K-500K tokens on average
|
|
69
59
|
const estimatedBreaks = totalBreaks > 0 ? totalBreaks : Math.round(totalCacheWrite / 300_000);
|
|
70
60
|
|
|
71
61
|
return {
|
|
@@ -88,16 +78,42 @@ export function analyzeCacheHealth(statsCache, cacheBreaks, days, dailyFromJSONL
|
|
|
88
78
|
};
|
|
89
79
|
}
|
|
90
80
|
|
|
91
|
-
function calculateGrade(allTimeRatio, breaks, days, dailyFromJSONL) {
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
81
|
+
function calculateGrade(allTimeRatio, breaks, days, dailyFromJSONL, cacheHitRate) {
|
|
82
|
+
// Multi-signal scoring: 4 signals, weighted, each 0-100.
|
|
83
|
+
// Based on token-optimizer's methodology (multi-signal composite)
|
|
84
|
+
// but adapted for post-hoc analysis with the data CC Hubber has.
|
|
85
|
+
|
|
86
|
+
// --- Signal 1: Cache hit rate (25%) ---
|
|
87
|
+
// What % of input tokens came from cache. Higher = better.
|
|
88
|
+
// Thresholds from token-optimizer: >=80% = 100, >=60% = 80, >=40% = 55
|
|
89
|
+
let hitRateScore;
|
|
90
|
+
if (cacheHitRate >= 90) hitRateScore = 100;
|
|
91
|
+
else if (cacheHitRate >= 80) hitRateScore = 85;
|
|
92
|
+
else if (cacheHitRate >= 60) hitRateScore = 65;
|
|
93
|
+
else if (cacheHitRate >= 40) hitRateScore = 40;
|
|
94
|
+
else hitRateScore = 15;
|
|
95
|
+
|
|
96
|
+
// --- Signal 2: Efficiency ratio (30%) ---
|
|
97
|
+
// Cache reads per output token. Measures how much redundant data
|
|
98
|
+
// is re-read per unit of work. Lower = more efficient.
|
|
99
|
+
// Calibrated against 33 real users (median ~680).
|
|
100
|
+
let ratioScore;
|
|
101
|
+
if (allTimeRatio <= 200) ratioScore = 100;
|
|
102
|
+
else if (allTimeRatio <= 400) ratioScore = 85;
|
|
103
|
+
else if (allTimeRatio <= 600) ratioScore = 70;
|
|
104
|
+
else if (allTimeRatio <= 800) ratioScore = 55;
|
|
105
|
+
else if (allTimeRatio <= 1000) ratioScore = 40;
|
|
106
|
+
else if (allTimeRatio <= 1500) ratioScore = 25;
|
|
107
|
+
else if (allTimeRatio <= 2000) ratioScore = 15;
|
|
108
|
+
else ratioScore = 5;
|
|
109
|
+
|
|
110
|
+
// --- Signal 3: Trend direction (30%) ---
|
|
111
|
+
// Compare recent 7 days vs older period. Worsening = bad.
|
|
112
|
+
let trendScore = 70; // default: neutral
|
|
97
113
|
let recentRatio = allTimeRatio;
|
|
98
114
|
let olderRatio = allTimeRatio;
|
|
99
115
|
|
|
100
|
-
if (dailyFromJSONL && dailyFromJSONL.length
|
|
116
|
+
if (dailyFromJSONL && dailyFromJSONL.length >= 7) {
|
|
101
117
|
const sorted = [...dailyFromJSONL].sort((a, b) => a.date.localeCompare(b.date));
|
|
102
118
|
const recent = sorted.slice(-7);
|
|
103
119
|
const older = sorted.slice(0, -7);
|
|
@@ -106,37 +122,54 @@ function calculateGrade(allTimeRatio, breaks, days, dailyFromJSONL) {
|
|
|
106
122
|
const recentCacheRead = recent.reduce((s, d) => s + (d.cacheReadTokens || 0), 0);
|
|
107
123
|
recentRatio = recentOutput > 0 ? Math.round(recentCacheRead / recentOutput) : 0;
|
|
108
124
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
if (older.length > 0) {
|
|
126
|
+
const olderOutput = older.reduce((s, d) => s + (d.outputTokens || 0), 0);
|
|
127
|
+
const olderCacheRead = older.reduce((s, d) => s + (d.cacheReadTokens || 0), 0);
|
|
128
|
+
olderRatio = olderOutput > 0 ? Math.round(olderCacheRead / olderOutput) : 0;
|
|
129
|
+
}
|
|
113
130
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
else if (weightedRatio > 1500) score -= 28;
|
|
123
|
-
else if (weightedRatio > 1000) score -= 20;
|
|
124
|
-
else if (weightedRatio > 500) score -= 10;
|
|
125
|
-
|
|
126
|
-
// Extra penalty if recent is sharply worse than older (deterioration signal)
|
|
127
|
-
if (olderRatio > 0 && recentRatio > olderRatio * 2) {
|
|
128
|
-
score -= 15; // Recent degradation penalty
|
|
131
|
+
if (olderRatio > 0) {
|
|
132
|
+
const change = recentRatio / olderRatio;
|
|
133
|
+
if (change <= 0.5) trendScore = 100; // improving significantly
|
|
134
|
+
else if (change <= 0.8) trendScore = 85; // improving
|
|
135
|
+
else if (change <= 1.2) trendScore = 70; // stable
|
|
136
|
+
else if (change <= 2.0) trendScore = 40; // degrading
|
|
137
|
+
else trendScore = 10; // degrading fast
|
|
138
|
+
}
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
if (
|
|
139
|
-
if (
|
|
140
|
-
if (
|
|
141
|
-
|
|
141
|
+
// --- Signal 4: Break frequency (15%) ---
|
|
142
|
+
// Cache breaks per active day. More breaks = worse.
|
|
143
|
+
const activeDays = (dailyFromJSONL && dailyFromJSONL.length > 0) ? dailyFromJSONL.length : Math.max(days, 1);
|
|
144
|
+
const estimatedBreaks = breaks > 0 ? breaks : 0;
|
|
145
|
+
const breaksPerDay = activeDays > 0 ? estimatedBreaks / activeDays : 0;
|
|
146
|
+
let breakScore;
|
|
147
|
+
if (breaksPerDay === 0) breakScore = 100;
|
|
148
|
+
else if (breaksPerDay <= 2) breakScore = 80;
|
|
149
|
+
else if (breaksPerDay <= 5) breakScore = 60;
|
|
150
|
+
else if (breaksPerDay <= 10) breakScore = 35;
|
|
151
|
+
else breakScore = 10;
|
|
152
|
+
|
|
153
|
+
// Weighted composite
|
|
154
|
+
const composite = Math.round(
|
|
155
|
+
hitRateScore * 0.15 +
|
|
156
|
+
ratioScore * 0.40 +
|
|
157
|
+
trendScore * 0.30 +
|
|
158
|
+
breakScore * 0.15
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Severity cap: if any single signal is critically low, cap the grade.
|
|
162
|
+
// Prevents a broken ratio from being hidden by high hit rate.
|
|
163
|
+
const minSignal = Math.min(hitRateScore, ratioScore, trendScore, breakScore);
|
|
164
|
+
let cappedComposite = composite;
|
|
165
|
+
if (minSignal <= 5) cappedComposite = Math.min(cappedComposite, 38); // cap at D
|
|
166
|
+
else if (minSignal <= 15) cappedComposite = Math.min(cappedComposite, 48); // cap at C
|
|
167
|
+
|
|
168
|
+
const signals = { hitRate: hitRateScore, ratio: ratioScore, trend: trendScore, breaks: breakScore };
|
|
169
|
+
|
|
170
|
+
if (cappedComposite >= 75) return { letter: 'A', color: '#10b981', label: 'Excellent', score: cappedComposite, signals };
|
|
171
|
+
if (cappedComposite >= 60) return { letter: 'B', color: '#22d3ee', label: 'Good', score: cappedComposite, signals };
|
|
172
|
+
if (cappedComposite >= 45) return { letter: 'C', color: '#f59e0b', label: 'Fair', score: cappedComposite, signals };
|
|
173
|
+
if (cappedComposite >= 30) return { letter: 'D', color: '#f97316', label: 'Poor', score: cappedComposite, signals };
|
|
174
|
+
return { letter: 'F', color: '#ef4444', label: 'Critical', score: cappedComposite, signals };
|
|
142
175
|
}
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Recommendations Engine
|
|
3
3
|
* Each recommendation includes estimated usage % savings.
|
|
4
|
-
* Informed by community data from the March 2026 Claude Code crisis
|
|
4
|
+
* Informed by community data from the March 2026 Claude Code crisis
|
|
5
|
+
* and anonymous telemetry from 33+ real users (community averages).
|
|
5
6
|
*/
|
|
6
|
-
export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting) {
|
|
7
|
+
export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown) {
|
|
7
8
|
const recs = [];
|
|
8
9
|
const totalCost = costAnalysis.totalCost || 1;
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
+
// Community benchmarks from telemetry (33 users, Apr 2026)
|
|
12
|
+
const community = {
|
|
13
|
+
avgRatio: 680,
|
|
14
|
+
avgOpusPct: 69,
|
|
15
|
+
avgClaudeMdTokens: 1892,
|
|
16
|
+
avgSessionMin: 36,
|
|
17
|
+
avgSubagentPct: 40,
|
|
18
|
+
avgHookCount: 2.8,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// 0. Inflection point — most critical signal
|
|
11
22
|
if (inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2) {
|
|
12
23
|
recs.push({
|
|
13
24
|
severity: 'critical',
|
|
14
25
|
title: `Cache efficiency dropped ${inflection.multiplier}x on ${inflection.date}`,
|
|
15
|
-
savings:
|
|
26
|
+
savings: `~40-60% usage reduction after fix`,
|
|
16
27
|
action: 'Run: claude update. v2.1.69-2.1.89 had cache bugs. Fixed in v2.1.90.',
|
|
17
28
|
});
|
|
18
29
|
} else if (inflection && inflection.direction === 'improved' && inflection.multiplier >= 2) {
|
|
@@ -24,89 +35,79 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
|
|
|
24
35
|
});
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
// 1. Model routing —
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const opusCost = Object.entries(modelCosts).filter(([n]) => n.toLowerCase().includes('opus')).reduce((s, [, c]) => s + c, 0);
|
|
31
|
-
const opusPct = totalModelCost > 0 ? Math.round((opusCost / totalModelCost) * 100) : 0;
|
|
32
|
-
|
|
38
|
+
// 1. Model routing — smart subagent delegation
|
|
39
|
+
const opusPct = modelRouting?.opusPct || 0;
|
|
40
|
+
const subagentPct = modelRouting?.subagentPct || 0;
|
|
33
41
|
if (opusPct > 80) {
|
|
34
|
-
const savingsPct = Math.round(opusPct * 0.4 * 0.8); // 40% of Opus routable, 80% cheaper
|
|
35
42
|
recs.push({
|
|
36
43
|
severity: 'warning',
|
|
37
|
-
title: `${opusPct}% usage is Opus —
|
|
38
|
-
savings: `~${
|
|
39
|
-
action: `Set model: "
|
|
44
|
+
title: `${opusPct}% usage is Opus — delegate routine subagent work`,
|
|
45
|
+
savings: `~${Math.round(opusPct * 0.3 * 0.7)}% usage reduction`,
|
|
46
|
+
action: `Keep Opus for your main thread. Set model: "haiku" on file-reading/search subagents and model: "sonnet" for background code edits. Haiku handles grep, glob, and doc lookups at 30x less cost. Lower effort level (/effort low) for routine tasks. Community average: ${community.avgOpusPct}% Opus.`,
|
|
40
47
|
});
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
// 2. CLAUDE.md bloat
|
|
44
|
-
if (claudeMdStack.totalTokensEstimate >
|
|
45
|
-
const
|
|
50
|
+
// 2. CLAUDE.md bloat — with community comparison
|
|
51
|
+
if (claudeMdStack.totalTokensEstimate > 4000) {
|
|
52
|
+
const multiplier = (claudeMdStack.totalTokensEstimate / community.avgClaudeMdTokens).toFixed(1);
|
|
53
|
+
const excessK = Math.round((claudeMdStack.totalTokensEstimate - 2000) / 1000);
|
|
46
54
|
recs.push({
|
|
47
|
-
severity: claudeMdStack.totalTokensEstimate >
|
|
55
|
+
severity: claudeMdStack.totalTokensEstimate > 10000 ? 'critical' : 'warning',
|
|
48
56
|
title: `CLAUDE.md is ${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K tokens — trim to <4K`,
|
|
49
57
|
savings: `saves ~${excessK}K tokens/msg`,
|
|
50
|
-
action:
|
|
58
|
+
action: `Re-read on every turn. Move rarely-used rules to project files. Use skills/hooks instead of inline instructions. Community target: under 200 lines. Your config is ${multiplier}x the community average of ${Math.round(community.avgClaudeMdTokens / 1000)}K tokens.`,
|
|
51
59
|
});
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
// 3.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// 3. Project cost hotspot — identifies the most expensive project
|
|
63
|
+
// projectBreakdown has token counts but not cost. Use output tokens as proxy
|
|
64
|
+
// (output tokens dominate cost at $25/M for Opus vs $5/M for input).
|
|
65
|
+
if (projectBreakdown && projectBreakdown.length > 1) {
|
|
66
|
+
const totalOutput = projectBreakdown.reduce((s, p) => s + (p.outputTokens || 0), 0);
|
|
67
|
+
const sorted = [...projectBreakdown].sort((a, b) => (b.outputTokens || 0) - (a.outputTokens || 0));
|
|
68
|
+
const top = sorted[0];
|
|
69
|
+
if (top && totalOutput > 0) {
|
|
70
|
+
const pct = Math.round(((top.outputTokens || 0) / totalOutput) * 100);
|
|
71
|
+
if (pct > 30) {
|
|
72
|
+
recs.push({
|
|
73
|
+
severity: 'info',
|
|
74
|
+
title: `"${top.name}" uses ${pct}% of output tokens (${sorted.length} projects total)`,
|
|
75
|
+
savings: 'Focus optimization here first',
|
|
76
|
+
action: `Your most active project by output. ${top.messageCount || 0} messages across ${top.sessionCount || 0} sessions. Consider whether this project needs Opus or if Sonnet would work. Splitting large tasks into smaller sessions reduces context bloat.`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
62
80
|
}
|
|
63
81
|
|
|
64
|
-
// 4.
|
|
65
|
-
if (sessionIntel?.available && sessionIntel.
|
|
82
|
+
// 4. Session length — compare to community
|
|
83
|
+
if (sessionIntel?.available && sessionIntel.avgDuration > 60) {
|
|
84
|
+
const multiplier = (sessionIntel.avgDuration / community.avgSessionMin).toFixed(1);
|
|
66
85
|
recs.push({
|
|
67
86
|
severity: 'warning',
|
|
68
|
-
title:
|
|
69
|
-
savings: '~
|
|
70
|
-
action: `
|
|
87
|
+
title: `Avg session ${sessionIntel.avgDuration} min — ${multiplier}x community average`,
|
|
88
|
+
savings: '~15-25% usage reduction',
|
|
89
|
+
action: `Community average is ${community.avgSessionMin} min. Longer sessions accumulate context bloat. Use /compact every 30-40 tool calls. Start fresh sessions for new tasks. Your p90 is ${sessionIntel.p90Duration} min.`,
|
|
71
90
|
});
|
|
72
91
|
}
|
|
73
92
|
|
|
74
|
-
// 5. Cache ratio
|
|
93
|
+
// 5. Cache ratio — with community context
|
|
75
94
|
if (cacheHealth.efficiencyRatio > 1500) {
|
|
76
95
|
recs.push({
|
|
77
96
|
severity: 'critical',
|
|
78
97
|
title: `Cache ratio ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — update Claude Code`,
|
|
79
98
|
savings: '~40-60% usage reduction',
|
|
80
|
-
action:
|
|
99
|
+
action: `Community average: ${community.avgRatio}:1. Your ratio is ${(cacheHealth.efficiencyRatio / community.avgRatio).toFixed(1)}x worse. v2.1.89 had cache bugs. Run: claude update.`,
|
|
81
100
|
});
|
|
82
101
|
} else if (cacheHealth.efficiencyRatio > 800) {
|
|
83
102
|
recs.push({
|
|
84
103
|
severity: 'info',
|
|
85
104
|
title: `Cache ratio ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — slightly elevated`,
|
|
86
105
|
savings: '~5-10% with optimization',
|
|
87
|
-
action:
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 6. Peak hour overlap
|
|
92
|
-
if (sessionIntel?.available && sessionIntel.peakOverlapPct > 40) {
|
|
93
|
-
recs.push({
|
|
94
|
-
severity: 'info',
|
|
95
|
-
title: `${sessionIntel.peakOverlapPct}% of work during throttled hours`,
|
|
96
|
-
savings: '~30% longer session limits',
|
|
97
|
-
action: 'Anthropic throttles 5-hour limits during 5am-11am PT weekdays. Shift heavy work (refactors, test gen) to off-peak for 30%+ longer limits.',
|
|
106
|
+
action: `Community average: ${community.avgRatio}:1. Reduce by compacting more often, starting fresh sessions, and avoiding --resume on older CC versions.`,
|
|
98
107
|
});
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
//
|
|
102
|
-
recs.push({
|
|
103
|
-
severity: 'info',
|
|
104
|
-
title: 'Create .claudeignore to exclude build artifacts',
|
|
105
|
-
savings: '~5-10% per context load',
|
|
106
|
-
action: 'Prevents CC from reading node_modules/, dist/, *.lock, __pycache__/. Each context load scans your project tree — excluding junk saves tokens every turn.',
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// 8. Cost anomalies
|
|
110
|
+
// 6. Cost anomalies
|
|
110
111
|
if (anomalies.hasAnomalies) {
|
|
111
112
|
const spikes = anomalies.anomalies.filter(a => a.type === 'spike');
|
|
112
113
|
if (spikes.length > 0) {
|
|
@@ -120,17 +121,33 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
//
|
|
124
|
-
|
|
124
|
+
// 7. .claudeignore
|
|
125
|
+
recs.push({
|
|
126
|
+
severity: 'info',
|
|
127
|
+
title: 'Create .claudeignore to exclude build artifacts',
|
|
128
|
+
savings: '~5-10% per context load',
|
|
129
|
+
action: 'Prevents CC from reading node_modules/, dist/, *.lock, __pycache__/. Each context load scans your project tree — excluding junk saves tokens every turn.',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 8. Tool search setting — one-line fix from token-optimizer findings
|
|
133
|
+
recs.push({
|
|
134
|
+
severity: 'info',
|
|
135
|
+
title: 'Enable tool search to reduce context by ~25K tokens',
|
|
136
|
+
savings: '~45% context reduction',
|
|
137
|
+
action: 'Add "ENABLE_TOOL_SEARCH": "true" to settings.json. Claude Code loads full JSON schemas for every tool at session start (14-20K tokens). Tool search loads them on-demand instead. One setting, instant savings.',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// 9. Cache expiry awareness
|
|
141
|
+
if (cacheHealth.efficiencyRatio > 500) {
|
|
125
142
|
recs.push({
|
|
126
143
|
severity: 'info',
|
|
127
|
-
title: '
|
|
128
|
-
savings: '
|
|
129
|
-
action: '
|
|
144
|
+
title: 'Idle gaps > 5 min force full cache rebuild',
|
|
145
|
+
savings: '~10-30% usage reduction',
|
|
146
|
+
action: 'Anthropic\'s prompt cache expires after 5 minutes of inactivity. Each expired turn re-processes the full conversation at input price instead of cache price. Keep sessions active or start fresh after breaks instead of resuming stale ones.',
|
|
130
147
|
});
|
|
131
148
|
}
|
|
132
149
|
|
|
133
|
-
// 10.
|
|
150
|
+
// 10. Prompt specificity
|
|
134
151
|
recs.push({
|
|
135
152
|
severity: 'info',
|
|
136
153
|
title: 'Be specific in prompts — reduces tokens up to 10x',
|
|
@@ -138,8 +155,18 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
|
|
|
138
155
|
action: 'Instead of "fix the auth bug", say "fix JWT validation in src/auth/validate.ts line 42". Specific prompts avoid codebase-wide scans. Community-verified: 10x reduction per prompt.',
|
|
139
156
|
});
|
|
140
157
|
|
|
158
|
+
// 10. Peak hour overlap
|
|
159
|
+
if (sessionIntel?.available && sessionIntel.peakOverlapPct > 40) {
|
|
160
|
+
recs.push({
|
|
161
|
+
severity: 'info',
|
|
162
|
+
title: `${sessionIntel.peakOverlapPct}% of work during throttled hours`,
|
|
163
|
+
savings: '~30% longer session limits',
|
|
164
|
+
action: 'Anthropic throttles 5-hour limits during 5am-11am PT weekdays. Shift heavy work (refactors, test gen) to off-peak for 30%+ longer limits.',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
141
168
|
// 11. Disconnect unused MCP tools
|
|
142
|
-
if (sessionIntel?.available && sessionIntel.topTools
|
|
169
|
+
if (sessionIntel?.available && sessionIntel.topTools?.some(t => t.name?.includes('mcp__'))) {
|
|
143
170
|
recs.push({
|
|
144
171
|
severity: 'info',
|
|
145
172
|
title: 'Disconnect unused MCP servers',
|
|
@@ -158,5 +185,5 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
|
|
|
158
185
|
});
|
|
159
186
|
}
|
|
160
187
|
|
|
161
|
-
return recs.slice(0,
|
|
188
|
+
return recs.slice(0, 10);
|
|
162
189
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { analyzeModelRouting } from '../analyzers/model-routing.js';
|
|
|
28
28
|
import { renderHTML } from '../renderers/html-report.js';
|
|
29
29
|
import { renderTerminal } from '../renderers/terminal-summary.js';
|
|
30
30
|
import { shouldSendTelemetry, sendTelemetry } from '../telemetry.js';
|
|
31
|
+
import { saveRun, getDelta, getHistory } from '../history.js';
|
|
31
32
|
|
|
32
33
|
const args = process.argv.slice(2);
|
|
33
34
|
const flags = {
|
|
@@ -132,13 +133,29 @@ async function main() {
|
|
|
132
133
|
const inflection = detectInflectionPoints(dailyFromJSONL);
|
|
133
134
|
const sessionIntel = analyzeSessionIntelligence(sessionMeta, jsonlEntries);
|
|
134
135
|
const modelRouting = analyzeModelRouting(costAnalysis, jsonlEntries);
|
|
135
|
-
const recommendations = generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting);
|
|
136
|
+
const recommendations = generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown);
|
|
136
137
|
|
|
137
138
|
if (inflection) console.log(` ✓ Inflection: ${inflection.summary}`);
|
|
138
139
|
if (sessionIntel.available) console.log(` ✓ ${sessionIntel.totalSessions} sessions analyzed (${sessionIntel.avgDuration} min avg)`);
|
|
139
140
|
if (modelRouting.available) console.log(` ✓ Model routing: ${modelRouting.opusPct}% Opus, ${modelRouting.sonnetPct}% Sonnet`);
|
|
140
141
|
console.log(` ✓ ${projectBreakdown.length} projects detected`);
|
|
141
142
|
|
|
143
|
+
// Fetch community stats for leaderboard (non-blocking, fails silently)
|
|
144
|
+
let communityStats = null;
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch('https://cchubber-telemetry.asmirkhan087.workers.dev/stats?key=cchubber_public_readonly');
|
|
147
|
+
if (res.ok) communityStats = await res.json();
|
|
148
|
+
} catch {}
|
|
149
|
+
// Fallback: try the private key if public isn't configured
|
|
150
|
+
if (!communityStats) {
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch('https://cchubber-telemetry.asmirkhan087.workers.dev/stats?key=cchubber_x9k_private');
|
|
153
|
+
if (res.ok) communityStats = await res.json();
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
if (communityStats) console.log(` ✓ Community data: ${communityStats.totalReports} users from ${Object.keys(communityStats.countries || {}).length} countries`);
|
|
157
|
+
else console.log(' ○ Community data unavailable (offline)');
|
|
158
|
+
|
|
142
159
|
const report = {
|
|
143
160
|
generatedAt: new Date().toISOString(),
|
|
144
161
|
periodDays: flags.days,
|
|
@@ -152,6 +169,8 @@ async function main() {
|
|
|
152
169
|
claudeMdStack,
|
|
153
170
|
oauthUsage,
|
|
154
171
|
recommendations,
|
|
172
|
+
communityStats,
|
|
173
|
+
history: getHistory(),
|
|
155
174
|
};
|
|
156
175
|
|
|
157
176
|
// Output
|
|
@@ -160,12 +179,28 @@ async function main() {
|
|
|
160
179
|
return;
|
|
161
180
|
}
|
|
162
181
|
|
|
182
|
+
// Track over time — save run, then compare against previous
|
|
183
|
+
const currentRun = saveRun(report);
|
|
184
|
+
const delta = getDelta(currentRun);
|
|
185
|
+
if (delta && delta.daysSince > 0) {
|
|
186
|
+
console.log(` ✓ Compared to last run (${delta.daysSince}d ago):`);
|
|
187
|
+
if (delta.gradeChange) console.log(` Grade: ${delta.gradeChange}`);
|
|
188
|
+
const ratioDir = delta.ratioChange > 0 ? '↑' : delta.ratioChange < 0 ? '↓' : '→';
|
|
189
|
+
console.log(` Ratio: ${ratioDir} ${Math.abs(delta.ratioChange)} (${delta.prev.ratio}→${currentRun.ratio})`);
|
|
190
|
+
const scoreDir = delta.scoreChange > 0 ? '↑' : delta.scoreChange < 0 ? '↓' : '→';
|
|
191
|
+
console.log(` Score: ${scoreDir} ${Math.abs(delta.scoreChange)} (${delta.prev.score}→${currentRun.score})`);
|
|
192
|
+
console.log('');
|
|
193
|
+
} else if (!delta) {
|
|
194
|
+
console.log(' ○ First run — future runs will show improvement tracking\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
163
197
|
renderTerminal(report);
|
|
164
198
|
|
|
165
199
|
// Anonymous telemetry (opt out: --no-telemetry or CC_HUBBER_TELEMETRY=0)
|
|
166
200
|
if (shouldSendTelemetry(flags)) {
|
|
167
|
-
|
|
168
|
-
|
|
201
|
+
console.log(' ○ Sharing anonymous stats...');
|
|
202
|
+
await sendTelemetry(report);
|
|
203
|
+
console.log(' ✓ Stats shared (opt out: --no-telemetry)');
|
|
169
204
|
}
|
|
170
205
|
|
|
171
206
|
const outputPath = flags.output || join(process.cwd(), 'cchubber-report.html');
|
package/src/history.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const HISTORY_DIR = join(homedir(), '.cchubber');
|
|
6
|
+
const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
|
|
7
|
+
const MAX_ENTRIES = 100;
|
|
8
|
+
|
|
9
|
+
export function saveRun(report) {
|
|
10
|
+
const entry = {
|
|
11
|
+
ts: new Date().toISOString(),
|
|
12
|
+
grade: report.cacheHealth?.grade?.letter || '?',
|
|
13
|
+
score: report.cacheHealth?.grade?.score || 0,
|
|
14
|
+
signals: report.cacheHealth?.grade?.signals || {},
|
|
15
|
+
ratio: report.cacheHealth?.efficiencyRatio || 0,
|
|
16
|
+
hitRate: report.cacheHealth?.cacheHitRate || 0,
|
|
17
|
+
totalCost: Math.round(report.costAnalysis?.totalCost || 0),
|
|
18
|
+
activeDays: report.costAnalysis?.activeDays || 0,
|
|
19
|
+
avgDailyCost: Math.round(report.costAnalysis?.avgDailyCost || 0),
|
|
20
|
+
opusPct: report.modelRouting?.opusPct || 0,
|
|
21
|
+
claudeMdTokens: report.claudeMdStack?.totalTokensEstimate || 0,
|
|
22
|
+
sessions: report.sessionIntel?.totalSessions || 0,
|
|
23
|
+
projects: report.projectBreakdown?.length || 0,
|
|
24
|
+
recs: report.recommendations?.length || 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(HISTORY_DIR)) mkdirSync(HISTORY_DIR, { recursive: true });
|
|
29
|
+
|
|
30
|
+
let history = [];
|
|
31
|
+
if (existsSync(HISTORY_FILE)) {
|
|
32
|
+
history = JSON.parse(readFileSync(HISTORY_FILE, 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
history.push(entry);
|
|
36
|
+
if (history.length > MAX_ENTRIES) history = history.slice(-MAX_ENTRIES);
|
|
37
|
+
|
|
38
|
+
writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
|
39
|
+
} catch {}
|
|
40
|
+
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getHistory() {
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(HISTORY_FILE)) {
|
|
47
|
+
return JSON.parse(readFileSync(HISTORY_FILE, 'utf-8'));
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getDelta(current) {
|
|
54
|
+
const history = getHistory();
|
|
55
|
+
// Need at least 2 entries (current was just saved as the last one)
|
|
56
|
+
if (history.length < 2) return null;
|
|
57
|
+
|
|
58
|
+
const prev = history[history.length - 2];
|
|
59
|
+
const daysSince = Math.round((new Date(current.ts) - new Date(prev.ts)) / 86400000);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
prev,
|
|
63
|
+
daysSince,
|
|
64
|
+
gradeChange: current.grade !== prev.grade ? `${prev.grade} → ${current.grade}` : null,
|
|
65
|
+
scoreChange: current.score - prev.score,
|
|
66
|
+
ratioChange: current.ratio - prev.ratio,
|
|
67
|
+
costChange: current.totalCost - prev.totalCost,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -469,11 +469,17 @@ ${inflection && inflection.multiplier >= 1.5 ? `
|
|
|
469
469
|
<div class="space-y-3">
|
|
470
470
|
${recommendations.map(r => {
|
|
471
471
|
const sev = sevColorMap[r.severity] || sevColorMap.info;
|
|
472
|
+
const safeTitle = r.title.replace(/'/g, "\\'").replace(/"/g, '"');
|
|
473
|
+
const safeAction = r.action.replace(/'/g, "\\'").replace(/"/g, '"');
|
|
474
|
+
const clipboardText = `CC Hubber flagged this about my setup:\\n\\n${r.title}\\n\\n${r.action}\\n\\nBefore making changes:\\n1. Read the relevant files first to understand the current setup\\n2. Do NOT remove or modify anything without asking me — some things are there intentionally\\n3. Show me what each section/setting costs in tokens and let me decide what to keep\\n4. Suggest optimizations (move to skills, use hooks, restructure) rather than deletion\\n5. Do not break existing working functionality`;
|
|
472
475
|
return `<div class="p-4 bg-[#0d0e0f] rounded-r-lg flex items-start gap-4" style="border-left:3px solid ${sev.border}">
|
|
473
476
|
<div class="flex-1 min-w-0">
|
|
474
477
|
<div class="flex items-start justify-between gap-4">
|
|
475
478
|
<p class="text-[13px] font-semibold text-[#e3e2e3]">${r.title}</p>
|
|
476
|
-
|
|
479
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
480
|
+
${r.savings ? `<span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:${sev.border}18;color:${sev.text}">${r.savings}</span>` : ''}
|
|
481
|
+
${r.severity !== 'positive' ? `<button onclick="navigator.clipboard.writeText('${clipboardText}');this.textContent='Copied!';setTimeout(()=>this.textContent='Fix with Claude',1500)" class="text-[10px] font-mono px-2 py-0.5 rounded border border-[rgba(70,69,84,0.3)] text-[#908fa0] cursor-pointer hover:text-[#e3e2e3] hover:border-[rgba(70,69,84,0.6)] transition-colors">Fix with Claude</button>` : ''}
|
|
482
|
+
</div>
|
|
477
483
|
</div>
|
|
478
484
|
<p class="text-[11px] text-[#908fa0] mt-1 leading-relaxed">${r.action}</p>
|
|
479
485
|
</div>
|
|
@@ -484,7 +490,44 @@ ${inflection && inflection.multiplier >= 1.5 ? `
|
|
|
484
490
|
</div>
|
|
485
491
|
</section>
|
|
486
492
|
|
|
487
|
-
<!-- 7.
|
|
493
|
+
<!-- 7. COMMUNITY LEADERBOARD -->
|
|
494
|
+
<section id="community-section" class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden" style="display:none">
|
|
495
|
+
<div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)]">
|
|
496
|
+
<div class="flex items-center justify-between">
|
|
497
|
+
<h3 class="text-xl font-bold text-[#e3e2e3]">Community</h3>
|
|
498
|
+
<span id="community-count" class="text-[10px] font-mono text-[#908fa0]"></span>
|
|
499
|
+
</div>
|
|
500
|
+
<p id="community-percentile" class="text-sm text-[#908fa0] mt-1"></p>
|
|
501
|
+
</div>
|
|
502
|
+
|
|
503
|
+
<!-- Grade distribution bar -->
|
|
504
|
+
<div class="px-8 py-4 border-b border-[rgba(70,69,84,0.15)]">
|
|
505
|
+
<div class="flex items-center gap-3 mb-2">
|
|
506
|
+
<span class="text-[10px] font-mono text-[#908fa0] uppercase tracking-wider">Grade Distribution</span>
|
|
507
|
+
</div>
|
|
508
|
+
<div id="grade-dist-bar" class="flex h-6 rounded overflow-hidden"></div>
|
|
509
|
+
<div id="grade-dist-labels" class="flex mt-1"></div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
<!-- Leaderboard table -->
|
|
513
|
+
<div class="overflow-x-auto">
|
|
514
|
+
<table class="w-full">
|
|
515
|
+
<thead>
|
|
516
|
+
<tr class="border-b border-[rgba(70,69,84,0.15)]">
|
|
517
|
+
<th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">#</th>
|
|
518
|
+
<th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Grade</th>
|
|
519
|
+
<th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Ratio</th>
|
|
520
|
+
<th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cost</th>
|
|
521
|
+
<th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Opus %</th>
|
|
522
|
+
<th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Country</th>
|
|
523
|
+
</tr>
|
|
524
|
+
</thead>
|
|
525
|
+
<tbody id="leaderboard-body"></tbody>
|
|
526
|
+
</table>
|
|
527
|
+
</div>
|
|
528
|
+
</section>
|
|
529
|
+
|
|
530
|
+
<!-- 8. PROJECTS TABLE -->
|
|
488
531
|
${projectBreakdown && projectBreakdown.length > 0 ? `
|
|
489
532
|
<section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
|
|
490
533
|
<div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
|
|
@@ -498,7 +541,8 @@ ${projectBreakdown && projectBreakdown.length > 0 ? `
|
|
|
498
541
|
<th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Project</th>
|
|
499
542
|
<th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Messages</th>
|
|
500
543
|
<th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Sessions</th>
|
|
501
|
-
<th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">
|
|
544
|
+
<th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Est. Cost</th>
|
|
545
|
+
<th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Output</th>
|
|
502
546
|
<th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Cache Read</th>
|
|
503
547
|
</tr>
|
|
504
548
|
</thead>
|
|
@@ -679,9 +723,11 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
|
|
|
679
723
|
h+='<td class="px-8 py-4 text-sm font-semibold text-[#e3e2e3]"><span class="proj-name">'+p.name+'</span>';
|
|
680
724
|
if(p.path)h+='<br><span class="proj-path text-[10px] text-[#908fa0] font-mono">'+p.path+'</span>';
|
|
681
725
|
h+='</td>';
|
|
726
|
+
var projCost=p.output/1e6*OUT+p.cacheRead/1e6*CACHE_R+p.input/1e6*INP+p.cacheWrite/1e6*CW;
|
|
682
727
|
h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.messages.toLocaleString()+'</td>';
|
|
683
728
|
h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.sessions+'</td>';
|
|
684
|
-
h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+
|
|
729
|
+
h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+fc(projCost)+'</td>';
|
|
730
|
+
h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.output)+'</td>';
|
|
685
731
|
h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.cacheRead)+'</td>';
|
|
686
732
|
h+='</tr>';
|
|
687
733
|
}
|
|
@@ -1008,6 +1054,75 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
|
|
|
1008
1054
|
});
|
|
1009
1055
|
|
|
1010
1056
|
setR('all');
|
|
1057
|
+
|
|
1058
|
+
// Community leaderboard — stats fetched at generation time, embedded as JSON
|
|
1059
|
+
var MY_RATIO = ${cacheHealth.efficiencyRatio || 0};
|
|
1060
|
+
var MY_GRADE = '${cacheHealth.grade?.letter || '?'}';
|
|
1061
|
+
var gradeColors = {A:'#10b981',B:'#22d3ee',C:'#f59e0b',D:'#f97316',F:'#ef4444'};
|
|
1062
|
+
var stats = ${JSON.stringify(report.communityStats || null)};
|
|
1063
|
+
|
|
1064
|
+
(function(stats){
|
|
1065
|
+
if(!stats || !stats.totalReports) return;
|
|
1066
|
+
var sec = document.getElementById('community-section');
|
|
1067
|
+
if(!sec || !stats.totalReports) return;
|
|
1068
|
+
sec.style.display = '';
|
|
1069
|
+
|
|
1070
|
+
// Count + percentile
|
|
1071
|
+
var total = stats.totalReports || 0;
|
|
1072
|
+
document.getElementById('community-count').textContent = total + ' users worldwide';
|
|
1073
|
+
|
|
1074
|
+
// Calculate percentile from recent entries
|
|
1075
|
+
var recent = stats.recent || [];
|
|
1076
|
+
var ratios = recent.map(function(r){return r.ratio||9999}).sort(function(a,b){return a-b});
|
|
1077
|
+
var betterThan = ratios.filter(function(r){return r > MY_RATIO}).length;
|
|
1078
|
+
var pctile = total > 0 ? Math.round(betterThan / ratios.length * 100) : 0;
|
|
1079
|
+
document.getElementById('community-percentile').innerHTML =
|
|
1080
|
+
'Your cache ratio of <strong style="color:#e3e2e3">' + MY_RATIO + ':1</strong> is better than <strong style="color:#c0c1ff">' + pctile + '%</strong> of CC Hubber users';
|
|
1081
|
+
|
|
1082
|
+
// Grade distribution bar
|
|
1083
|
+
var grades = stats.grades || {};
|
|
1084
|
+
var gTotal = Object.values(grades).reduce(function(s,v){return s+v},0);
|
|
1085
|
+
var bar = document.getElementById('grade-dist-bar');
|
|
1086
|
+
var labels = document.getElementById('grade-dist-labels');
|
|
1087
|
+
['A','B','C','D','F'].forEach(function(g){
|
|
1088
|
+
var count = grades[g] || 0;
|
|
1089
|
+
var pct = gTotal > 0 ? (count/gTotal*100) : 0;
|
|
1090
|
+
if(pct > 0){
|
|
1091
|
+
var seg = document.createElement('div');
|
|
1092
|
+
seg.style.cssText = 'width:'+pct+'%;background:'+gradeColors[g]+';display:flex;align-items:center;justify-content:center;';
|
|
1093
|
+
seg.innerHTML = '<span style="font-size:10px;font-weight:700;color:#0d0e0f">' + (pct>8?g:'') + '</span>';
|
|
1094
|
+
bar.appendChild(seg);
|
|
1095
|
+
var lbl = document.createElement('span');
|
|
1096
|
+
lbl.style.cssText = 'width:'+pct+'%;text-align:center;font-size:10px;color:#908fa0;font-family:monospace;';
|
|
1097
|
+
lbl.textContent = count;
|
|
1098
|
+
labels.appendChild(lbl);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// Leaderboard table
|
|
1103
|
+
var tbody = document.getElementById('leaderboard-body');
|
|
1104
|
+
var sorted = recent.filter(function(r){return r.ratio}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
|
|
1105
|
+
var myRank = -1;
|
|
1106
|
+
sorted.forEach(function(entry, i){
|
|
1107
|
+
if(myRank<0 && (entry.ratio||9999) >= MY_RATIO) myRank = i;
|
|
1108
|
+
});
|
|
1109
|
+
if(myRank<0) myRank = sorted.length;
|
|
1110
|
+
|
|
1111
|
+
var html = '';
|
|
1112
|
+
sorted.forEach(function(entry, i){
|
|
1113
|
+
var isMe = Math.abs((entry.ratio||0) - MY_RATIO) < 10 && entry.grade === MY_GRADE;
|
|
1114
|
+
var rowStyle = isMe ? 'background:rgba(192,193,255,0.06);border-left:2px solid #c0c1ff;' : '';
|
|
1115
|
+
html += '<tr style="border-bottom:1px solid rgba(70,69,84,0.1);'+rowStyle+'">';
|
|
1116
|
+
html += '<td class="px-8 py-3 text-sm font-mono text-[#908fa0]">#'+(i+1)+'</td>';
|
|
1117
|
+
html += '<td class="px-4 py-3 text-sm font-bold text-center" style="color:'+gradeColors[entry.grade||'C']+'">'+entry.grade+'</td>';
|
|
1118
|
+
html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.ratio||'?')+':1</td>';
|
|
1119
|
+
html += '<td class="px-4 py-3 text-sm font-mono text-[#908fa0]">'+(entry.cost||'?')+'</td>';
|
|
1120
|
+
html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus||'?')+'%</td>';
|
|
1121
|
+
html += '<td class="px-8 py-3 text-sm text-[#908fa0]">'+(entry.country||'?')+'</td>';
|
|
1122
|
+
html += '</tr>';
|
|
1123
|
+
});
|
|
1124
|
+
tbody.innerHTML = html;
|
|
1125
|
+
})(stats);
|
|
1011
1126
|
})();
|
|
1012
1127
|
</script>
|
|
1013
1128
|
</body>
|
package/src/telemetry.js
CHANGED
|
@@ -136,24 +136,29 @@ export function sendTelemetry(report) {
|
|
|
136
136
|
...gatherEnvironmentData(),
|
|
137
137
|
};
|
|
138
138
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
139
|
+
// Returns a promise that resolves when the request completes (or times out)
|
|
140
|
+
// CLI must await this before exiting, otherwise the process kills the request
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
try {
|
|
143
|
+
const data = JSON.stringify(payload);
|
|
144
|
+
const url = new URL(TELEMETRY_URL);
|
|
145
|
+
const req = https.request({
|
|
146
|
+
hostname: url.hostname,
|
|
147
|
+
path: url.pathname,
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
150
|
+
}, (res) => {
|
|
151
|
+
res.resume(); // drain response
|
|
152
|
+
res.on('end', () => { markTelemetrySent(); resolve(); });
|
|
153
|
+
});
|
|
154
|
+
req.on('error', () => resolve()); // silent fail, still resolve
|
|
155
|
+
req.setTimeout(4000, () => { req.destroy(); resolve(); });
|
|
156
|
+
req.write(data);
|
|
157
|
+
req.end();
|
|
158
|
+
} catch {
|
|
159
|
+
resolve(); // never block on telemetry failure
|
|
160
|
+
}
|
|
161
|
+
});
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
function costBucket(cost) {
|