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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchubber",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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": {
@@ -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 breaks (rough estimate)
61
- // Each cache break forces a full re-read at write price ($6.25/M) instead of read price ($0.50/M)
62
- // Estimate ~200K tokens re-cached per break
63
- const wastedFromBreaks = totalBreaks * 200_000 / 1_000_000 * (6.25 - 0.50);
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 sharpest change in cache efficiency ratio over time.
4
- * Outputs: "Your efficiency dropped 3.6x starting March 29. Before: 482:1. After: 1,726:1."
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 bestSplit = null;
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
- // Score = magnitude of change (either direction)
31
- const changeMultiplier = afterRatio > beforeRatio
32
- ? afterRatio / beforeRatio
33
- : beforeRatio / afterRatio;
34
-
35
- if (changeMultiplier > bestScore && changeMultiplier >= 1.5) {
36
- bestScore = changeMultiplier;
37
- bestSplit = {
38
- date: sorted[i].date,
39
- beforeRatio,
40
- afterRatio,
41
- multiplier: Math.round(changeMultiplier * 10) / 10,
42
- direction: afterRatio > beforeRatio ? 'worsened' : 'improved',
43
- beforeDays: before.length,
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
- if (!bestSplit) return null;
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
- // Build human-readable summary
52
- const dirLabel = bestSplit.direction === 'worsened' ? 'dropped' : 'improved';
53
- bestSplit.summary = `Your cache efficiency ${dirLabel} ${bestSplit.multiplier}x starting ${formatDate(bestSplit.date)}. Before: ${bestSplit.beforeRatio.toLocaleString()}:1. After: ${bestSplit.afterRatio.toLocaleString()}:1.`;
51
+ primary.secondary = worstDegradation ? bestImprovement : null;
52
+ return primary;
53
+ }
54
54
 
55
- return bestSplit;
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
- export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection) {
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 — most important signal, goes first
10
+ // 0. Inflection point
5
11
  if (inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2) {
6
12
  recs.push({
7
13
  severity: 'critical',
8
- title: `Efficiency dropped ${inflection.multiplier}x on ${inflection.date}`,
9
- detail: inflection.summary,
10
- action: 'This date likely correlates with a Claude Code update or cache regression. Check your CC version history. v2.1.89 had a known cache bug v2.1.90 includes a fix.',
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
- detail: inflection.summary,
17
- action: 'Something changed for the better on this date. Likely a version update or workflow change.',
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. CLAUDE.md size
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: 'Large CLAUDE.md stack',
26
- detail: `Your CLAUDE.md files total ~${claudeMdStack.totalTokensEstimate.toLocaleString()} tokens (${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB). This is re-read on every message. At 200 messages/day, this costs ~$${claudeMdStack.costPerMessage.dailyCached200.toFixed(2)}/day cached, or $${claudeMdStack.costPerMessage.dailyUncached200.toFixed(2)}/day if cache breaks.`,
27
- action: 'Review your CLAUDE.md for sections that could be moved to project-level files loaded on demand.',
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
- // 2. Cache break frequency
32
- if (cacheHealth.totalCacheBreaks > 10) {
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: cacheHealth.totalCacheBreaks > 50 ? 'critical' : 'warning',
36
- title: `${cacheHealth.totalCacheBreaks} cache breaks detected`,
37
- detail: `Each cache break forces a full context re-read at write prices (12.5x cache read cost). Top cause: "${topReason?.reason}" (${topReason?.count} times, ${topReason?.percentage}%).`,
38
- action: topReason?.reason === 'Tool schemas changed'
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
- // 3. High cache:output ratio
49
- if (cacheHealth.efficiencyRatio > 2000) {
74
+ // 5. Cache ratio warning
75
+ if (cacheHealth.efficiencyRatio > 1500) {
50
76
  recs.push({
51
77
  severity: 'critical',
52
- title: `Cache efficiency ratio: ${cacheHealth.efficiencyRatio.toLocaleString()}:1`,
53
- detail: `For every 1 token of output, ${cacheHealth.efficiencyRatio.toLocaleString()} tokens are read from cache. Healthy range is 300-800:1. This could indicate the known Claude Code cache bug (March 2026).`,
54
- action: 'Check your Claude Code version. Versions around 2.1.85-2.1.90 have known cache regression bugs. Consider pinning to an earlier version.',
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 > 1000) {
82
+ } else if (cacheHealth.efficiencyRatio > 800) {
57
83
  recs.push({
58
- severity: 'warning',
59
- title: `Elevated cache ratio: ${cacheHealth.efficiencyRatio.toLocaleString()}:1`,
60
- detail: 'Above average but not critical. Could be large codebase exploration or heavy file reading.',
61
- action: 'Use /compact more frequently in long sessions. Start fresh sessions for new tasks.',
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
- // 4. Cost anomalies
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' : ''} detected`,
73
- detail: `Worst: $${worst.cost.toFixed(2)} on ${worst.date} (${worst.zScore > 0 ? '+' : ''}${worst.deviation.toFixed(2)} from average of $${worst.avgCost.toFixed(2)}).${worst.cacheRatioAnomaly ? ' Cache ratio was also anomalous — likely cache bug impact.' : ''}`,
74
- action: 'Compare session activity on spike days. Look for long sessions without /compact, or sessions where many MCP tools were connected.',
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
- // 5. Cost trend
80
- if (anomalies.trend === 'rising_fast') {
123
+ // 9. Avoid --resume on older versions
124
+ if (cacheHealth.efficiencyRatio > 600) {
81
125
  recs.push({
82
- severity: 'critical',
83
- title: 'Costs rising rapidly',
84
- detail: 'Your recent 7-day average is significantly higher than your historical average.',
85
- action: 'This may be related to the March 2026 Claude Code cache bug. Check Anthropic status for updates.',
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
- // 6. Opus dominance
90
- const modelCosts = costAnalysis.modelCosts || {};
91
- const totalModelCost = Object.values(modelCosts).reduce((s, c) => s + c, 0);
92
- const opusCost = Object.entries(modelCosts)
93
- .filter(([name]) => name.includes('opus'))
94
- .reduce((s, [, c]) => s + c, 0);
95
- const opusPercentage = totalModelCost > 0 ? (opusCost / totalModelCost) * 100 : 0;
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
- if (opusPercentage > 90) {
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: `${Math.round(opusPercentage)}% of costs from Opus`,
101
- detail: 'Opus is the most expensive model. Subagents and simple tasks could use Sonnet or Haiku.',
102
- action: 'Set model: "sonnet" or "haiku" on Task tool calls for search, documentation lookup, and log analysis.',
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
- // 8. Caching savings acknowledgment
118
- if (cacheHealth.savings.fromCaching > 100) {
151
+ // 12. Cache savings (positive)
152
+ if (cacheHealth.savings?.fromCaching > 100) {
119
153
  recs.push({
120
154
  severity: 'positive',
121
- title: `Caching saved you ~$${cacheHealth.savings.fromCaching.toLocaleString()}`,
122
- detail: 'Without prompt caching, your bill would be significantly higher. The cache system is working — the question is whether it breaks too often.',
123
- action: 'No action needed. Keep sessions alive to maximize cache hits.',
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 recommendations = generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection);
119
-
120
- if (inflection) {
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, // Default view in HTML
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,
@@ -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,