claude-cli-analytics 0.0.1
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/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/cli.js +44 -0
- package/dist/client/assets/index-BkOIudNK.css +1 -0
- package/dist/client/assets/index-CXwfzzf8.js +48 -0
- package/dist/client/index.html +14 -0
- package/dist/client/vite.svg +1 -0
- package/dist/server/analyzer.js +711 -0
- package/dist/server/config.js +120 -0
- package/dist/server/index.js +151 -0
- package/dist/server/parser.js +48 -0
- package/dist/server/types.js +2 -0
- package/package.json +77 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
// ── Session analysis, scoring, and aggregation ──
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { parseJsonlFile, getProjectsList } from './parser.js';
|
|
5
|
+
// ── Helper: compute efficiency score ──
|
|
6
|
+
function computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit) {
|
|
7
|
+
let score = 0;
|
|
8
|
+
// 1. Cache efficiency (30 pts)
|
|
9
|
+
score += cacheHitRate >= 95 ? 30 : cacheHitRate >= 90 ? 25 : cacheHitRate >= 80 ? 20 : cacheHitRate >= 60 ? 10 : 0;
|
|
10
|
+
// 2. Tool reliability (20 pts)
|
|
11
|
+
score += toolErrorRate <= 2 ? 20 : toolErrorRate <= 5 ? 15 : toolErrorRate <= 10 ? 10 : 5;
|
|
12
|
+
// 3. Work efficiency (25 pts)
|
|
13
|
+
score += (duplicateReadRate < 10 ? 10 : duplicateReadRate < 20 ? 5 : 0)
|
|
14
|
+
+ (readEditRatio >= 5 && readEditRatio <= 20 ? 10 : readEditRatio >= 3 ? 5 : 0)
|
|
15
|
+
+ (repeatedEditRate < 10 ? 5 : repeatedEditRate < 20 ? 3 : 0);
|
|
16
|
+
// 4. Session termination (10 pts)
|
|
17
|
+
score += sessionExit === 'clean' ? 10 : sessionExit === 'unknown' ? 5 : 0;
|
|
18
|
+
// 5. Cost efficiency (15 pts)
|
|
19
|
+
score += tokensPerEdit > 0 && tokensPerEdit < 30000 ? 15
|
|
20
|
+
: tokensPerEdit < 50000 ? 10
|
|
21
|
+
: tokensPerEdit < 80000 ? 5 : 0;
|
|
22
|
+
// 6. Error penalty
|
|
23
|
+
score = Math.max(0, score - (toolErrorCount * 5));
|
|
24
|
+
return score;
|
|
25
|
+
}
|
|
26
|
+
// ── Helper: compute SEI (Spec Efficiency Index) ──
|
|
27
|
+
function computeSEI(postSpecReadTotal, postSpecReadErrors, cacheRead, specFilesCount) {
|
|
28
|
+
const seiAccuracy = postSpecReadTotal > 0 ? 1.0 - (postSpecReadErrors / postSpecReadTotal) : 1.0;
|
|
29
|
+
let specVolumeTokens = cacheRead;
|
|
30
|
+
if (specVolumeTokens === 0)
|
|
31
|
+
specVolumeTokens = specFilesCount * 500;
|
|
32
|
+
let volFactor = Math.log10(specVolumeTokens + 1);
|
|
33
|
+
if (volFactor === 0)
|
|
34
|
+
volFactor = 1;
|
|
35
|
+
const seiScore = (seiAccuracy * 100) / volFactor;
|
|
36
|
+
const interpretation = seiScore > 25 ? "Elite" : seiScore > 20 ? "Good" : seiScore > 15 ? "Average" : "Low";
|
|
37
|
+
return {
|
|
38
|
+
sei_score: Math.round(seiScore * 100) / 100,
|
|
39
|
+
accuracy: Math.round(seiAccuracy * 1000) / 10,
|
|
40
|
+
volume_tokens: Math.round(specVolumeTokens),
|
|
41
|
+
interpretation
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// ── Helper: compute comprehensive grade ──
|
|
45
|
+
function computeGrade(cacheHitRate, toolErrorCount, requests, readEditRatio) {
|
|
46
|
+
const gradeEfficiency = Math.min(40, (cacheHitRate / 100) * 40);
|
|
47
|
+
const errorRateVal = requests > 0 ? toolErrorCount / requests : 0;
|
|
48
|
+
const gradeStability = Math.max(0, 30 - (errorRateVal * 300));
|
|
49
|
+
const gradePrecision = readEditRatio >= 1.0 ? 30 : (readEditRatio * 30);
|
|
50
|
+
const gradePenalty = 0;
|
|
51
|
+
const finalScore = Math.max(0, gradeEfficiency + gradeStability + gradePrecision - gradePenalty);
|
|
52
|
+
const letter = finalScore >= 90 ? 'S' : finalScore >= 80 ? 'A' : finalScore >= 60 ? 'B' : 'C';
|
|
53
|
+
return {
|
|
54
|
+
breakdown: {
|
|
55
|
+
efficiency: Math.round(gradeEfficiency * 10) / 10,
|
|
56
|
+
stability: Math.round(gradeStability * 10) / 10,
|
|
57
|
+
precision: Math.round(gradePrecision * 10) / 10,
|
|
58
|
+
penalty: gradePenalty,
|
|
59
|
+
final_score: Math.round(finalScore * 10) / 10
|
|
60
|
+
},
|
|
61
|
+
letter: letter
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// ── Helper: compute danger level ──
|
|
65
|
+
function getDangerLevel(avgContextSize) {
|
|
66
|
+
if (avgContextSize < 10000)
|
|
67
|
+
return 'optimal';
|
|
68
|
+
if (avgContextSize < 20000)
|
|
69
|
+
return 'safe';
|
|
70
|
+
if (avgContextSize > 50000)
|
|
71
|
+
return 'critical';
|
|
72
|
+
return 'caution';
|
|
73
|
+
}
|
|
74
|
+
// ── Get the list of projects to scan ──
|
|
75
|
+
function getProjectsToScan(projectsDir, projectPath) {
|
|
76
|
+
if (projectPath) {
|
|
77
|
+
const fullPath = path.join(projectsDir, projectPath);
|
|
78
|
+
if (fs.existsSync(fullPath)) {
|
|
79
|
+
return [{ name: projectPath, path: fullPath }];
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
if (!fs.existsSync(projectsDir))
|
|
84
|
+
return [];
|
|
85
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
86
|
+
return entries
|
|
87
|
+
.filter(e => e.isDirectory() && e.name.startsWith('-'))
|
|
88
|
+
.map(e => ({ name: e.name, path: path.join(projectsDir, e.name) }));
|
|
89
|
+
}
|
|
90
|
+
// ════════════════════════════════════════════════════════════
|
|
91
|
+
// Public API
|
|
92
|
+
// ════════════════════════════════════════════════════════════
|
|
93
|
+
/**
|
|
94
|
+
* Get list of all sessions, optionally filtered by project and date range.
|
|
95
|
+
*/
|
|
96
|
+
export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
97
|
+
const sessions = [];
|
|
98
|
+
const projectsToScan = getProjectsToScan(projectsDir, projectPath);
|
|
99
|
+
for (const project of projectsToScan) {
|
|
100
|
+
const files = fs.readdirSync(project.path).filter(f => f.endsWith('.jsonl'));
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const filepath = path.join(project.path, file);
|
|
103
|
+
const records = parseJsonlFile(filepath);
|
|
104
|
+
if (records.length === 0)
|
|
105
|
+
continue;
|
|
106
|
+
// Date filtering
|
|
107
|
+
const firstRecord = records.find(r => r.timestamp);
|
|
108
|
+
if (firstRecord?.timestamp) {
|
|
109
|
+
const sessionDate = new Date(firstRecord.timestamp);
|
|
110
|
+
if (startDate && sessionDate < new Date(startDate))
|
|
111
|
+
continue;
|
|
112
|
+
if (endDate) {
|
|
113
|
+
const end = new Date(endDate);
|
|
114
|
+
end.setHours(23, 59, 59, 999);
|
|
115
|
+
if (sessionDate > end)
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let inputTokens = 0, cacheRead = 0, requests = 0;
|
|
120
|
+
const filesReadList = [];
|
|
121
|
+
const filesEdited = [];
|
|
122
|
+
const specFilesRead = [];
|
|
123
|
+
let readCount = 0, editCount = 0;
|
|
124
|
+
let firstSpecReadIndex = -1;
|
|
125
|
+
let postSpecReadTotal = 0, postSpecReadErrors = 0;
|
|
126
|
+
let recordIndex = 0;
|
|
127
|
+
let toolResultCount = 0, toolErrorCount = 0;
|
|
128
|
+
let humanTurns = 0, autoTurns = 0;
|
|
129
|
+
for (const record of records) {
|
|
130
|
+
if (record.type === 'assistant' && record.message?.usage) {
|
|
131
|
+
const usage = record.message.usage;
|
|
132
|
+
inputTokens += usage.input_tokens || 0;
|
|
133
|
+
// outputTokens += usage.output_tokens || 0;
|
|
134
|
+
cacheRead += usage.cache_read_input_tokens || 0;
|
|
135
|
+
requests++;
|
|
136
|
+
if (record.message?.content && Array.isArray(record.message.content)) {
|
|
137
|
+
for (const block of record.message.content) {
|
|
138
|
+
if (block.type === 'tool_use') {
|
|
139
|
+
const toolName = block.name?.toLowerCase() || '';
|
|
140
|
+
if (toolName.includes('read') || toolName.includes('view') || toolName.includes('grep') || toolName.includes('glob') || toolName.includes('list')) {
|
|
141
|
+
readCount++;
|
|
142
|
+
const input = block.input || {};
|
|
143
|
+
const fp = input.file_path || input.path || '';
|
|
144
|
+
if (firstSpecReadIndex === -1 && (fp.includes('.claude/') || fp.includes('CLAUDE.md'))) {
|
|
145
|
+
firstSpecReadIndex = recordIndex;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (toolName.includes('edit') || toolName.includes('write') || toolName.includes('replace')) {
|
|
149
|
+
editCount++;
|
|
150
|
+
const input = block.input;
|
|
151
|
+
const filePath = input?.file_path || input?.filePath;
|
|
152
|
+
if (filePath)
|
|
153
|
+
filesEdited.push(filePath);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (record.type === 'user' && record.message?.content) {
|
|
160
|
+
const msgContent = record.message.content;
|
|
161
|
+
if (Array.isArray(msgContent)) {
|
|
162
|
+
for (const block of msgContent) {
|
|
163
|
+
if (block.type === 'tool_result') {
|
|
164
|
+
toolResultCount++;
|
|
165
|
+
if (block.is_error) {
|
|
166
|
+
toolErrorCount++;
|
|
167
|
+
if (firstSpecReadIndex !== -1 && recordIndex > firstSpecReadIndex)
|
|
168
|
+
postSpecReadErrors++;
|
|
169
|
+
}
|
|
170
|
+
if (firstSpecReadIndex !== -1 && recordIndex > firstSpecReadIndex)
|
|
171
|
+
postSpecReadTotal++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (record.type === 'user' && record.toolUseResult?.file?.filePath) {
|
|
177
|
+
const fp = record.toolUseResult.file.filePath;
|
|
178
|
+
filesReadList.push(fp);
|
|
179
|
+
if (fp.includes('.claude/') || fp.includes('CLAUDE.md'))
|
|
180
|
+
specFilesRead.push(fp);
|
|
181
|
+
}
|
|
182
|
+
// Human vs auto turn detection
|
|
183
|
+
// Human vs auto turn detection
|
|
184
|
+
if (record.type === 'user') {
|
|
185
|
+
const hasTUR = !!record.toolUseResult;
|
|
186
|
+
const isMeta = !!record.isMeta;
|
|
187
|
+
const msgContent = record.message?.content;
|
|
188
|
+
if (hasTUR) {
|
|
189
|
+
autoTurns++;
|
|
190
|
+
}
|
|
191
|
+
else if (isMeta) {
|
|
192
|
+
// skip
|
|
193
|
+
}
|
|
194
|
+
else if (typeof msgContent === 'string' && msgContent.trim().length > 0) {
|
|
195
|
+
humanTurns++;
|
|
196
|
+
}
|
|
197
|
+
else if (Array.isArray(msgContent)) {
|
|
198
|
+
const hasText = msgContent.some(b => b.type === 'text' && b.text?.trim().length);
|
|
199
|
+
const hasOnlyToolResults = msgContent.every(b => b.type === 'tool_result');
|
|
200
|
+
if (hasOnlyToolResults)
|
|
201
|
+
autoTurns++;
|
|
202
|
+
else if (hasText)
|
|
203
|
+
humanTurns++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
recordIndex++;
|
|
207
|
+
}
|
|
208
|
+
// Compute derived metrics
|
|
209
|
+
const lastRecordRaw = records[records.length - 1];
|
|
210
|
+
const sessionExit = lastRecordRaw?.type === 'summary' ? 'clean' :
|
|
211
|
+
lastRecordRaw?.type === 'system' ? 'forced' : 'unknown';
|
|
212
|
+
const totalContextSent = inputTokens + cacheRead;
|
|
213
|
+
const avgContextSize = requests > 0 ? Math.round(totalContextSent / requests) : 0;
|
|
214
|
+
const dangerLevel = getDangerLevel(avgContextSize);
|
|
215
|
+
const limitImpact = Math.round((totalContextSent / 44000) * 100);
|
|
216
|
+
const uniqueFilesRead = new Set(filesReadList).size;
|
|
217
|
+
const duplicateReads = filesReadList.length - uniqueFilesRead;
|
|
218
|
+
const duplicateReadRate = filesReadList.length > 0 ? Math.round((duplicateReads / filesReadList.length) * 100) : 0;
|
|
219
|
+
const readEditRatio = editCount > 0 ? Math.round((readCount / editCount) * 10) / 10 : readCount;
|
|
220
|
+
const uniqueFilesEdited = new Set(filesEdited).size;
|
|
221
|
+
const repeatedEdits = filesEdited.length - uniqueFilesEdited;
|
|
222
|
+
const repeatedEditRate = filesEdited.length > 0 ? Math.round((repeatedEdits / filesEdited.length) * 100) : 0;
|
|
223
|
+
const tokensPerEdit = editCount > 0 ? Math.round(totalContextSent / editCount) : 0;
|
|
224
|
+
const toolErrorRate = toolResultCount > 0 ? Math.round((toolErrorCount / toolResultCount) * 1000) / 10 : 0;
|
|
225
|
+
const cacheHitRate = (inputTokens + cacheRead) > 0 ? (cacheRead / (inputTokens + cacheRead)) * 100 : 0;
|
|
226
|
+
const efficiencyScore = computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit);
|
|
227
|
+
const sei = computeSEI(postSpecReadTotal, postSpecReadErrors, cacheRead, new Set(specFilesRead).size);
|
|
228
|
+
const grade = computeGrade(cacheHitRate, toolErrorCount, requests, readEditRatio);
|
|
229
|
+
// Timestamps & duration
|
|
230
|
+
const firstTimestampRecord = records.find(r => r.timestamp);
|
|
231
|
+
const lastTimestampRecord = [...records].reverse().find(r => r.timestamp);
|
|
232
|
+
const startTs = firstTimestampRecord?.timestamp || '';
|
|
233
|
+
const endTs = lastTimestampRecord?.timestamp || '';
|
|
234
|
+
let durationMinutes = 0;
|
|
235
|
+
if (startTs && endTs) {
|
|
236
|
+
durationMinutes = Math.round((new Date(endTs).getTime() - new Date(startTs).getTime()) / 60000);
|
|
237
|
+
if (durationMinutes < 0)
|
|
238
|
+
durationMinutes = 0;
|
|
239
|
+
}
|
|
240
|
+
const specCount = new Set(specFilesRead).size;
|
|
241
|
+
sessions.push({
|
|
242
|
+
session_id: file.replace('.jsonl', ''),
|
|
243
|
+
project: project.name,
|
|
244
|
+
start_time: startTs,
|
|
245
|
+
end_time: endTs,
|
|
246
|
+
git_branch: firstTimestampRecord?.gitBranch || '',
|
|
247
|
+
total_requests: requests,
|
|
248
|
+
avg_context_size: avgContextSize,
|
|
249
|
+
danger_level: dangerLevel,
|
|
250
|
+
limit_impact: limitImpact,
|
|
251
|
+
total_context_tokens: totalContextSent,
|
|
252
|
+
total_output_tokens: 0,
|
|
253
|
+
duplicate_read_rate: duplicateReadRate,
|
|
254
|
+
read_edit_ratio: readEditRatio,
|
|
255
|
+
repeated_edit_rate: repeatedEditRate,
|
|
256
|
+
tokens_per_edit: tokensPerEdit,
|
|
257
|
+
tool_error_rate: toolErrorRate,
|
|
258
|
+
session_exit: sessionExit,
|
|
259
|
+
efficiency_score: efficiencyScore,
|
|
260
|
+
session_grade: grade.letter,
|
|
261
|
+
duration_minutes: durationMinutes,
|
|
262
|
+
spec_files_count: specCount,
|
|
263
|
+
has_spec_context: specCount > 0,
|
|
264
|
+
human_turns: humanTurns,
|
|
265
|
+
auto_turns: autoTurns,
|
|
266
|
+
human_turns_per_edit: editCount > 0 ? Math.round((humanTurns / editCount) * 10) / 10 : 0,
|
|
267
|
+
spec_efficiency: sei,
|
|
268
|
+
grade_breakdown: grade.breakdown
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return sessions.sort((a, b) => {
|
|
273
|
+
if (!a.start_time && !b.start_time)
|
|
274
|
+
return 0;
|
|
275
|
+
if (!a.start_time)
|
|
276
|
+
return 1;
|
|
277
|
+
if (!b.start_time)
|
|
278
|
+
return -1;
|
|
279
|
+
return new Date(b.start_time).getTime() - new Date(a.start_time).getTime();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Get detailed session data with processed messages.
|
|
284
|
+
*/
|
|
285
|
+
export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
286
|
+
let filepath = null;
|
|
287
|
+
let foundProject = '';
|
|
288
|
+
if (projectPath) {
|
|
289
|
+
const testPath = path.join(projectsDir, projectPath, `${sessionId}.jsonl`);
|
|
290
|
+
if (fs.existsSync(testPath)) {
|
|
291
|
+
filepath = testPath;
|
|
292
|
+
foundProject = projectPath;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
if (!fs.existsSync(projectsDir))
|
|
297
|
+
return null;
|
|
298
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
if (entry.isDirectory() && entry.name.startsWith('-')) {
|
|
301
|
+
const testPath = path.join(projectsDir, entry.name, `${sessionId}.jsonl`);
|
|
302
|
+
if (fs.existsSync(testPath)) {
|
|
303
|
+
filepath = testPath;
|
|
304
|
+
foundProject = entry.name;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (!filepath)
|
|
311
|
+
return null;
|
|
312
|
+
const records = parseJsonlFile(filepath);
|
|
313
|
+
const messages = [];
|
|
314
|
+
const filesRead = [];
|
|
315
|
+
const specFilesRead = [];
|
|
316
|
+
let inputTokens = 0, cacheRead = 0, requests = 0;
|
|
317
|
+
let readCount = 0, editCount = 0;
|
|
318
|
+
let toolResultCount = 0, toolErrorCount = 0;
|
|
319
|
+
const filesEdited = [];
|
|
320
|
+
const allReadFiles = [];
|
|
321
|
+
let firstSpecReadIndex = -1;
|
|
322
|
+
let postSpecReadTotal = 0, postSpecReadErrors = 0;
|
|
323
|
+
let recordIndex = 0;
|
|
324
|
+
const toolIdMap = new Map();
|
|
325
|
+
const errorMap = new Map();
|
|
326
|
+
// Track which files were read by the assistant via tool_use
|
|
327
|
+
// so we can deduplicate them from user's "context load" display
|
|
328
|
+
let lastAssistantReadFiles = new Set();
|
|
329
|
+
let messageId = 0;
|
|
330
|
+
for (const record of records) {
|
|
331
|
+
if (record.type === 'user') {
|
|
332
|
+
if (record.isMeta)
|
|
333
|
+
continue;
|
|
334
|
+
const hasTUR = !!record.toolUseResult;
|
|
335
|
+
const msgContent = record.message?.content;
|
|
336
|
+
let content = '';
|
|
337
|
+
let messageSubtype = '';
|
|
338
|
+
if (typeof msgContent === 'string' && msgContent.trim().length > 0) {
|
|
339
|
+
const raw = msgContent.trim();
|
|
340
|
+
if (raw.startsWith('<local-command-stdout>') || raw.startsWith('<local-command-caveat>')) {
|
|
341
|
+
// Skip system messages
|
|
342
|
+
}
|
|
343
|
+
else if (raw.includes('<command-name>')) {
|
|
344
|
+
const cmdMatch = raw.match(/<command-name>\/?(.+?)<\/command-name>/);
|
|
345
|
+
const argsMatch = raw.match(/<command-args>([\s\S]*?)<\/command-args>/);
|
|
346
|
+
const cmdName = cmdMatch ? `/${cmdMatch[1]}` : '';
|
|
347
|
+
const cmdArgs = argsMatch ? argsMatch[1].trim() : '';
|
|
348
|
+
content = cmdArgs ? `${cmdName} ${cmdArgs}` : cmdName;
|
|
349
|
+
messageSubtype = 'command';
|
|
350
|
+
}
|
|
351
|
+
else if (raw.startsWith('This session is being continued')) {
|
|
352
|
+
content = '(세션 이어하기 — 이전 대화 요약 자동 삽입)';
|
|
353
|
+
messageSubtype = 'continuation';
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
content = raw;
|
|
357
|
+
messageSubtype = 'human';
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else if (Array.isArray(msgContent)) {
|
|
361
|
+
for (const block of msgContent) {
|
|
362
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
363
|
+
content += block.text;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (content)
|
|
367
|
+
messageSubtype = 'human';
|
|
368
|
+
}
|
|
369
|
+
const fileList = [];
|
|
370
|
+
const toolUses = [];
|
|
371
|
+
if (hasTUR) {
|
|
372
|
+
const tur = record.toolUseResult;
|
|
373
|
+
if (tur?.file?.filePath) {
|
|
374
|
+
const fp = tur.file.filePath;
|
|
375
|
+
// ── BUG FIX (항목 4): Only add to files_read if NOT already
|
|
376
|
+
// shown in the previous assistant's tool_use list ──
|
|
377
|
+
if (!lastAssistantReadFiles.has(fp)) {
|
|
378
|
+
fileList.push(fp);
|
|
379
|
+
}
|
|
380
|
+
filesRead.push(fp);
|
|
381
|
+
if (fp.includes('.claude/') || fp.includes('CLAUDE.md')) {
|
|
382
|
+
specFilesRead.push(fp);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!content) {
|
|
386
|
+
toolUses.push({ name: 'tool_result' });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Track tool results and errors
|
|
390
|
+
if (Array.isArray(msgContent)) {
|
|
391
|
+
for (const block of msgContent) {
|
|
392
|
+
if (block?.type === 'tool_result') {
|
|
393
|
+
toolResultCount++;
|
|
394
|
+
if (block.is_error) {
|
|
395
|
+
toolErrorCount++;
|
|
396
|
+
if (firstSpecReadIndex !== -1 && recordIndex > firstSpecReadIndex)
|
|
397
|
+
postSpecReadErrors++;
|
|
398
|
+
const toolId = block.tool_use_id;
|
|
399
|
+
const toolName = toolIdMap.get(toolId) || 'unknown';
|
|
400
|
+
let errorMsg = '';
|
|
401
|
+
if (typeof block.content === 'string')
|
|
402
|
+
errorMsg = block.content;
|
|
403
|
+
else if (Array.isArray(block.content))
|
|
404
|
+
errorMsg = block.content.map(c => c.text || '').join(' ');
|
|
405
|
+
if (errorMsg) {
|
|
406
|
+
const displayMsg = errorMsg.length > 200 ? errorMsg.substring(0, 200) + '...' : errorMsg;
|
|
407
|
+
const key = `${toolName}|||${displayMsg}`;
|
|
408
|
+
const existing = errorMap.get(key) || { tool: toolName, error: displayMsg, count: 0 };
|
|
409
|
+
existing.count++;
|
|
410
|
+
errorMap.set(key, existing);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (firstSpecReadIndex !== -1 && recordIndex > firstSpecReadIndex)
|
|
414
|
+
postSpecReadTotal++;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Clear assistant read tracking for next cycle
|
|
419
|
+
lastAssistantReadFiles = new Set();
|
|
420
|
+
if (content || fileList.length > 0) {
|
|
421
|
+
messages.push({
|
|
422
|
+
id: messageId++,
|
|
423
|
+
type: 'user',
|
|
424
|
+
subtype: messageSubtype || (hasTUR ? 'tool_result' : ''),
|
|
425
|
+
timestamp: record.timestamp || '',
|
|
426
|
+
content,
|
|
427
|
+
files_read: fileList,
|
|
428
|
+
tool_uses: toolUses
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (record.type === 'assistant') {
|
|
433
|
+
const usage = record.message?.usage;
|
|
434
|
+
const content = [];
|
|
435
|
+
const toolUses = [];
|
|
436
|
+
// Reset assistant read files tracking for this message
|
|
437
|
+
lastAssistantReadFiles = new Set();
|
|
438
|
+
if (record.message?.content && Array.isArray(record.message.content)) {
|
|
439
|
+
for (const block of record.message.content) {
|
|
440
|
+
if (block.type === 'text' && block.text) {
|
|
441
|
+
content.push(block.text);
|
|
442
|
+
}
|
|
443
|
+
else if (block.type === 'tool_use') {
|
|
444
|
+
const toolName = block.name || 'tool';
|
|
445
|
+
const input = block.input || {};
|
|
446
|
+
const toolId = block.id;
|
|
447
|
+
if (toolId)
|
|
448
|
+
toolIdMap.set(toolId, toolName);
|
|
449
|
+
let detail = '';
|
|
450
|
+
const nameLower = toolName.toLowerCase();
|
|
451
|
+
if (nameLower === 'read' || nameLower === 'view') {
|
|
452
|
+
const fp = input.file_path || input.filePath || '';
|
|
453
|
+
if (fp) {
|
|
454
|
+
detail = fp;
|
|
455
|
+
// Track that this file was read by assistant tool
|
|
456
|
+
lastAssistantReadFiles.add(fp);
|
|
457
|
+
const parts = [];
|
|
458
|
+
if (input.offset !== undefined)
|
|
459
|
+
parts.push(`offset:${input.offset}`);
|
|
460
|
+
if (input.limit !== undefined)
|
|
461
|
+
parts.push(`limit:${input.limit}`);
|
|
462
|
+
if (input.start_line !== undefined)
|
|
463
|
+
parts.push(`L${input.start_line}`);
|
|
464
|
+
if (input.end_line !== undefined)
|
|
465
|
+
parts.push(`L${input.end_line}`);
|
|
466
|
+
if (input.view_range)
|
|
467
|
+
parts.push(`range:${Array.isArray(input.view_range) ? input.view_range.join('-') : input.view_range}`);
|
|
468
|
+
if (parts.length > 0)
|
|
469
|
+
detail += ` (${parts.join(', ')})`;
|
|
470
|
+
readCount++;
|
|
471
|
+
allReadFiles.push(detail);
|
|
472
|
+
if (firstSpecReadIndex === -1 && (fp.includes('.claude/') || fp.includes('CLAUDE.md'))) {
|
|
473
|
+
firstSpecReadIndex = recordIndex;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else if (nameLower === 'edit' || nameLower === 'replace') {
|
|
478
|
+
detail = input.file_path || input.filePath || '';
|
|
479
|
+
editCount++;
|
|
480
|
+
if (detail)
|
|
481
|
+
filesEdited.push(detail);
|
|
482
|
+
}
|
|
483
|
+
else if (nameLower === 'write') {
|
|
484
|
+
detail = input.file_path || input.filePath || '';
|
|
485
|
+
editCount++;
|
|
486
|
+
if (detail)
|
|
487
|
+
filesEdited.push(detail);
|
|
488
|
+
}
|
|
489
|
+
else if (nameLower === 'bash') {
|
|
490
|
+
detail = input.command || '';
|
|
491
|
+
}
|
|
492
|
+
else if (nameLower === 'grep') {
|
|
493
|
+
detail = `${input.pattern || ''} in ${input.path || ''}`;
|
|
494
|
+
}
|
|
495
|
+
else if (nameLower === 'glob') {
|
|
496
|
+
detail = `${input.pattern || ''} in ${input.path || ''}`;
|
|
497
|
+
}
|
|
498
|
+
else if (nameLower === 'task') {
|
|
499
|
+
detail = input.description || '';
|
|
500
|
+
}
|
|
501
|
+
else if (nameLower === 'taskcreate') {
|
|
502
|
+
detail = input.subject || '';
|
|
503
|
+
}
|
|
504
|
+
else if (nameLower === 'taskupdate') {
|
|
505
|
+
detail = `#${input.taskId || ''} → ${input.status || ''}`;
|
|
506
|
+
}
|
|
507
|
+
else if (nameLower === 'tasklist') {
|
|
508
|
+
detail = '';
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
detail = input.file_path || input.filePath || input.command || input.description || '';
|
|
512
|
+
if (nameLower.includes('read') || nameLower.includes('view') || nameLower.includes('grep') || nameLower.includes('glob') || nameLower.includes('list')) {
|
|
513
|
+
readCount++;
|
|
514
|
+
if (detail) {
|
|
515
|
+
allReadFiles.push(detail);
|
|
516
|
+
lastAssistantReadFiles.add(detail);
|
|
517
|
+
if (firstSpecReadIndex === -1 && (detail.includes('.claude/') || detail.includes('CLAUDE.md'))) {
|
|
518
|
+
firstSpecReadIndex = recordIndex;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
else if (nameLower.includes('edit') || nameLower.includes('write') || nameLower.includes('replace')) {
|
|
523
|
+
editCount++;
|
|
524
|
+
if (detail)
|
|
525
|
+
filesEdited.push(detail);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
toolUses.push({ name: toolName, detail: detail || undefined });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (usage) {
|
|
533
|
+
inputTokens += usage.input_tokens || 0;
|
|
534
|
+
// outputTokens += usage.output_tokens || 0; // Unused
|
|
535
|
+
cacheRead += usage.cache_read_input_tokens || 0;
|
|
536
|
+
requests++;
|
|
537
|
+
}
|
|
538
|
+
messages.push({
|
|
539
|
+
id: messageId++,
|
|
540
|
+
type: 'assistant',
|
|
541
|
+
timestamp: record.timestamp || '',
|
|
542
|
+
content: content.join('\n'),
|
|
543
|
+
usage,
|
|
544
|
+
files_read: [],
|
|
545
|
+
tool_uses: toolUses
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
recordIndex++;
|
|
549
|
+
}
|
|
550
|
+
const firstRecord = records.find(r => r.timestamp) || {};
|
|
551
|
+
const lastRecord = [...records].reverse().find(r => r.timestamp) || {};
|
|
552
|
+
const toolErrorRate = toolResultCount > 0 ? (toolErrorCount / toolResultCount) * 100 : 0;
|
|
553
|
+
const errorDetails = Array.from(errorMap.values()).sort((a, b) => b.count - a.count);
|
|
554
|
+
const sessionExit = messages.length > 0 && messages[messages.length - 1].type === 'user' ? 'forced' :
|
|
555
|
+
messages.length > 0 && messages[messages.length - 1].type === 'assistant' ? 'clean' : 'unknown';
|
|
556
|
+
const totalContextSent = inputTokens + cacheRead;
|
|
557
|
+
const avgContextSize = requests > 0 ? Math.round(totalContextSent / requests) : 0;
|
|
558
|
+
const dangerLevel = getDangerLevel(avgContextSize);
|
|
559
|
+
const limitImpact = Math.round((totalContextSent / 44000) * 100);
|
|
560
|
+
const readEditRatio = editCount > 0 ? Math.round((readCount / editCount) * 10) / 10 : readCount;
|
|
561
|
+
const uniqueFilesRead = new Set(allReadFiles).size;
|
|
562
|
+
const duplicateReads = allReadFiles.length - uniqueFilesRead;
|
|
563
|
+
const duplicateReadRate = allReadFiles.length > 0 ? Math.round((duplicateReads / allReadFiles.length) * 100) : 0;
|
|
564
|
+
const fileReadCounts = {};
|
|
565
|
+
for (const fp of allReadFiles) {
|
|
566
|
+
const shortPath = fp.split('/').slice(-2).join('/');
|
|
567
|
+
fileReadCounts[shortPath] = (fileReadCounts[shortPath] || 0) + 1;
|
|
568
|
+
}
|
|
569
|
+
const duplicateFiles = Object.entries(fileReadCounts)
|
|
570
|
+
.filter(([, count]) => count > 1)
|
|
571
|
+
.sort((a, b) => b[1] - a[1])
|
|
572
|
+
.map(([file, count]) => ({ file, count }));
|
|
573
|
+
const uniqueFilesEdited = new Set(filesEdited).size;
|
|
574
|
+
const repeatedEdits = filesEdited.length - uniqueFilesEdited;
|
|
575
|
+
const repeatedEditRate = filesEdited.length > 0 ? Math.round((repeatedEdits / filesEdited.length) * 100) : 0;
|
|
576
|
+
const tokensPerEdit = editCount > 0 ? Math.round(totalContextSent / editCount) : 0;
|
|
577
|
+
const cacheHitRate = (inputTokens + cacheRead) > 0 ? (cacheRead / (inputTokens + cacheRead)) * 100 : 0;
|
|
578
|
+
const efficiencyScore = computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit);
|
|
579
|
+
const specFilesReadList = allReadFiles.filter(f => f.includes('.claude/') || f.includes('CLAUDE.md'));
|
|
580
|
+
const sei = computeSEI(postSpecReadTotal, postSpecReadErrors, cacheRead, specFilesReadList.length);
|
|
581
|
+
const grade = computeGrade(cacheHitRate, toolErrorCount, requests, readEditRatio);
|
|
582
|
+
return {
|
|
583
|
+
session_id: sessionId,
|
|
584
|
+
project: foundProject,
|
|
585
|
+
start_time: firstRecord.timestamp || '',
|
|
586
|
+
end_time: lastRecord.timestamp || '',
|
|
587
|
+
git_branch: firstRecord?.gitBranch || '',
|
|
588
|
+
messages,
|
|
589
|
+
summary: {
|
|
590
|
+
total_requests: requests,
|
|
591
|
+
avg_context_size: avgContextSize,
|
|
592
|
+
danger_level: dangerLevel,
|
|
593
|
+
limit_impact: limitImpact,
|
|
594
|
+
total_context_tokens: totalContextSent,
|
|
595
|
+
files_read: [...new Set(filesRead)],
|
|
596
|
+
spec_files_read: [...new Set(specFilesRead)]
|
|
597
|
+
},
|
|
598
|
+
quality: {
|
|
599
|
+
read_count: readCount,
|
|
600
|
+
edit_count: editCount,
|
|
601
|
+
read_edit_ratio: readEditRatio,
|
|
602
|
+
duplicate_read_rate: duplicateReadRate,
|
|
603
|
+
duplicate_files: duplicateFiles,
|
|
604
|
+
repeated_edit_rate: repeatedEditRate,
|
|
605
|
+
tokens_per_edit: tokensPerEdit,
|
|
606
|
+
tool_error_rate: Math.round(toolErrorRate * 10) / 10,
|
|
607
|
+
error_details: errorDetails,
|
|
608
|
+
cache_hit_rate: Math.round(cacheHitRate * 10) / 10,
|
|
609
|
+
efficiency_score: Math.round(efficiencyScore),
|
|
610
|
+
session_grade: grade.letter,
|
|
611
|
+
session_exit: sessionExit,
|
|
612
|
+
score_breakdown: {
|
|
613
|
+
cache: cacheHitRate >= 95 ? 30 : cacheHitRate >= 90 ? 25 : cacheHitRate >= 80 ? 20 : cacheHitRate >= 60 ? 10 : 0,
|
|
614
|
+
tool_reliability: (toolErrorRate <= 2 ? 20 : toolErrorRate <= 5 ? 15 : toolErrorRate <= 10 ? 10 : 5) - (toolErrorCount * 5),
|
|
615
|
+
work_efficiency: (duplicateReadRate < 10 ? 10 : duplicateReadRate < 20 ? 5 : 0)
|
|
616
|
+
+ (readEditRatio >= 5 && readEditRatio <= 20 ? 10 : readEditRatio >= 3 ? 5 : 0)
|
|
617
|
+
+ (repeatedEditRate < 10 ? 5 : repeatedEditRate < 20 ? 3 : 0),
|
|
618
|
+
termination: sessionExit === 'clean' ? 10 : sessionExit === 'unknown' ? 5 : 0,
|
|
619
|
+
cost_efficiency: tokensPerEdit > 0 && tokensPerEdit < 30000 ? 15 : tokensPerEdit < 50000 ? 10 : tokensPerEdit < 80000 ? 5 : 0
|
|
620
|
+
},
|
|
621
|
+
spec_efficiency: sei,
|
|
622
|
+
grade_breakdown: grade.breakdown
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Get aggregated analytics across all sessions.
|
|
628
|
+
*/
|
|
629
|
+
export function getAnalytics(projectsDir, projectPath, startDate, endDate) {
|
|
630
|
+
const sessions = getSessionsList(projectsDir, projectPath, startDate, endDate);
|
|
631
|
+
const projects = getProjectsList(projectsDir);
|
|
632
|
+
let totalInput = 0, totalOutput = 0, totalCacheRead = 0;
|
|
633
|
+
const projectsToScan = getProjectsToScan(projectsDir, projectPath);
|
|
634
|
+
for (const projectDir of projectsToScan) {
|
|
635
|
+
if (!fs.existsSync(projectDir.path))
|
|
636
|
+
continue;
|
|
637
|
+
const files = fs.readdirSync(projectDir.path).filter(f => f.endsWith('.jsonl'));
|
|
638
|
+
for (const file of files) {
|
|
639
|
+
const filepath = path.join(projectDir.path, file);
|
|
640
|
+
const records = parseJsonlFile(filepath);
|
|
641
|
+
for (const record of records) {
|
|
642
|
+
if (record.type === 'assistant' && record.message?.usage) {
|
|
643
|
+
const usage = record.message.usage;
|
|
644
|
+
totalInput += usage.input_tokens || 0;
|
|
645
|
+
totalOutput += usage.output_tokens || 0;
|
|
646
|
+
totalCacheRead += usage.cache_read_input_tokens || 0;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const totalContextSent = totalInput + totalCacheRead;
|
|
652
|
+
const totalRequests = sessions.reduce((sum, s) => sum + s.total_requests, 0);
|
|
653
|
+
const avgContextSize = totalRequests > 0 ? Math.round(totalContextSent / totalRequests) : 0;
|
|
654
|
+
const dangerLevel = getDangerLevel(avgContextSize);
|
|
655
|
+
const limitImpact = Math.round((totalContextSent / 44000) * 100);
|
|
656
|
+
const avgEfficiency = sessions.length > 0 ? Math.round(sessions.reduce((sum, s) => sum + s.efficiency_score, 0) / sessions.length) : 0;
|
|
657
|
+
const avgToolErrorRate = sessions.length > 0 ? Math.round(sessions.reduce((sum, s) => sum + s.tool_error_rate, 0) / sessions.length * 10) / 10 : 0;
|
|
658
|
+
// Hypothesis Check
|
|
659
|
+
const withSpec = sessions.filter(s => s.has_spec_context && s.total_requests > 0);
|
|
660
|
+
const withoutSpec = sessions.filter(s => !s.has_spec_context && s.total_requests > 0);
|
|
661
|
+
const avg = (arr, fn) => arr.length > 0 ? Math.round(arr.reduce((sum, s) => sum + fn(s), 0) / arr.length * 10) / 10 : 0;
|
|
662
|
+
const avgHumanTurnsWithSpec = avg(withSpec, s => s.human_turns);
|
|
663
|
+
const avgHumanTurnsWithoutSpec = avg(withoutSpec, s => s.human_turns);
|
|
664
|
+
const humanTurnImprovement = avgHumanTurnsWithoutSpec > 0
|
|
665
|
+
? Math.round((1 - avgHumanTurnsWithSpec / avgHumanTurnsWithoutSpec) * 1000) / 10 : 0;
|
|
666
|
+
const specWithEdits = withSpec.filter(s => s.human_turns_per_edit > 0);
|
|
667
|
+
const noSpecWithEdits = withoutSpec.filter(s => s.human_turns_per_edit > 0);
|
|
668
|
+
const avgHtPerEditWithSpec = avg(specWithEdits, s => s.human_turns_per_edit);
|
|
669
|
+
const avgHtPerEditWithoutSpec = avg(noSpecWithEdits, s => s.human_turns_per_edit);
|
|
670
|
+
const normalizedImprovement = avgHtPerEditWithoutSpec > 0
|
|
671
|
+
? Math.round((1 - avgHtPerEditWithSpec / avgHtPerEditWithoutSpec) * 1000) / 10 : 0;
|
|
672
|
+
return {
|
|
673
|
+
summary: {
|
|
674
|
+
total_sessions: sessions.length,
|
|
675
|
+
total_projects: projects.length,
|
|
676
|
+
total_context_tokens: totalContextSent,
|
|
677
|
+
total_output_tokens: totalOutput,
|
|
678
|
+
avg_context_size: avgContextSize,
|
|
679
|
+
danger_level: dangerLevel,
|
|
680
|
+
limit_impact: limitImpact,
|
|
681
|
+
optimal_sessions: sessions.filter(s => s.danger_level === 'optimal').length,
|
|
682
|
+
safe_sessions: sessions.filter(s => s.danger_level === 'safe').length,
|
|
683
|
+
caution_sessions: sessions.filter(s => s.danger_level === 'caution').length,
|
|
684
|
+
critical_sessions: sessions.filter(s => s.danger_level === 'critical').length,
|
|
685
|
+
grade_s: sessions.filter(s => s.session_grade === 'S').length,
|
|
686
|
+
grade_a: sessions.filter(s => s.session_grade === 'A').length,
|
|
687
|
+
grade_b: sessions.filter(s => s.session_grade === 'B').length,
|
|
688
|
+
grade_c: sessions.filter(s => s.session_grade === 'C').length,
|
|
689
|
+
avg_efficiency_score: avgEfficiency,
|
|
690
|
+
avg_tool_error_rate: avgToolErrorRate,
|
|
691
|
+
clean_exits: sessions.filter(s => s.session_exit === 'clean').length,
|
|
692
|
+
forced_exits: sessions.filter(s => s.session_exit === 'forced').length,
|
|
693
|
+
hypothesis_check: {
|
|
694
|
+
avg_turns_with_spec: avg(withSpec, s => s.total_requests),
|
|
695
|
+
avg_turns_without_spec: avg(withoutSpec, s => s.total_requests),
|
|
696
|
+
avg_human_turns_with_spec: avgHumanTurnsWithSpec,
|
|
697
|
+
avg_human_turns_without_spec: avgHumanTurnsWithoutSpec,
|
|
698
|
+
human_turn_improvement: humanTurnImprovement,
|
|
699
|
+
avg_ht_per_edit_with_spec: avgHtPerEditWithSpec,
|
|
700
|
+
avg_ht_per_edit_without_spec: avgHtPerEditWithoutSpec,
|
|
701
|
+
normalized_improvement: normalizedImprovement,
|
|
702
|
+
avg_duration_with_spec: avg(withSpec, s => s.duration_minutes),
|
|
703
|
+
avg_duration_without_spec: avg(withoutSpec, s => s.duration_minutes),
|
|
704
|
+
sessions_with_spec: withSpec.length,
|
|
705
|
+
sessions_without_spec: withoutSpec.length
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
projects,
|
|
709
|
+
sessions: sessions.slice(0, 100)
|
|
710
|
+
};
|
|
711
|
+
}
|