cchubber 0.2.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/recommendations.js +97 -76
- package/src/readers/claude-md.js +2 -3
- package/src/readers/jsonl-reader.js +28 -4
- package/src/renderers/html-report.js +1045 -767
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) {
|
|
@@ -1,117 +1,111 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Recommendations Engine
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Each recommendation includes estimated usage % savings.
|
|
4
|
+
* Informed by community data from the March 2026 Claude Code crisis.
|
|
5
5
|
*/
|
|
6
6
|
export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting) {
|
|
7
7
|
const recs = [];
|
|
8
|
+
const totalCost = costAnalysis.totalCost || 1;
|
|
8
9
|
|
|
9
|
-
// 0. Inflection point
|
|
10
|
+
// 0. Inflection point
|
|
10
11
|
if (inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2) {
|
|
11
12
|
recs.push({
|
|
12
13
|
severity: 'critical',
|
|
13
14
|
title: `Cache efficiency dropped ${inflection.multiplier}x on ${inflection.date}`,
|
|
14
|
-
|
|
15
|
-
action: 'Run: claude update.
|
|
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.',
|
|
16
17
|
});
|
|
17
18
|
} else if (inflection && inflection.direction === 'improved' && inflection.multiplier >= 2) {
|
|
18
19
|
recs.push({
|
|
19
20
|
severity: 'positive',
|
|
20
21
|
title: `Efficiency improved ${inflection.multiplier}x on ${inflection.date}`,
|
|
21
|
-
|
|
22
|
-
action: 'Your cache efficiency improved
|
|
22
|
+
savings: 'Already saving',
|
|
23
|
+
action: 'Your cache efficiency improved. Likely a version update or workflow change.',
|
|
23
24
|
});
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
// 1.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
title: `CLAUDE.md is ${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K tokens`,
|
|
32
|
-
detail: `Re-read on every turn. Community best practice: keep under 200 lines (~4K tokens). Yours costs ~$${dailyCost ? dailyCost.toFixed(2) : '?'}/day at 200 messages. Each cache break re-reads at 12.5x the cached price.`,
|
|
33
|
-
action: 'Move rarely-used rules to project-level files. Use skills/hooks instead of inline instructions. Every 1K tokens removed saves ~$0.50/day.',
|
|
34
|
-
});
|
|
35
|
-
}
|
|
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;
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
if (opusPct > 80) {
|
|
34
|
+
const savingsPct = Math.round(opusPct * 0.4 * 0.8); // 40% of Opus routable, 80% cheaper
|
|
39
35
|
recs.push({
|
|
40
|
-
severity: '
|
|
41
|
-
title:
|
|
42
|
-
|
|
43
|
-
action:
|
|
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.`,
|
|
44
40
|
});
|
|
45
41
|
}
|
|
46
42
|
|
|
47
|
-
//
|
|
48
|
-
if (
|
|
49
|
-
const
|
|
43
|
+
// 2. CLAUDE.md bloat
|
|
44
|
+
if (claudeMdStack.totalTokensEstimate > 8000) {
|
|
45
|
+
const excessK = Math.round((claudeMdStack.totalTokensEstimate - 4000) / 1000);
|
|
50
46
|
recs.push({
|
|
51
|
-
severity:
|
|
52
|
-
title:
|
|
53
|
-
|
|
54
|
-
action:
|
|
55
|
-
? 'Reduce MCP server connections. Each tool schema change breaks the cache prefix. Disconnect tools you\'re not actively using.'
|
|
56
|
-
: topReason?.reason === 'System prompt changed'
|
|
57
|
-
? 'Stop editing CLAUDE.md mid-session. Batch rule changes between sessions.'
|
|
58
|
-
: 'Review ~/.claude/tmp/cache-break-*.diff for exact invalidation causes.',
|
|
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.',
|
|
59
51
|
});
|
|
60
52
|
}
|
|
61
53
|
|
|
62
|
-
//
|
|
63
|
-
if (
|
|
64
|
-
recs.push({
|
|
65
|
-
severity: 'critical',
|
|
66
|
-
title: `Cache ratio ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — abnormally high`,
|
|
67
|
-
detail: `Healthy range: 300-800:1. You\'re at ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — every output token costs ${cacheHealth.efficiencyRatio.toLocaleString()} cache read tokens. This pattern matches the March 2026 cache bug reported by thousands of users.`,
|
|
68
|
-
action: 'Immediate fix: update to v2.1.90+. If already updated, avoid --resume flag and start fresh sessions per task.',
|
|
69
|
-
});
|
|
70
|
-
} else if (cacheHealth.efficiencyRatio > 1000) {
|
|
54
|
+
// 3. Compaction frequency — community's #1 session management tip
|
|
55
|
+
if (sessionIntel?.available && sessionIntel.avgToolsPerSession > 25) {
|
|
71
56
|
recs.push({
|
|
72
57
|
severity: 'warning',
|
|
73
|
-
title: `
|
|
74
|
-
|
|
75
|
-
action: 'Use /compact every 30-40 tool calls.
|
|
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.',
|
|
76
61
|
});
|
|
77
62
|
}
|
|
78
63
|
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
const totalModelCost = Object.values(modelCosts).reduce((s, c) => s + c, 0);
|
|
82
|
-
const opusCost = Object.entries(modelCosts).filter(([n]) => n.toLowerCase().includes('opus')).reduce((s, [, c]) => s + c, 0);
|
|
83
|
-
const opusPct = totalModelCost > 0 ? Math.round((opusCost / totalModelCost) * 100) : 0;
|
|
84
|
-
|
|
85
|
-
if (opusPct > 85) {
|
|
86
|
-
const savings = modelRouting?.estimatedSavings || Math.round(opusCost * 0.16);
|
|
64
|
+
// 4. Fresh sessions per task
|
|
65
|
+
if (sessionIntel?.available && sessionIntel.longSessionPct > 30) {
|
|
87
66
|
recs.push({
|
|
88
67
|
severity: 'warning',
|
|
89
|
-
title: `${
|
|
90
|
-
|
|
91
|
-
action: `
|
|
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.`,
|
|
92
71
|
});
|
|
93
72
|
}
|
|
94
73
|
|
|
95
|
-
//
|
|
96
|
-
if (
|
|
74
|
+
// 5. Cache ratio warning
|
|
75
|
+
if (cacheHealth.efficiencyRatio > 1500) {
|
|
97
76
|
recs.push({
|
|
98
|
-
severity: '
|
|
99
|
-
title:
|
|
100
|
-
|
|
101
|
-
action: '
|
|
77
|
+
severity: 'critical',
|
|
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.',
|
|
81
|
+
});
|
|
82
|
+
} else if (cacheHealth.efficiencyRatio > 800) {
|
|
83
|
+
recs.push({
|
|
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.',
|
|
102
88
|
});
|
|
103
89
|
}
|
|
104
90
|
|
|
105
|
-
//
|
|
91
|
+
// 6. Peak hour overlap
|
|
106
92
|
if (sessionIntel?.available && sessionIntel.peakOverlapPct > 40) {
|
|
107
93
|
recs.push({
|
|
108
94
|
severity: 'info',
|
|
109
|
-
title: `${sessionIntel.peakOverlapPct}% of
|
|
110
|
-
|
|
111
|
-
action: 'Shift
|
|
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.',
|
|
112
98
|
});
|
|
113
99
|
}
|
|
114
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
|
+
|
|
115
109
|
// 8. Cost anomalies
|
|
116
110
|
if (anomalies.hasAnomalies) {
|
|
117
111
|
const spikes = anomalies.anomalies.filter(a => a.type === 'spike');
|
|
@@ -119,23 +113,50 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
|
|
|
119
113
|
const worst = spikes[0];
|
|
120
114
|
recs.push({
|
|
121
115
|
severity: worst.severity,
|
|
122
|
-
title: `${spikes.length} cost spike${spikes.length > 1 ? 's' : ''} — worst
|
|
123
|
-
|
|
124
|
-
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.',
|
|
125
119
|
});
|
|
126
120
|
}
|
|
127
121
|
}
|
|
128
122
|
|
|
129
|
-
// 9.
|
|
123
|
+
// 9. Avoid --resume on older versions
|
|
124
|
+
if (cacheHealth.efficiencyRatio > 600) {
|
|
125
|
+
recs.push({
|
|
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.',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
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
|
+
});
|
|
140
|
+
|
|
141
|
+
// 11. Disconnect unused MCP tools
|
|
142
|
+
if (sessionIntel?.available && sessionIntel.topTools.some(t => t.name.includes('mcp__'))) {
|
|
143
|
+
recs.push({
|
|
144
|
+
severity: 'info',
|
|
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.',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 12. Cache savings (positive)
|
|
130
152
|
if (cacheHealth.savings?.fromCaching > 100) {
|
|
131
153
|
recs.push({
|
|
132
154
|
severity: 'positive',
|
|
133
|
-
title: `Cache saved
|
|
134
|
-
|
|
135
|
-
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.',
|
|
136
158
|
});
|
|
137
159
|
}
|
|
138
160
|
|
|
139
|
-
|
|
140
|
-
return recs.slice(0, 5);
|
|
161
|
+
return recs.slice(0, 8);
|
|
141
162
|
}
|
package/src/readers/claude-md.js
CHANGED
|
@@ -28,10 +28,9 @@ export function readClaudeMdStack(claudeDir) {
|
|
|
28
28
|
}
|
|
29
29
|
if (currentSection.lines > 0) sections.push(currentSection);
|
|
30
30
|
|
|
31
|
-
// Add token estimates
|
|
31
|
+
// Add token estimates, keep original file order (don't sort)
|
|
32
32
|
globalSections = sections
|
|
33
|
-
.map(s => ({ ...s, tokens: Math.round(s.bytes / 4) }))
|
|
34
|
-
.sort((a, b) => b.bytes - a.bytes);
|
|
33
|
+
.map((s, idx) => ({ ...s, tokens: Math.round(s.bytes / 4), order: idx }));
|
|
35
34
|
|
|
36
35
|
stack.push({
|
|
37
36
|
level: 'global',
|
|
@@ -36,20 +36,37 @@ function readProjectsDir(dir, entries) {
|
|
|
36
36
|
for (const hash of projectHashes) {
|
|
37
37
|
const projectDir = join(dir, hash);
|
|
38
38
|
|
|
39
|
-
// Read top-level JSONL files
|
|
40
|
-
// Subagent files in <session>/subagents/ are NOT read for cost —
|
|
41
|
-
// parent session JSONL already includes subagent token billing.
|
|
42
|
-
// Reading both would double-count (confirmed: $5.7K → $10.8K).
|
|
39
|
+
// Read top-level JSONL files (one per session)
|
|
43
40
|
const jsonlFiles = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
44
41
|
for (const file of jsonlFiles) {
|
|
45
42
|
readJsonlFile(join(projectDir, file), basename(file, '.jsonl'), hash, entries);
|
|
46
43
|
}
|
|
44
|
+
|
|
45
|
+
// Read subagent JSONL files (for Haiku/Sonnet model attribution)
|
|
46
|
+
// Dedup by message ID prevents double-counting
|
|
47
|
+
const subdirs = readdirSync(projectDir).filter(f => {
|
|
48
|
+
try { return statSync(join(projectDir, f)).isDirectory(); } catch { return false; }
|
|
49
|
+
});
|
|
50
|
+
for (const subdir of subdirs) {
|
|
51
|
+
const subagentDir = join(projectDir, subdir, 'subagents');
|
|
52
|
+
if (existsSync(subagentDir)) {
|
|
53
|
+
try {
|
|
54
|
+
const subFiles = readdirSync(subagentDir).filter(f => f.endsWith('.jsonl'));
|
|
55
|
+
for (const file of subFiles) {
|
|
56
|
+
readJsonlFile(join(subagentDir, file), basename(file, '.jsonl'), hash, entries);
|
|
57
|
+
}
|
|
58
|
+
} catch { /* skip */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
47
61
|
}
|
|
48
62
|
} catch {
|
|
49
63
|
// Directory read failed
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
66
|
|
|
67
|
+
// Track seen message IDs to deduplicate (JSONL files contain dupes from session resume)
|
|
68
|
+
const seenMessageIds = new Set();
|
|
69
|
+
|
|
53
70
|
function readJsonlFile(filePath, sessionId, projectHash, entries) {
|
|
54
71
|
try {
|
|
55
72
|
const raw = readFileSync(filePath, 'utf-8');
|
|
@@ -65,6 +82,13 @@ function readJsonlFile(filePath, sessionId, projectHash, entries) {
|
|
|
65
82
|
const usage = record.message?.usage;
|
|
66
83
|
if (!usage) continue;
|
|
67
84
|
|
|
85
|
+
// Deduplicate by message ID — JSONL files contain duplicates from session resume
|
|
86
|
+
const msgId = record.message?.id;
|
|
87
|
+
if (msgId) {
|
|
88
|
+
if (seenMessageIds.has(msgId)) continue;
|
|
89
|
+
seenMessageIds.add(msgId);
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
entries.push({
|
|
69
93
|
sessionId,
|
|
70
94
|
projectHash,
|