cchubber 0.1.0 → 0.3.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/package.json +1 -1
- package/src/analyzers/cache-health.js +11 -4
- package/src/analyzers/inflection-detector.js +38 -26
- package/src/analyzers/model-routing.js +72 -0
- package/src/analyzers/recommendations.js +106 -72
- package/src/analyzers/session-intelligence.js +114 -0
- package/src/cli/index.js +11 -6
- package/src/readers/claude-md.js +27 -1
- package/src/readers/jsonl-reader.js +117 -50
- package/src/renderers/html-report.js +1045 -1375
package/package.json
CHANGED
|
@@ -57,13 +57,20 @@ export function analyzeCacheHealth(statsCache, cacheBreaks, days, dailyFromJSONL
|
|
|
57
57
|
// With cache: reads are $0.50/M
|
|
58
58
|
const savingsFromCache = totalCacheRead / 1_000_000 * (5.0 - 0.50);
|
|
59
59
|
|
|
60
|
-
// Cost wasted from cache
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
const wastedFromBreaks = totalBreaks
|
|
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
|
+
const wastedFromBreaks = totalBreaks > 0
|
|
64
|
+
? totalBreaks * 200_000 / 1_000_000 * (6.25 - 0.50)
|
|
65
|
+
: totalCacheWrite / 1_000_000 * (6.25 - 0.50); // estimate from write tokens
|
|
66
|
+
|
|
67
|
+
// If no diff files but cache writes exist, estimate break count
|
|
68
|
+
// Each break re-caches ~200K-500K tokens on average
|
|
69
|
+
const estimatedBreaks = totalBreaks > 0 ? totalBreaks : Math.round(totalCacheWrite / 300_000);
|
|
64
70
|
|
|
65
71
|
return {
|
|
66
72
|
totalCacheBreaks: totalBreaks,
|
|
73
|
+
estimatedBreaks,
|
|
67
74
|
reasonsRanked,
|
|
68
75
|
cacheHitRate: Math.round(cacheHitRate * 10) / 10,
|
|
69
76
|
efficiencyRatio,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inflection Point Detection
|
|
3
|
-
* Finds the
|
|
4
|
-
*
|
|
3
|
+
* Finds BOTH the worst degradation AND best improvement in cache efficiency.
|
|
4
|
+
* Prioritizes degradation — that's what users care about ("why is my usage draining?").
|
|
5
5
|
*/
|
|
6
6
|
export function detectInflectionPoints(dailyFromJSONL) {
|
|
7
7
|
if (!dailyFromJSONL || dailyFromJSONL.length < 5) return null;
|
|
@@ -12,10 +12,10 @@ export function detectInflectionPoints(dailyFromJSONL) {
|
|
|
12
12
|
|
|
13
13
|
if (sorted.length < 5) return null;
|
|
14
14
|
|
|
15
|
-
// Sliding window: compare the average ratio of days before vs after each point
|
|
16
|
-
// Window size: at least 3 days on each side
|
|
17
15
|
const minWindow = 3;
|
|
18
|
-
let
|
|
16
|
+
let worstDegradation = null;
|
|
17
|
+
let worstScore = 0;
|
|
18
|
+
let bestImprovement = null;
|
|
19
19
|
let bestScore = 0;
|
|
20
20
|
|
|
21
21
|
for (let i = minWindow; i <= sorted.length - minWindow; i++) {
|
|
@@ -27,32 +27,44 @@ export function detectInflectionPoints(dailyFromJSONL) {
|
|
|
27
27
|
|
|
28
28
|
if (beforeRatio === 0 || afterRatio === 0) continue;
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
afterDays: after.length,
|
|
45
|
-
};
|
|
30
|
+
if (afterRatio > beforeRatio) {
|
|
31
|
+
// Degradation (ratio went UP = worse)
|
|
32
|
+
const mult = afterRatio / beforeRatio;
|
|
33
|
+
if (mult > worstScore && mult >= 1.5) {
|
|
34
|
+
worstScore = mult;
|
|
35
|
+
worstDegradation = buildResult(sorted[i].date, beforeRatio, afterRatio, mult, 'worsened', before.length, after.length);
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
// Improvement (ratio went DOWN = better)
|
|
39
|
+
const mult = beforeRatio / afterRatio;
|
|
40
|
+
if (mult > bestScore && mult >= 1.5) {
|
|
41
|
+
bestScore = mult;
|
|
42
|
+
bestImprovement = buildResult(sorted[i].date, beforeRatio, afterRatio, mult, 'improved', before.length, after.length);
|
|
43
|
+
}
|
|
46
44
|
}
|
|
47
45
|
}
|
|
48
46
|
|
|
49
|
-
|
|
47
|
+
// Return degradation as primary (that's the problem), improvement as secondary
|
|
48
|
+
const primary = worstDegradation || bestImprovement;
|
|
49
|
+
if (!primary) return null;
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
primary.secondary = worstDegradation ? bestImprovement : null;
|
|
52
|
+
return primary;
|
|
53
|
+
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
function buildResult(date, beforeRatio, afterRatio, multiplier, direction, beforeDays, afterDays) {
|
|
56
|
+
const mult = Math.round(multiplier * 10) / 10;
|
|
57
|
+
const dirLabel = direction === 'worsened' ? 'dropped' : 'improved';
|
|
58
|
+
return {
|
|
59
|
+
date,
|
|
60
|
+
beforeRatio,
|
|
61
|
+
afterRatio,
|
|
62
|
+
multiplier: mult,
|
|
63
|
+
direction,
|
|
64
|
+
beforeDays,
|
|
65
|
+
afterDays,
|
|
66
|
+
summary: `Your cache efficiency ${dirLabel} ${mult}x starting ${formatDate(date)}. Before: ${beforeRatio.toLocaleString()}:1. After: ${afterRatio.toLocaleString()}:1.`,
|
|
67
|
+
};
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
function computeRatio(days) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Routing Analysis
|
|
3
|
+
* Detects model usage patterns and estimates savings from better routing.
|
|
4
|
+
*/
|
|
5
|
+
export function analyzeModelRouting(costAnalysis, jsonlEntries) {
|
|
6
|
+
const modelCosts = costAnalysis.modelCosts || {};
|
|
7
|
+
const totalCost = Object.values(modelCosts).reduce((s, c) => s + c, 0);
|
|
8
|
+
|
|
9
|
+
if (totalCost < 0.01) return { available: false };
|
|
10
|
+
|
|
11
|
+
// Classify models into tiers
|
|
12
|
+
const tiers = { opus: 0, sonnet: 0, haiku: 0, other: 0 };
|
|
13
|
+
const tierCosts = { opus: 0, sonnet: 0, haiku: 0, other: 0 };
|
|
14
|
+
|
|
15
|
+
for (const [name, cost] of Object.entries(modelCosts)) {
|
|
16
|
+
const lower = name.toLowerCase();
|
|
17
|
+
if (lower.includes('opus')) { tiers.opus++; tierCosts.opus += cost; }
|
|
18
|
+
else if (lower.includes('sonnet')) { tiers.sonnet++; tierCosts.sonnet += cost; }
|
|
19
|
+
else if (lower.includes('haiku')) { tiers.haiku++; tierCosts.haiku += cost; }
|
|
20
|
+
else { tiers.other++; tierCosts.other += cost; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const opusPct = totalCost > 0 ? Math.round((tierCosts.opus / totalCost) * 100) : 0;
|
|
24
|
+
const sonnetPct = totalCost > 0 ? Math.round((tierCosts.sonnet / totalCost) * 100) : 0;
|
|
25
|
+
const haikuPct = totalCost > 0 ? Math.round((tierCosts.haiku / totalCost) * 100) : 0;
|
|
26
|
+
|
|
27
|
+
// Estimate savings: assume 40% of Opus work could be done by Sonnet at 60% cost
|
|
28
|
+
// Conservative estimate — Sonnet handles file reads, simple edits, search well
|
|
29
|
+
const opusCost = tierCosts.opus;
|
|
30
|
+
const routableToSonnet = opusCost * 0.4; // 40% of Opus work is routable
|
|
31
|
+
const sonnetEquivalentCost = routableToSonnet * 0.6; // Sonnet is ~60% of Opus cost
|
|
32
|
+
const estimatedSavings = routableToSonnet - sonnetEquivalentCost;
|
|
33
|
+
|
|
34
|
+
// Detect subagent usage from JSONL (subagent messages often use different models)
|
|
35
|
+
let subagentMessages = 0;
|
|
36
|
+
let mainMessages = 0;
|
|
37
|
+
if (jsonlEntries && jsonlEntries.length > 0) {
|
|
38
|
+
for (const entry of jsonlEntries) {
|
|
39
|
+
const model = (entry.model || '').toLowerCase();
|
|
40
|
+
// Subagents typically use sonnet/haiku, main thread uses opus
|
|
41
|
+
if (model.includes('sonnet') || model.includes('haiku')) {
|
|
42
|
+
subagentMessages++;
|
|
43
|
+
} else {
|
|
44
|
+
mainMessages++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const subagentPct = (subagentMessages + mainMessages) > 0
|
|
50
|
+
? Math.round((subagentMessages / (subagentMessages + mainMessages)) * 100)
|
|
51
|
+
: 0;
|
|
52
|
+
|
|
53
|
+
// Model diversity score (0-100): higher = better routing
|
|
54
|
+
const modelCount = Object.keys(modelCosts).length;
|
|
55
|
+
let diversityScore = 0;
|
|
56
|
+
if (modelCount >= 3 && opusPct < 80) diversityScore = 90;
|
|
57
|
+
else if (modelCount >= 2 && opusPct < 90) diversityScore = 60;
|
|
58
|
+
else if (opusPct > 95) diversityScore = 20;
|
|
59
|
+
else diversityScore = 40;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
available: true,
|
|
63
|
+
opusPct,
|
|
64
|
+
sonnetPct,
|
|
65
|
+
haikuPct,
|
|
66
|
+
estimatedSavings: Math.round(estimatedSavings),
|
|
67
|
+
subagentPct,
|
|
68
|
+
diversityScore,
|
|
69
|
+
tierCosts,
|
|
70
|
+
totalCost,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -1,128 +1,162 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Recommendations Engine
|
|
3
|
+
* Each recommendation includes estimated usage % savings.
|
|
4
|
+
* Informed by community data from the March 2026 Claude Code crisis.
|
|
5
|
+
*/
|
|
6
|
+
export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting) {
|
|
2
7
|
const recs = [];
|
|
8
|
+
const totalCost = costAnalysis.totalCost || 1;
|
|
3
9
|
|
|
4
|
-
// 0. Inflection point
|
|
10
|
+
// 0. Inflection point
|
|
5
11
|
if (inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2) {
|
|
6
12
|
recs.push({
|
|
7
13
|
severity: 'critical',
|
|
8
|
-
title: `
|
|
9
|
-
|
|
10
|
-
action: '
|
|
14
|
+
title: `Cache efficiency dropped ${inflection.multiplier}x on ${inflection.date}`,
|
|
15
|
+
savings: '~40-60% usage reduction after fix',
|
|
16
|
+
action: 'Run: claude update. v2.1.69-2.1.89 had cache bugs. Fixed in v2.1.90.',
|
|
11
17
|
});
|
|
12
18
|
} else if (inflection && inflection.direction === 'improved' && inflection.multiplier >= 2) {
|
|
13
19
|
recs.push({
|
|
14
20
|
severity: 'positive',
|
|
15
21
|
title: `Efficiency improved ${inflection.multiplier}x on ${inflection.date}`,
|
|
16
|
-
|
|
17
|
-
action: '
|
|
22
|
+
savings: 'Already saving',
|
|
23
|
+
action: 'Your cache efficiency improved. Likely a version update or workflow change.',
|
|
18
24
|
});
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
// 1.
|
|
27
|
+
// 1. Model routing — biggest actionable saving for most users
|
|
28
|
+
const modelCosts = costAnalysis.modelCosts || {};
|
|
29
|
+
const totalModelCost = Object.values(modelCosts).reduce((s, c) => s + c, 0);
|
|
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
|
+
|
|
33
|
+
if (opusPct > 80) {
|
|
34
|
+
const savingsPct = Math.round(opusPct * 0.4 * 0.8); // 40% of Opus routable, 80% cheaper
|
|
35
|
+
recs.push({
|
|
36
|
+
severity: 'warning',
|
|
37
|
+
title: `${opusPct}% usage is Opus — route subagents to Sonnet`,
|
|
38
|
+
savings: `~${savingsPct}% usage reduction`,
|
|
39
|
+
action: `Set model: "sonnet" on Task/subagent calls. Sonnet handles search, file reads, docs, and simple edits at same quality. Community-verified: limits lasted 3-5x longer.`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. CLAUDE.md bloat
|
|
22
44
|
if (claudeMdStack.totalTokensEstimate > 8000) {
|
|
45
|
+
const excessK = Math.round((claudeMdStack.totalTokensEstimate - 4000) / 1000);
|
|
46
|
+
recs.push({
|
|
47
|
+
severity: claudeMdStack.totalTokensEstimate > 15000 ? 'critical' : 'warning',
|
|
48
|
+
title: `CLAUDE.md is ${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K tokens — trim to <4K`,
|
|
49
|
+
savings: `saves ~${excessK}K tokens/msg`,
|
|
50
|
+
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.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Compaction frequency — community's #1 session management tip
|
|
55
|
+
if (sessionIntel?.available && sessionIntel.avgToolsPerSession > 25) {
|
|
23
56
|
recs.push({
|
|
24
57
|
severity: 'warning',
|
|
25
|
-
title:
|
|
26
|
-
|
|
27
|
-
action: '
|
|
58
|
+
title: `Avg ${sessionIntel.avgToolsPerSession} tool calls/session — compact more often`,
|
|
59
|
+
savings: '~15-25% usage reduction',
|
|
60
|
+
action: 'Use /compact every 30-40 tool calls. Context bloat compounds — each message re-reads the full history. Community tip: compacting at 40 calls saves 20%+ on long sessions.',
|
|
28
61
|
});
|
|
29
62
|
}
|
|
30
63
|
|
|
31
|
-
//
|
|
32
|
-
if (
|
|
33
|
-
const topReason = cacheHealth.reasonsRanked[0];
|
|
64
|
+
// 4. Fresh sessions per task
|
|
65
|
+
if (sessionIntel?.available && sessionIntel.longSessionPct > 30) {
|
|
34
66
|
recs.push({
|
|
35
|
-
severity:
|
|
36
|
-
title: `${
|
|
37
|
-
|
|
38
|
-
action:
|
|
39
|
-
? 'Reduce MCP tool connections. Each tool add/remove invalidates the cache.'
|
|
40
|
-
: topReason?.reason === 'System prompt changed'
|
|
41
|
-
? 'Avoid editing CLAUDE.md mid-session. Make changes between sessions.'
|
|
42
|
-
: topReason?.reason === 'TTL expiry'
|
|
43
|
-
? 'Keep sessions active. Cache expires after 5 minutes of inactivity.'
|
|
44
|
-
: 'Review cache break logs in ~/.claude/tmp/cache-break-*.diff for details.',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
title: `${sessionIntel.longSessionPct}% of sessions over 60 min — start fresh more often`,
|
|
69
|
+
savings: '~10-20% usage reduction',
|
|
70
|
+
action: `One task, one session. Your p90 is ${sessionIntel.p90Duration}min, longest ${sessionIntel.maxDuration}min. Starting fresh resets context and maximizes cache hits. Cheaper than a bloated session.`,
|
|
45
71
|
});
|
|
46
72
|
}
|
|
47
73
|
|
|
48
|
-
//
|
|
49
|
-
if (cacheHealth.efficiencyRatio >
|
|
74
|
+
// 5. Cache ratio warning
|
|
75
|
+
if (cacheHealth.efficiencyRatio > 1500) {
|
|
50
76
|
recs.push({
|
|
51
77
|
severity: 'critical',
|
|
52
|
-
title: `Cache
|
|
53
|
-
|
|
54
|
-
action: '
|
|
78
|
+
title: `Cache ratio ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — update Claude Code`,
|
|
79
|
+
savings: '~40-60% usage reduction',
|
|
80
|
+
action: 'Run: claude update. v2.1.89 had cache bugs that inflated ratios 10-20x. Community-verified: v2.1.90 dropped usage from 80-100% to 5-7% of Max quota.',
|
|
55
81
|
});
|
|
56
|
-
} else if (cacheHealth.efficiencyRatio >
|
|
82
|
+
} else if (cacheHealth.efficiencyRatio > 800) {
|
|
57
83
|
recs.push({
|
|
58
|
-
severity: '
|
|
59
|
-
title: `
|
|
60
|
-
|
|
61
|
-
action: '
|
|
84
|
+
severity: 'info',
|
|
85
|
+
title: `Cache ratio ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — slightly elevated`,
|
|
86
|
+
savings: '~5-10% with optimization',
|
|
87
|
+
action: 'Healthy range: 300-800:1. Reduce by compacting more often, starting fresh sessions, and avoiding --resume on older CC versions.',
|
|
62
88
|
});
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
//
|
|
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.',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 7. .claudeignore — prevents reading node_modules etc
|
|
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
|
|
66
110
|
if (anomalies.hasAnomalies) {
|
|
67
111
|
const spikes = anomalies.anomalies.filter(a => a.type === 'spike');
|
|
68
112
|
if (spikes.length > 0) {
|
|
69
113
|
const worst = spikes[0];
|
|
70
114
|
recs.push({
|
|
71
115
|
severity: worst.severity,
|
|
72
|
-
title: `${spikes.length} cost spike${spikes.length > 1 ? 's' : ''}
|
|
73
|
-
|
|
74
|
-
action: '
|
|
116
|
+
title: `${spikes.length} cost spike${spikes.length > 1 ? 's' : ''} — worst $${worst.cost.toFixed(0)} on ${worst.date}`,
|
|
117
|
+
savings: 'Preventable with monitoring',
|
|
118
|
+
action: 'Watch the first 1-2 messages of each session. If a single message burns 3-5% of quota, restart immediately. GitHub #38029 documents phantom 652K output token bugs.',
|
|
75
119
|
});
|
|
76
120
|
}
|
|
77
121
|
}
|
|
78
122
|
|
|
79
|
-
//
|
|
80
|
-
if (
|
|
123
|
+
// 9. Avoid --resume on older versions
|
|
124
|
+
if (cacheHealth.efficiencyRatio > 600) {
|
|
81
125
|
recs.push({
|
|
82
|
-
severity: '
|
|
83
|
-
title: '
|
|
84
|
-
|
|
85
|
-
action: '
|
|
126
|
+
severity: 'info',
|
|
127
|
+
title: 'Avoid --resume and --continue flags',
|
|
128
|
+
savings: '~$0.15 saved per resume',
|
|
129
|
+
action: 'These flags caused full prompt-cache misses in v2.1.69-2.1.89 (~$0.15 per resume on 500K context). Fixed in v2.1.90. Copy your last message and start fresh instead.',
|
|
86
130
|
});
|
|
87
131
|
}
|
|
88
132
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
133
|
+
// 10. Specific prompt discipline
|
|
134
|
+
recs.push({
|
|
135
|
+
severity: 'info',
|
|
136
|
+
title: 'Be specific in prompts — reduces tokens up to 10x',
|
|
137
|
+
savings: '~20-40% usage reduction',
|
|
138
|
+
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
|
+
});
|
|
96
140
|
|
|
97
|
-
|
|
141
|
+
// 11. Disconnect unused MCP tools
|
|
142
|
+
if (sessionIntel?.available && sessionIntel.topTools.some(t => t.name.includes('mcp__'))) {
|
|
98
143
|
recs.push({
|
|
99
144
|
severity: 'info',
|
|
100
|
-
title:
|
|
101
|
-
|
|
102
|
-
action: '
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 7. Session depth — long sessions without compact
|
|
107
|
-
const sessions = costAnalysis.sessions || {};
|
|
108
|
-
if (sessions.avgDurationMinutes > 60) {
|
|
109
|
-
recs.push({
|
|
110
|
-
severity: 'warning',
|
|
111
|
-
title: `Average session: ${Math.round(sessions.avgDurationMinutes)} minutes`,
|
|
112
|
-
detail: `Long sessions accumulate context that degrades both performance and cache efficiency. Sessions over 60 minutes often benefit from /compact.`,
|
|
113
|
-
action: 'Use /compact every 30-40 tool calls or when switching tasks. Start fresh sessions for new work.',
|
|
145
|
+
title: 'Disconnect unused MCP servers',
|
|
146
|
+
savings: '~5-15% per cache break avoided',
|
|
147
|
+
action: 'Each MCP tool schema change invalidates the prompt cache. Only connect servers you actively need. Disconnect the rest between sessions.',
|
|
114
148
|
});
|
|
115
149
|
}
|
|
116
150
|
|
|
117
|
-
//
|
|
118
|
-
if (cacheHealth.savings
|
|
151
|
+
// 12. Cache savings (positive)
|
|
152
|
+
if (cacheHealth.savings?.fromCaching > 100) {
|
|
119
153
|
recs.push({
|
|
120
154
|
severity: 'positive',
|
|
121
|
-
title: `
|
|
122
|
-
|
|
123
|
-
action: '
|
|
155
|
+
title: `Cache saved ~$${cacheHealth.savings.fromCaching.toLocaleString()} in equivalent API costs`,
|
|
156
|
+
savings: 'Working as intended',
|
|
157
|
+
action: 'Prompt caching is saving you significantly. Keep sessions alive, avoid mid-session CLAUDE.md edits and MCP tool changes to maximize hits.',
|
|
124
158
|
});
|
|
125
159
|
}
|
|
126
160
|
|
|
127
|
-
return recs;
|
|
161
|
+
return recs.slice(0, 8);
|
|
128
162
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Intelligence
|
|
3
|
+
* Analyzes session patterns: length, tool density, compact usage, productivity.
|
|
4
|
+
*/
|
|
5
|
+
export function analyzeSessionIntelligence(sessionMeta, jsonlEntries) {
|
|
6
|
+
if (!sessionMeta || sessionMeta.length === 0) {
|
|
7
|
+
return { available: false };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const sessions = sessionMeta.filter(s => s.durationMinutes > 0);
|
|
11
|
+
if (sessions.length === 0) return { available: false };
|
|
12
|
+
|
|
13
|
+
// Basic session stats
|
|
14
|
+
const durations = sessions.map(s => s.durationMinutes);
|
|
15
|
+
const totalMinutes = durations.reduce((s, d) => s + d, 0);
|
|
16
|
+
const avgDuration = totalMinutes / sessions.length;
|
|
17
|
+
const maxDuration = Math.max(...durations);
|
|
18
|
+
const longestSession = sessions.find(s => s.durationMinutes === maxDuration);
|
|
19
|
+
|
|
20
|
+
// Sort by duration for percentile calc
|
|
21
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
22
|
+
const p50 = sorted[Math.floor(sorted.length * 0.5)];
|
|
23
|
+
const p90 = sorted[Math.floor(sorted.length * 0.9)];
|
|
24
|
+
|
|
25
|
+
// Long sessions (>60 min) — likely need /compact
|
|
26
|
+
const longSessions = sessions.filter(s => s.durationMinutes > 60);
|
|
27
|
+
const longSessionPct = sessions.length > 0 ? Math.round((longSessions.length / sessions.length) * 100) : 0;
|
|
28
|
+
|
|
29
|
+
// Tool call density per session
|
|
30
|
+
const toolDensities = sessions.map(s => {
|
|
31
|
+
const totalTools = Object.values(s.toolCounts || {}).reduce((sum, c) => sum + c, 0);
|
|
32
|
+
return { sessionId: s.sessionId, tools: totalTools, minutes: s.durationMinutes, density: s.durationMinutes > 0 ? (totalTools / s.durationMinutes).toFixed(1) : 0 };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const avgToolsPerSession = toolDensities.reduce((s, t) => s + t.tools, 0) / sessions.length;
|
|
36
|
+
|
|
37
|
+
// Most used tools across all sessions
|
|
38
|
+
const toolTotals = {};
|
|
39
|
+
for (const s of sessions) {
|
|
40
|
+
for (const [tool, count] of Object.entries(s.toolCounts || {})) {
|
|
41
|
+
toolTotals[tool] = (toolTotals[tool] || 0) + count;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const topTools = Object.entries(toolTotals)
|
|
45
|
+
.sort((a, b) => b[1] - a[1])
|
|
46
|
+
.slice(0, 8)
|
|
47
|
+
.map(([name, count]) => ({ name, count }));
|
|
48
|
+
|
|
49
|
+
// Lines of code per session hour (productivity proxy)
|
|
50
|
+
const totalLines = sessions.reduce((s, x) => s + x.linesAdded + x.linesRemoved, 0);
|
|
51
|
+
const totalHours = totalMinutes / 60;
|
|
52
|
+
const linesPerHour = totalHours > 0 ? Math.round(totalLines / totalHours) : 0;
|
|
53
|
+
|
|
54
|
+
// Messages per session
|
|
55
|
+
const totalMessages = sessions.reduce((s, x) => s + x.userMessageCount + x.assistantMessageCount, 0);
|
|
56
|
+
const avgMessagesPerSession = Math.round(totalMessages / sessions.length);
|
|
57
|
+
|
|
58
|
+
// Time-of-day distribution (from JSONL timestamps)
|
|
59
|
+
const hourDistribution = new Array(24).fill(0);
|
|
60
|
+
if (jsonlEntries && jsonlEntries.length > 0) {
|
|
61
|
+
for (const entry of jsonlEntries) {
|
|
62
|
+
if (!entry.timestamp) continue;
|
|
63
|
+
try {
|
|
64
|
+
const d = new Date(entry.timestamp);
|
|
65
|
+
if (!isNaN(d.getTime())) {
|
|
66
|
+
hourDistribution[d.getHours()]++;
|
|
67
|
+
}
|
|
68
|
+
} catch { /* skip */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Peak hours (top 3)
|
|
73
|
+
const peakHours = hourDistribution
|
|
74
|
+
.map((count, hour) => ({ hour, count }))
|
|
75
|
+
.sort((a, b) => b.count - a.count)
|
|
76
|
+
.slice(0, 3)
|
|
77
|
+
.map(h => ({ hour: h.hour, label: formatHour(h.hour), count: h.count }));
|
|
78
|
+
|
|
79
|
+
// Off-peak overlap check (5am-11am PT = 12pm-6pm UTC, roughly)
|
|
80
|
+
const offPeakStart = 12; // UTC
|
|
81
|
+
const offPeakEnd = 18;
|
|
82
|
+
const peakOverlapMessages = hourDistribution
|
|
83
|
+
.slice(offPeakStart, offPeakEnd + 1)
|
|
84
|
+
.reduce((s, c) => s + c, 0);
|
|
85
|
+
const totalHourMessages = hourDistribution.reduce((s, c) => s + c, 0);
|
|
86
|
+
const peakOverlapPct = totalHourMessages > 0 ? Math.round((peakOverlapMessages / totalHourMessages) * 100) : 0;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
available: true,
|
|
90
|
+
totalSessions: sessions.length,
|
|
91
|
+
totalMinutes,
|
|
92
|
+
avgDuration: Math.round(avgDuration),
|
|
93
|
+
medianDuration: p50,
|
|
94
|
+
p90Duration: p90,
|
|
95
|
+
maxDuration,
|
|
96
|
+
longestSessionProject: longestSession?.projectPath,
|
|
97
|
+
longSessions: longSessions.length,
|
|
98
|
+
longSessionPct,
|
|
99
|
+
avgToolsPerSession: Math.round(avgToolsPerSession),
|
|
100
|
+
topTools,
|
|
101
|
+
linesPerHour,
|
|
102
|
+
avgMessagesPerSession,
|
|
103
|
+
peakHours,
|
|
104
|
+
peakOverlapPct,
|
|
105
|
+
hourDistribution,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatHour(h) {
|
|
110
|
+
if (h === 0) return '12am';
|
|
111
|
+
if (h < 12) return h + 'am';
|
|
112
|
+
if (h === 12) return '12pm';
|
|
113
|
+
return (h - 12) + 'pm';
|
|
114
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import { analyzeCacheHealth } from '../analyzers/cache-health.js';
|
|
|
16
16
|
import { detectAnomalies } from '../analyzers/anomaly-detector.js';
|
|
17
17
|
import { generateRecommendations } from '../analyzers/recommendations.js';
|
|
18
18
|
import { detectInflectionPoints } from '../analyzers/inflection-detector.js';
|
|
19
|
+
import { analyzeSessionIntelligence } from '../analyzers/session-intelligence.js';
|
|
20
|
+
import { analyzeModelRouting } from '../analyzers/model-routing.js';
|
|
19
21
|
import { renderHTML } from '../renderers/html-report.js';
|
|
20
22
|
import { renderTerminal } from '../renderers/terminal-summary.js';
|
|
21
23
|
|
|
@@ -115,21 +117,24 @@ async function main() {
|
|
|
115
117
|
const cacheHealth = analyzeCacheHealth(statsCache, cacheBreaks, allTimeDays, dailyFromJSONL);
|
|
116
118
|
const anomalies = detectAnomalies(costAnalysis);
|
|
117
119
|
const inflection = detectInflectionPoints(dailyFromJSONL);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
console.log(` ✓ Inflection point: ${inflection.summary}`);
|
|
122
|
-
}
|
|
120
|
+
const sessionIntel = analyzeSessionIntelligence(sessionMeta, jsonlEntries);
|
|
121
|
+
const modelRouting = analyzeModelRouting(costAnalysis, jsonlEntries);
|
|
122
|
+
const recommendations = generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting);
|
|
123
123
|
|
|
124
|
+
if (inflection) console.log(` ✓ Inflection: ${inflection.summary}`);
|
|
125
|
+
if (sessionIntel.available) console.log(` ✓ ${sessionIntel.totalSessions} sessions analyzed (${sessionIntel.avgDuration} min avg)`);
|
|
126
|
+
if (modelRouting.available) console.log(` ✓ Model routing: ${modelRouting.opusPct}% Opus, ${modelRouting.sonnetPct}% Sonnet`);
|
|
124
127
|
console.log(` ✓ ${projectBreakdown.length} projects detected`);
|
|
125
128
|
|
|
126
129
|
const report = {
|
|
127
130
|
generatedAt: new Date().toISOString(),
|
|
128
|
-
periodDays: flags.days,
|
|
131
|
+
periodDays: flags.days,
|
|
129
132
|
costAnalysis,
|
|
130
133
|
cacheHealth,
|
|
131
134
|
anomalies,
|
|
132
135
|
inflection,
|
|
136
|
+
sessionIntel,
|
|
137
|
+
modelRouting,
|
|
133
138
|
projectBreakdown,
|
|
134
139
|
claudeMdStack,
|
|
135
140
|
oauthUsage,
|
package/src/readers/claude-md.js
CHANGED
|
@@ -6,15 +6,40 @@ export function readClaudeMdStack(claudeDir) {
|
|
|
6
6
|
const home = homedir();
|
|
7
7
|
const stack = [];
|
|
8
8
|
|
|
9
|
-
// Global CLAUDE.md
|
|
9
|
+
// Global CLAUDE.md — detailed section analysis
|
|
10
10
|
const globalPath = join(home, '.claude', 'CLAUDE.md');
|
|
11
|
+
let globalSections = [];
|
|
11
12
|
if (existsSync(globalPath)) {
|
|
12
13
|
const stat = statSync(globalPath);
|
|
14
|
+
const content = readFileSync(globalPath, 'utf-8');
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
const lineCount = lines.length;
|
|
17
|
+
|
|
18
|
+
// Parse sections (## headings)
|
|
19
|
+
let currentSection = { name: 'Header', lines: 0, bytes: 0 };
|
|
20
|
+
const sections = [];
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
if (line.match(/^##\s+/)) {
|
|
23
|
+
if (currentSection.lines > 0) sections.push(currentSection);
|
|
24
|
+
currentSection = { name: line.replace(/^#+\s*/, '').trim(), lines: 0, bytes: 0 };
|
|
25
|
+
}
|
|
26
|
+
currentSection.lines++;
|
|
27
|
+
currentSection.bytes += Buffer.byteLength(line + '\n', 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
if (currentSection.lines > 0) sections.push(currentSection);
|
|
30
|
+
|
|
31
|
+
// Add token estimates, keep original file order (don't sort)
|
|
32
|
+
globalSections = sections
|
|
33
|
+
.map((s, idx) => ({ ...s, tokens: Math.round(s.bytes / 4), order: idx }));
|
|
34
|
+
|
|
13
35
|
stack.push({
|
|
14
36
|
level: 'global',
|
|
15
37
|
path: globalPath,
|
|
16
38
|
bytes: stat.size,
|
|
17
39
|
tokensEstimate: Math.round(stat.size / 4),
|
|
40
|
+
lineCount,
|
|
41
|
+
sectionCount: sections.length,
|
|
42
|
+
sections: globalSections,
|
|
18
43
|
});
|
|
19
44
|
}
|
|
20
45
|
|
|
@@ -54,6 +79,7 @@ export function readClaudeMdStack(claudeDir) {
|
|
|
54
79
|
|
|
55
80
|
return {
|
|
56
81
|
files: stack,
|
|
82
|
+
globalSections,
|
|
57
83
|
totalBytes,
|
|
58
84
|
totalTokensEstimate,
|
|
59
85
|
settingsBytes: settingsSize,
|