chekk 0.5.4 → 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.
@@ -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
- }