chekk 0.5.5 → 1.0.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/dist/index.d.ts +17 -0
- package/dist/index.js +448 -0
- package/package.json +18 -34
- package/bin/chekk.js +0 -62
- package/src/detect.js +0 -146
- package/src/display.js +0 -1153
- package/src/index.js +0 -301
- package/src/insights.js +0 -661
- package/src/metrics/ai-leverage.js +0 -186
- package/src/metrics/debug-cycles.js +0 -204
- package/src/metrics/decomposition.js +0 -158
- package/src/metrics/session-structure.js +0 -199
- package/src/metrics/token-efficiency.js +0 -258
- package/src/parsers/claude-code.js +0 -231
- package/src/parsers/codex.js +0 -188
- package/src/parsers/cursor.js +0 -281
- package/src/scorer.js +0 -228
- package/src/upload.js +0 -140
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session Structure / Workflow Quality
|
|
3
|
-
*
|
|
4
|
-
* Measures how deliberate and structured the engineer's workflow is.
|
|
5
|
-
*
|
|
6
|
-
* Signals:
|
|
7
|
-
* - Do sessions start with context-setting? (explaining what needs to happen)
|
|
8
|
-
* - Do they plan before diving into code?
|
|
9
|
-
* - Is there a review/validation step at the end?
|
|
10
|
-
* - Session duration distribution (very short = throwaway, very long = unfocused)
|
|
11
|
-
* - Modification rate of AI output (shows critical review)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// ── Evidence quality filter ──
|
|
15
|
-
const noisePatterns = /^This session is being continued|^\[?[0-9T:.Z-]{20,}|^\S+@\S+.*[%$#>]|^\s*\$\s|^\s*>\s/;
|
|
16
|
-
function isGoodEvidence(prompt) {
|
|
17
|
-
if (!prompt || prompt.length < 40 || prompt.length > 600) return false;
|
|
18
|
-
if (noisePatterns.test(prompt)) return false;
|
|
19
|
-
const alpha = prompt.replace(/[^a-zA-Z]/g, '').length;
|
|
20
|
-
if (alpha / prompt.length < 0.4) return false;
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const contextSettingPatterns = /^(i('?m| am) (working on|building|trying to|looking at)|we need to|the goal is|here'?s (the|what)|context:|background:|i have a|there'?s a|i want to|let me explain)/i;
|
|
25
|
-
const planningStartPatterns = /^(let'?s (plan|think|figure|start by)|first,? (let'?s|we should)|before we (start|begin|code)|the plan is|step 1|here'?s (my|the) plan)/i;
|
|
26
|
-
const reviewPatterns = /\b(looks good|ship it|deploy|push it|commit|merge|let'?s go|lgtm|approved|test it|run (the )?tests|build it|does this look|review this|check this)\b/i;
|
|
27
|
-
const refinementPatterns = /\b(actually|wait|hmm|instead|change|modify|tweak|adjust|no,? |not quite|close but|almost|that'?s not)\b/i;
|
|
28
|
-
|
|
29
|
-
export function computeSessionStructure(sessions) {
|
|
30
|
-
if (sessions.length === 0) return { score: 50, details: {} };
|
|
31
|
-
|
|
32
|
-
let contextSetSessions = 0;
|
|
33
|
-
let planBeforeCodeSessions = 0;
|
|
34
|
-
let reviewEndSessions = 0;
|
|
35
|
-
let refinementCount = 0;
|
|
36
|
-
let totalExchanges = 0;
|
|
37
|
-
|
|
38
|
-
// Duration distribution
|
|
39
|
-
let shortSessions = 0; // < 5 min
|
|
40
|
-
let mediumSessions = 0; // 5-60 min
|
|
41
|
-
let longSessions = 0; // > 60 min
|
|
42
|
-
let focusedSessions = 0; // 10-45 min (sweet spot)
|
|
43
|
-
|
|
44
|
-
// First prompt length distribution (longer first prompts = more context setting)
|
|
45
|
-
let firstPromptTotalLength = 0;
|
|
46
|
-
|
|
47
|
-
// Capture representative examples
|
|
48
|
-
let bestContextPrompt = null; // best context-setting opener
|
|
49
|
-
let bestRefinementPrompt = null; // best refinement/critical feedback
|
|
50
|
-
let bestContextLen = 0;
|
|
51
|
-
|
|
52
|
-
for (const session of sessions) {
|
|
53
|
-
const { exchanges, durationMinutes } = session;
|
|
54
|
-
if (exchanges.length === 0) continue;
|
|
55
|
-
|
|
56
|
-
totalExchanges += exchanges.length;
|
|
57
|
-
|
|
58
|
-
// Check if session starts with context
|
|
59
|
-
const firstPrompt = exchanges[0].userPrompt || '';
|
|
60
|
-
firstPromptTotalLength += firstPrompt.length;
|
|
61
|
-
|
|
62
|
-
if (contextSettingPatterns.test(firstPrompt) || firstPrompt.length > 200) {
|
|
63
|
-
contextSetSessions++;
|
|
64
|
-
// Track best context-setting prompt
|
|
65
|
-
if (isGoodEvidence(firstPrompt) && firstPrompt.length > bestContextLen) {
|
|
66
|
-
bestContextLen = firstPrompt.length;
|
|
67
|
-
bestContextPrompt = firstPrompt;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (planningStartPatterns.test(firstPrompt)) {
|
|
72
|
-
planBeforeCodeSessions++;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Check if session ends with review/validation
|
|
76
|
-
if (exchanges.length >= 2) {
|
|
77
|
-
const lastPrompt = exchanges[exchanges.length - 1].userPrompt || '';
|
|
78
|
-
const secondLastPrompt = exchanges.length >= 3 ? exchanges[exchanges.length - 2].userPrompt || '' : '';
|
|
79
|
-
if (reviewPatterns.test(lastPrompt) || reviewPatterns.test(secondLastPrompt)) {
|
|
80
|
-
reviewEndSessions++;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Count refinements (shows critical evaluation)
|
|
85
|
-
for (let i = 1; i < exchanges.length; i++) {
|
|
86
|
-
const prompt = exchanges[i].userPrompt || '';
|
|
87
|
-
if (refinementPatterns.test(prompt)) {
|
|
88
|
-
refinementCount++;
|
|
89
|
-
// Track best refinement example
|
|
90
|
-
if (isGoodEvidence(prompt) && (!bestRefinementPrompt || prompt.length > bestRefinementPrompt.length)) {
|
|
91
|
-
bestRefinementPrompt = prompt;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Duration buckets
|
|
97
|
-
if (durationMinutes !== undefined && durationMinutes !== null) {
|
|
98
|
-
if (durationMinutes < 5) shortSessions++;
|
|
99
|
-
else if (durationMinutes <= 60) mediumSessions++;
|
|
100
|
-
else longSessions++;
|
|
101
|
-
|
|
102
|
-
if (durationMinutes >= 10 && durationMinutes <= 45) focusedSessions++;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const sessionsWithExchanges = sessions.filter(s => s.exchanges.length > 0).length;
|
|
107
|
-
|
|
108
|
-
// Score components
|
|
109
|
-
const contextRatio = sessionsWithExchanges > 0 ? contextSetSessions / sessionsWithExchanges : 0;
|
|
110
|
-
const contextScore = Math.min(100, contextRatio * 170);
|
|
111
|
-
|
|
112
|
-
const planRatio = sessionsWithExchanges > 0 ? planBeforeCodeSessions / sessionsWithExchanges : 0;
|
|
113
|
-
const planScore = Math.min(100, planRatio * 300);
|
|
114
|
-
|
|
115
|
-
const reviewRatio = sessionsWithExchanges > 0 ? reviewEndSessions / sessionsWithExchanges : 0;
|
|
116
|
-
const reviewScore = Math.min(100, reviewRatio * 200);
|
|
117
|
-
|
|
118
|
-
// Refinement shows critical thinking
|
|
119
|
-
const refinementRatio = totalExchanges > 0 ? refinementCount / totalExchanges : 0;
|
|
120
|
-
const refinementScore = Math.min(100, refinementRatio * 400);
|
|
121
|
-
|
|
122
|
-
// Duration focus (medium/focused sessions are ideal)
|
|
123
|
-
const totalWithDuration = shortSessions + mediumSessions + longSessions;
|
|
124
|
-
const focusedRatio = totalWithDuration > 0 ? focusedSessions / totalWithDuration : 0.5;
|
|
125
|
-
const focusScore = Math.min(100, focusedRatio * 170);
|
|
126
|
-
|
|
127
|
-
// Average first prompt length (longer = more thoughtful setup)
|
|
128
|
-
const avgFirstPromptLength = sessionsWithExchanges > 0 ? firstPromptTotalLength / sessionsWithExchanges : 0;
|
|
129
|
-
const firstPromptScore = avgFirstPromptLength > 300 ? 90 :
|
|
130
|
-
avgFirstPromptLength > 150 ? 75 :
|
|
131
|
-
avgFirstPromptLength > 50 ? 55 : 35;
|
|
132
|
-
|
|
133
|
-
const score = Math.round(
|
|
134
|
-
contextScore * 0.2 +
|
|
135
|
-
planScore * 0.15 +
|
|
136
|
-
reviewScore * 0.15 +
|
|
137
|
-
refinementScore * 0.2 +
|
|
138
|
-
focusScore * 0.15 +
|
|
139
|
-
firstPromptScore * 0.15
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
// Build examples array
|
|
143
|
-
const examples = [];
|
|
144
|
-
if (bestContextPrompt) examples.push({ type: 'context_setting', prompt: bestContextPrompt });
|
|
145
|
-
if (bestRefinementPrompt) examples.push({ type: 'refinement', prompt: bestRefinementPrompt });
|
|
146
|
-
|
|
147
|
-
// ── Token cost evidence ──
|
|
148
|
-
// Compare token cost of focused sessions vs marathon sessions
|
|
149
|
-
let focusedTokens = 0, focusedTokenCount = 0;
|
|
150
|
-
let marathonTokens = 0, marathonTokenCount = 0;
|
|
151
|
-
let contextSetTokens = 0, contextSetCount = 0;
|
|
152
|
-
let noContextTokens = 0, noContextCount = 0;
|
|
153
|
-
|
|
154
|
-
for (const session of sessions) {
|
|
155
|
-
const t = session.tokenUsage;
|
|
156
|
-
if (!t || (t.inputTokens + t.outputTokens + t.cacheReadTokens + t.cacheCreationTokens) === 0) continue;
|
|
157
|
-
const total = t.inputTokens + t.outputTokens + t.cacheReadTokens + t.cacheCreationTokens;
|
|
158
|
-
const perExchange = session.exchangeCount > 0 ? total / session.exchangeCount : total;
|
|
159
|
-
|
|
160
|
-
// Duration-based
|
|
161
|
-
if (session.durationMinutes >= 10 && session.durationMinutes <= 45) {
|
|
162
|
-
focusedTokens += perExchange; focusedTokenCount++;
|
|
163
|
-
} else if (session.durationMinutes > 60) {
|
|
164
|
-
marathonTokens += perExchange; marathonTokenCount++;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Context-setting vs not
|
|
168
|
-
const firstPrompt = session.exchanges[0]?.userPrompt || '';
|
|
169
|
-
if (contextSettingPatterns.test(firstPrompt) || firstPrompt.length > 200) {
|
|
170
|
-
contextSetTokens += perExchange; contextSetCount++;
|
|
171
|
-
} else if (session.exchanges.length > 0) {
|
|
172
|
-
noContextTokens += perExchange; noContextCount++;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
score: Math.max(0, Math.min(100, score)),
|
|
178
|
-
details: {
|
|
179
|
-
contextSetRatio: Math.round(contextRatio * 100),
|
|
180
|
-
planBeforeCodeRatio: Math.round(planRatio * 100),
|
|
181
|
-
reviewEndRatio: Math.round(reviewRatio * 100),
|
|
182
|
-
refinementRatio: Math.round(refinementRatio * 100),
|
|
183
|
-
avgFirstPromptLength: Math.round(avgFirstPromptLength),
|
|
184
|
-
durationDistribution: {
|
|
185
|
-
short: shortSessions,
|
|
186
|
-
medium: mediumSessions,
|
|
187
|
-
long: longSessions,
|
|
188
|
-
focused: focusedSessions,
|
|
189
|
-
},
|
|
190
|
-
tokenEvidence: {
|
|
191
|
-
avgTokensPerExchangeFocused: focusedTokenCount > 0 ? Math.round(focusedTokens / focusedTokenCount) : null,
|
|
192
|
-
avgTokensPerExchangeMarathon: marathonTokenCount > 0 ? Math.round(marathonTokens / marathonTokenCount) : null,
|
|
193
|
-
avgTokensPerExchangeWithContext: contextSetCount > 0 ? Math.round(contextSetTokens / contextSetCount) : null,
|
|
194
|
-
avgTokensPerExchangeNoContext: noContextCount > 0 ? Math.round(noContextTokens / noContextCount) : null,
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
examples,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token Efficiency Analytics
|
|
3
|
-
*
|
|
4
|
-
* Computes token spend statistics from Claude Code session data.
|
|
5
|
-
* This is NOT a scored dimension — it provides concrete evidence
|
|
6
|
-
* that enriches the existing 4 metrics with cost data.
|
|
7
|
-
*
|
|
8
|
-
* Outputs:
|
|
9
|
-
* - Total token breakdown (input, output, cache read, cache creation)
|
|
10
|
-
* - Estimated cost using Anthropic pricing
|
|
11
|
-
* - Per-project token breakdown
|
|
12
|
-
* - Costliest sessions and prompts
|
|
13
|
-
* - Cache efficiency ratio (how much context is re-read vs new)
|
|
14
|
-
* - Prompt-type cost analysis (vague vs specific, short vs long)
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
// ── Anthropic pricing per million tokens (as of early 2025) ──
|
|
18
|
-
// Claude Code uses a mix of models; we estimate with Sonnet pricing
|
|
19
|
-
// which is the most common model in Claude Code sessions.
|
|
20
|
-
const PRICING = {
|
|
21
|
-
'claude-sonnet-4-5-20250929': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheCreation: 3.75 },
|
|
22
|
-
'claude-opus-4-6': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheCreation: 18.75 },
|
|
23
|
-
'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00, cacheRead: 0.08, cacheCreation: 1.00 },
|
|
24
|
-
// Fallback for unknown models — use Sonnet pricing as default
|
|
25
|
-
default: { input: 3.00, output: 15.00, cacheRead: 0.30, cacheCreation: 3.75 },
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
function getPricing(model) {
|
|
29
|
-
if (!model) return PRICING.default;
|
|
30
|
-
for (const [key, prices] of Object.entries(PRICING)) {
|
|
31
|
-
if (key !== 'default' && model.includes(key.replace(/-\d+$/, ''))) return prices;
|
|
32
|
-
}
|
|
33
|
-
// Try partial match
|
|
34
|
-
if (model.includes('opus')) return PRICING['claude-opus-4-6'];
|
|
35
|
-
if (model.includes('haiku')) return PRICING['claude-haiku-4-5-20251001'];
|
|
36
|
-
if (model.includes('sonnet')) return PRICING['claude-sonnet-4-5-20250929'];
|
|
37
|
-
return PRICING.default;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function estimateCost(tokens, pricing) {
|
|
41
|
-
return (
|
|
42
|
-
(tokens.inputTokens / 1_000_000) * pricing.input +
|
|
43
|
-
(tokens.outputTokens / 1_000_000) * pricing.output +
|
|
44
|
-
(tokens.cacheReadTokens / 1_000_000) * pricing.cacheRead +
|
|
45
|
-
(tokens.cacheCreationTokens / 1_000_000) * pricing.cacheCreation
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function addTokens(target, source) {
|
|
50
|
-
target.inputTokens += source.inputTokens || 0;
|
|
51
|
-
target.outputTokens += source.outputTokens || 0;
|
|
52
|
-
target.cacheReadTokens += source.cacheReadTokens || 0;
|
|
53
|
-
target.cacheCreationTokens += source.cacheCreationTokens || 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function totalTokens(t) {
|
|
57
|
-
return (t.inputTokens || 0) + (t.outputTokens || 0) + (t.cacheReadTokens || 0) + (t.cacheCreationTokens || 0);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Compute comprehensive token efficiency analytics.
|
|
62
|
-
*
|
|
63
|
-
* @param {Array} sessions - Parsed sessions with tokenUsage on each exchange
|
|
64
|
-
* @returns {Object} Token analytics data (not a score)
|
|
65
|
-
*/
|
|
66
|
-
export function computeTokenEfficiency(sessions) {
|
|
67
|
-
if (sessions.length === 0) {
|
|
68
|
-
return { hasData: false };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Check if any session has token data (only Claude Code currently provides this)
|
|
72
|
-
const sessionsWithTokens = sessions.filter(s =>
|
|
73
|
-
s.tokenUsage && totalTokens(s.tokenUsage) > 0
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
if (sessionsWithTokens.length === 0) {
|
|
77
|
-
return { hasData: false };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── Aggregate totals ──
|
|
81
|
-
const totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
82
|
-
for (const s of sessionsWithTokens) {
|
|
83
|
-
addTokens(totals, s.tokenUsage);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const grandTotal = totalTokens(totals);
|
|
87
|
-
|
|
88
|
-
// ── Cost estimation ──
|
|
89
|
-
// For now use default pricing; could be refined per-message if model data is on exchanges
|
|
90
|
-
const pricing = PRICING.default;
|
|
91
|
-
const estimatedCostTotal = estimateCost(totals, pricing);
|
|
92
|
-
|
|
93
|
-
// ── Token composition ──
|
|
94
|
-
const composition = {
|
|
95
|
-
inputPct: grandTotal > 0 ? (totals.inputTokens / grandTotal * 100) : 0,
|
|
96
|
-
outputPct: grandTotal > 0 ? (totals.outputTokens / grandTotal * 100) : 0,
|
|
97
|
-
cacheReadPct: grandTotal > 0 ? (totals.cacheReadTokens / grandTotal * 100) : 0,
|
|
98
|
-
cacheCreationPct: grandTotal > 0 ? (totals.cacheCreationTokens / grandTotal * 100) : 0,
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
// The "context re-reading" ratio: cache_read / (cache_read + output)
|
|
102
|
-
// This shows how much of Claude's work is re-reading vs producing new output
|
|
103
|
-
const contextRereadRatio = (totals.cacheReadTokens + totals.outputTokens) > 0
|
|
104
|
-
? totals.cacheReadTokens / (totals.cacheReadTokens + totals.outputTokens)
|
|
105
|
-
: 0;
|
|
106
|
-
|
|
107
|
-
// ── Per-project breakdown ──
|
|
108
|
-
const projectTokens = {};
|
|
109
|
-
for (const s of sessionsWithTokens) {
|
|
110
|
-
const p = s.project || 'unknown';
|
|
111
|
-
if (!projectTokens[p]) {
|
|
112
|
-
projectTokens[p] = {
|
|
113
|
-
tokens: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 },
|
|
114
|
-
sessions: 0,
|
|
115
|
-
exchanges: 0,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
addTokens(projectTokens[p].tokens, s.tokenUsage);
|
|
119
|
-
projectTokens[p].sessions++;
|
|
120
|
-
projectTokens[p].exchanges += s.exchangeCount;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const perProject = Object.entries(projectTokens)
|
|
124
|
-
.map(([name, data]) => ({
|
|
125
|
-
name: name.length > 30 ? '...' + name.slice(-27) : name,
|
|
126
|
-
fullName: name,
|
|
127
|
-
totalTokens: totalTokens(data.tokens),
|
|
128
|
-
estimatedCost: estimateCost(data.tokens, pricing),
|
|
129
|
-
sessions: data.sessions,
|
|
130
|
-
exchanges: data.exchanges,
|
|
131
|
-
tokensPerExchange: data.exchanges > 0 ? Math.round(totalTokens(data.tokens) / data.exchanges) : 0,
|
|
132
|
-
...data.tokens,
|
|
133
|
-
}))
|
|
134
|
-
.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
135
|
-
|
|
136
|
-
// ── Costliest sessions ──
|
|
137
|
-
const costliestSessions = sessionsWithTokens
|
|
138
|
-
.map(s => ({
|
|
139
|
-
id: s.id,
|
|
140
|
-
project: s.project || 'unknown',
|
|
141
|
-
totalTokens: totalTokens(s.tokenUsage),
|
|
142
|
-
estimatedCost: estimateCost(s.tokenUsage, pricing),
|
|
143
|
-
exchanges: s.exchangeCount,
|
|
144
|
-
durationMinutes: s.durationMinutes,
|
|
145
|
-
cacheReadRatio: totalTokens(s.tokenUsage) > 0
|
|
146
|
-
? s.tokenUsage.cacheReadTokens / totalTokens(s.tokenUsage)
|
|
147
|
-
: 0,
|
|
148
|
-
firstPrompt: s.exchanges[0]?.userPrompt?.slice(0, 80) || '',
|
|
149
|
-
}))
|
|
150
|
-
.sort((a, b) => b.totalTokens - a.totalTokens)
|
|
151
|
-
.slice(0, 5);
|
|
152
|
-
|
|
153
|
-
// ── Costliest exchanges (individual prompts) ──
|
|
154
|
-
const allExchanges = [];
|
|
155
|
-
for (const s of sessionsWithTokens) {
|
|
156
|
-
for (let i = 0; i < s.exchanges.length; i++) {
|
|
157
|
-
const ex = s.exchanges[i];
|
|
158
|
-
if (!ex.tokenUsage || totalTokens(ex.tokenUsage) === 0) continue;
|
|
159
|
-
allExchanges.push({
|
|
160
|
-
prompt: ex.userPrompt || '',
|
|
161
|
-
totalTokens: totalTokens(ex.tokenUsage),
|
|
162
|
-
estimatedCost: estimateCost(ex.tokenUsage, pricing),
|
|
163
|
-
cacheReadTokens: ex.tokenUsage.cacheReadTokens,
|
|
164
|
-
outputTokens: ex.tokenUsage.outputTokens,
|
|
165
|
-
sessionId: s.id,
|
|
166
|
-
project: s.project || 'unknown',
|
|
167
|
-
exchangeIndex: i,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
allExchanges.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
173
|
-
const costliestExchanges = allExchanges.slice(0, 5);
|
|
174
|
-
|
|
175
|
-
// ── Prompt length vs token cost correlation ──
|
|
176
|
-
// Group exchanges by prompt length buckets and compute avg token cost
|
|
177
|
-
const buckets = {
|
|
178
|
-
veryShort: { label: '< 20 chars', prompts: 0, totalTokens: 0, totalCost: 0 },
|
|
179
|
-
short: { label: '20-100 chars', prompts: 0, totalTokens: 0, totalCost: 0 },
|
|
180
|
-
medium: { label: '100-500 chars', prompts: 0, totalTokens: 0, totalCost: 0 },
|
|
181
|
-
long: { label: '500+ chars', prompts: 0, totalTokens: 0, totalCost: 0 },
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
for (const ex of allExchanges) {
|
|
185
|
-
const len = ex.prompt.length;
|
|
186
|
-
let bucket;
|
|
187
|
-
if (len < 20) bucket = buckets.veryShort;
|
|
188
|
-
else if (len < 100) bucket = buckets.short;
|
|
189
|
-
else if (len < 500) bucket = buckets.medium;
|
|
190
|
-
else bucket = buckets.long;
|
|
191
|
-
|
|
192
|
-
bucket.prompts++;
|
|
193
|
-
bucket.totalTokens += ex.totalTokens;
|
|
194
|
-
bucket.totalCost += ex.estimatedCost;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const promptLengthAnalysis = Object.values(buckets)
|
|
198
|
-
.filter(b => b.prompts > 0)
|
|
199
|
-
.map(b => ({
|
|
200
|
-
...b,
|
|
201
|
-
avgTokens: Math.round(b.totalTokens / b.prompts),
|
|
202
|
-
avgCost: b.totalCost / b.prompts,
|
|
203
|
-
}));
|
|
204
|
-
|
|
205
|
-
// ── Session length vs token efficiency ──
|
|
206
|
-
// Marathon sessions compound context, so later exchanges cost more
|
|
207
|
-
const sessionLengthAnalysis = {
|
|
208
|
-
short: { label: '1-5 exchanges', sessions: 0, avgTokensPerExchange: 0, totalTokens: 0, totalExchanges: 0 },
|
|
209
|
-
medium: { label: '6-20 exchanges', sessions: 0, avgTokensPerExchange: 0, totalTokens: 0, totalExchanges: 0 },
|
|
210
|
-
long: { label: '21-50 exchanges', sessions: 0, avgTokensPerExchange: 0, totalTokens: 0, totalExchanges: 0 },
|
|
211
|
-
marathon: { label: '50+ exchanges', sessions: 0, avgTokensPerExchange: 0, totalTokens: 0, totalExchanges: 0 },
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
for (const s of sessionsWithTokens) {
|
|
215
|
-
const ec = s.exchangeCount;
|
|
216
|
-
const t = totalTokens(s.tokenUsage);
|
|
217
|
-
let bucket;
|
|
218
|
-
if (ec <= 5) bucket = sessionLengthAnalysis.short;
|
|
219
|
-
else if (ec <= 20) bucket = sessionLengthAnalysis.medium;
|
|
220
|
-
else if (ec <= 50) bucket = sessionLengthAnalysis.long;
|
|
221
|
-
else bucket = sessionLengthAnalysis.marathon;
|
|
222
|
-
|
|
223
|
-
bucket.sessions++;
|
|
224
|
-
bucket.totalTokens += t;
|
|
225
|
-
bucket.totalExchanges += ec;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
for (const bucket of Object.values(sessionLengthAnalysis)) {
|
|
229
|
-
bucket.avgTokensPerExchange = bucket.totalExchanges > 0
|
|
230
|
-
? Math.round(bucket.totalTokens / bucket.totalExchanges)
|
|
231
|
-
: 0;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ── Top-level stats ──
|
|
235
|
-
const avgTokensPerSession = sessionsWithTokens.length > 0
|
|
236
|
-
? Math.round(grandTotal / sessionsWithTokens.length)
|
|
237
|
-
: 0;
|
|
238
|
-
const avgTokensPerExchange = allExchanges.length > 0
|
|
239
|
-
? Math.round(grandTotal / allExchanges.length)
|
|
240
|
-
: 0;
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
hasData: true,
|
|
244
|
-
sessionsAnalyzed: sessionsWithTokens.length,
|
|
245
|
-
totals,
|
|
246
|
-
grandTotal,
|
|
247
|
-
estimatedCostTotal,
|
|
248
|
-
composition,
|
|
249
|
-
contextRereadRatio,
|
|
250
|
-
avgTokensPerSession,
|
|
251
|
-
avgTokensPerExchange,
|
|
252
|
-
perProject,
|
|
253
|
-
costliestSessions,
|
|
254
|
-
costliestExchanges,
|
|
255
|
-
promptLengthAnalysis,
|
|
256
|
-
sessionLengthAnalysis: Object.values(sessionLengthAnalysis).filter(b => b.sessions > 0),
|
|
257
|
-
};
|
|
258
|
-
}
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Parse Claude Code JSONL session files into normalized format.
|
|
6
|
-
*
|
|
7
|
-
* Each JSONL line is one of:
|
|
8
|
-
* - type: "user" → human prompt
|
|
9
|
-
* - type: "assistant" → AI response (may contain text, thinking, tool_use)
|
|
10
|
-
* - type: "summary" → session summary (skip)
|
|
11
|
-
* - type: "file-history-snapshot" → file state (skip)
|
|
12
|
-
*
|
|
13
|
-
* We normalize into sessions, each with turns.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
function parseJsonlLine(line) {
|
|
17
|
-
try {
|
|
18
|
-
return JSON.parse(line);
|
|
19
|
-
} catch {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function extractToolCalls(content) {
|
|
25
|
-
if (!Array.isArray(content)) return [];
|
|
26
|
-
return content
|
|
27
|
-
.filter(block => block.type === 'tool_use')
|
|
28
|
-
.map(block => ({
|
|
29
|
-
tool: block.name || block.input?.description || 'unknown',
|
|
30
|
-
input: block.input || {},
|
|
31
|
-
}));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function extractTextContent(content) {
|
|
35
|
-
if (typeof content === 'string') return content;
|
|
36
|
-
if (!Array.isArray(content)) return '';
|
|
37
|
-
return content
|
|
38
|
-
.filter(block => block.type === 'text')
|
|
39
|
-
.map(block => block.text || '')
|
|
40
|
-
.join('\n');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function extractThinking(content) {
|
|
44
|
-
if (!Array.isArray(content)) return '';
|
|
45
|
-
return content
|
|
46
|
-
.filter(block => block.type === 'thinking')
|
|
47
|
-
.map(block => block.thinking || '')
|
|
48
|
-
.join('\n');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function hasToolResults(content) {
|
|
52
|
-
if (!Array.isArray(content)) return false;
|
|
53
|
-
return content.some(block => block.type === 'tool_result');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Parse a single JSONL file into a list of turns.
|
|
58
|
-
*/
|
|
59
|
-
function parseSessionFile(filePath) {
|
|
60
|
-
let raw;
|
|
61
|
-
try {
|
|
62
|
-
raw = readFileSync(filePath, 'utf-8');
|
|
63
|
-
} catch {
|
|
64
|
-
return [];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const lines = raw.split('\n').filter(l => l.trim());
|
|
68
|
-
const turns = [];
|
|
69
|
-
|
|
70
|
-
for (const line of lines) {
|
|
71
|
-
const entry = parseJsonlLine(line);
|
|
72
|
-
if (!entry) continue;
|
|
73
|
-
|
|
74
|
-
// Skip non-message types
|
|
75
|
-
if (entry.type === 'summary' || entry.type === 'file-history-snapshot') continue;
|
|
76
|
-
if (!entry.message) continue;
|
|
77
|
-
|
|
78
|
-
const role = entry.message.role || entry.type;
|
|
79
|
-
if (role !== 'user' && role !== 'assistant') continue;
|
|
80
|
-
|
|
81
|
-
// Skip tool result messages (these are system-injected responses to tool calls)
|
|
82
|
-
if (role === 'user' && hasToolResults(entry.message.content)) continue;
|
|
83
|
-
|
|
84
|
-
// Extract token usage from assistant messages
|
|
85
|
-
const usage = (role === 'assistant' && entry.message.usage) ? {
|
|
86
|
-
inputTokens: entry.message.usage.input_tokens || 0,
|
|
87
|
-
outputTokens: entry.message.usage.output_tokens || 0,
|
|
88
|
-
cacheReadTokens: entry.message.usage.cache_read_input_tokens || 0,
|
|
89
|
-
cacheCreationTokens: entry.message.usage.cache_creation_input_tokens || 0,
|
|
90
|
-
} : null;
|
|
91
|
-
|
|
92
|
-
const turn = {
|
|
93
|
-
role,
|
|
94
|
-
text: extractTextContent(entry.message.content),
|
|
95
|
-
thinking: role === 'assistant' ? extractThinking(entry.message.content) : '',
|
|
96
|
-
toolCalls: role === 'assistant' ? extractToolCalls(entry.message.content) : [],
|
|
97
|
-
timestamp: entry.timestamp || null,
|
|
98
|
-
uuid: entry.uuid || null,
|
|
99
|
-
parentUuid: entry.parentUuid || null,
|
|
100
|
-
model: entry.message.model || null,
|
|
101
|
-
usage,
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
// Skip empty assistant messages that are just tool call continuations
|
|
105
|
-
if (role === 'assistant' && !turn.text && turn.toolCalls.length === 0 && !turn.thinking) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
turns.push(turn);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return turns;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Group consecutive turns into logical conversation exchanges.
|
|
117
|
-
* A "exchange" is one user prompt followed by all assistant responses until the next user prompt.
|
|
118
|
-
*/
|
|
119
|
-
function groupIntoExchanges(turns) {
|
|
120
|
-
const exchanges = [];
|
|
121
|
-
let current = null;
|
|
122
|
-
|
|
123
|
-
for (const turn of turns) {
|
|
124
|
-
if (turn.role === 'user') {
|
|
125
|
-
if (current) exchanges.push(current);
|
|
126
|
-
current = {
|
|
127
|
-
userPrompt: turn.text,
|
|
128
|
-
userTimestamp: turn.timestamp,
|
|
129
|
-
assistantResponses: [],
|
|
130
|
-
toolCalls: [],
|
|
131
|
-
thinkingContent: [],
|
|
132
|
-
tokenUsage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 },
|
|
133
|
-
};
|
|
134
|
-
} else if (turn.role === 'assistant' && current) {
|
|
135
|
-
if (turn.text) current.assistantResponses.push(turn.text);
|
|
136
|
-
if (turn.thinking) current.thinkingContent.push(turn.thinking);
|
|
137
|
-
current.toolCalls.push(...turn.toolCalls);
|
|
138
|
-
// Accumulate token usage across all assistant turns in this exchange
|
|
139
|
-
if (turn.usage) {
|
|
140
|
-
current.tokenUsage.inputTokens += turn.usage.inputTokens;
|
|
141
|
-
current.tokenUsage.outputTokens += turn.usage.outputTokens;
|
|
142
|
-
current.tokenUsage.cacheReadTokens += turn.usage.cacheReadTokens;
|
|
143
|
-
current.tokenUsage.cacheCreationTokens += turn.usage.cacheCreationTokens;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (current) exchanges.push(current);
|
|
149
|
-
return exchanges;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Parse all Claude Code sessions from a project directory.
|
|
154
|
-
*/
|
|
155
|
-
export function parseProject(projectPath) {
|
|
156
|
-
const files = readdirSync(projectPath).filter(f =>
|
|
157
|
-
f.endsWith('.jsonl') && !f.startsWith('agent-')
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
const sessions = [];
|
|
161
|
-
|
|
162
|
-
for (const file of files) {
|
|
163
|
-
const filePath = join(projectPath, file);
|
|
164
|
-
const stat = statSync(filePath);
|
|
165
|
-
|
|
166
|
-
const turns = parseSessionFile(filePath);
|
|
167
|
-
if (turns.length === 0) continue;
|
|
168
|
-
|
|
169
|
-
const exchanges = groupIntoExchanges(turns);
|
|
170
|
-
if (exchanges.length === 0) continue;
|
|
171
|
-
|
|
172
|
-
// Get time range
|
|
173
|
-
const timestamps = turns
|
|
174
|
-
.map(t => t.timestamp)
|
|
175
|
-
.filter(Boolean)
|
|
176
|
-
.map(t => new Date(t).getTime())
|
|
177
|
-
.sort();
|
|
178
|
-
|
|
179
|
-
// Aggregate token usage across all exchanges in this session
|
|
180
|
-
const sessionTokens = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
181
|
-
for (const ex of exchanges) {
|
|
182
|
-
sessionTokens.inputTokens += ex.tokenUsage.inputTokens;
|
|
183
|
-
sessionTokens.outputTokens += ex.tokenUsage.outputTokens;
|
|
184
|
-
sessionTokens.cacheReadTokens += ex.tokenUsage.cacheReadTokens;
|
|
185
|
-
sessionTokens.cacheCreationTokens += ex.tokenUsage.cacheCreationTokens;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
sessions.push({
|
|
189
|
-
id: file.replace('.jsonl', ''),
|
|
190
|
-
file,
|
|
191
|
-
exchanges,
|
|
192
|
-
turnCount: turns.length,
|
|
193
|
-
exchangeCount: exchanges.length,
|
|
194
|
-
startTime: timestamps[0] ? new Date(timestamps[0]).toISOString() : null,
|
|
195
|
-
endTime: timestamps[timestamps.length - 1] ? new Date(timestamps[timestamps.length - 1]).toISOString() : null,
|
|
196
|
-
durationMinutes: timestamps.length >= 2
|
|
197
|
-
? Math.round((timestamps[timestamps.length - 1] - timestamps[0]) / 60000)
|
|
198
|
-
: 0,
|
|
199
|
-
tokenUsage: sessionTokens,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return sessions;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Parse all Claude Code projects.
|
|
208
|
-
*/
|
|
209
|
-
export function parseAllProjects(basePath) {
|
|
210
|
-
const projects = readdirSync(basePath).filter(f => {
|
|
211
|
-
try {
|
|
212
|
-
return statSync(join(basePath, f)).isDirectory();
|
|
213
|
-
} catch {
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
const allSessions = [];
|
|
219
|
-
|
|
220
|
-
for (const project of projects) {
|
|
221
|
-
const projectPath = join(basePath, project);
|
|
222
|
-
const sessions = parseProject(projectPath);
|
|
223
|
-
|
|
224
|
-
for (const session of sessions) {
|
|
225
|
-
session.project = project.replace(/-/g, '/').replace(/^\//, '');
|
|
226
|
-
allSessions.push(session);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return allSessions;
|
|
231
|
-
}
|