claude-cli-analytics 0.0.4 → 0.0.6
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/README.md +14 -15
- package/dist/client/assets/{index-Cb1zGdUJ.js → index-C91ygWO-.js} +26 -19
- package/dist/client/index.html +1 -1
- package/dist/server/analyzer.js +168 -82
- package/dist/server/index.js +7 -5
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>claude-analytics</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-C91ygWO-.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-BkOIudNK.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/dist/server/analyzer.js
CHANGED
|
@@ -2,8 +2,28 @@
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { parseJsonlFile, getProjectsList } from './parser.js';
|
|
5
|
+
// ── Model cost multiplier (Sonnet = 1.0 baseline) ──
|
|
6
|
+
const MODEL_COST_MULTIPLIER = {
|
|
7
|
+
haiku: 0.25,
|
|
8
|
+
sonnet: 1.0,
|
|
9
|
+
opus: 5.0,
|
|
10
|
+
};
|
|
11
|
+
function getModelCostMultiplier(modelName) {
|
|
12
|
+
const lower = modelName.toLowerCase();
|
|
13
|
+
for (const [key, value] of Object.entries(MODEL_COST_MULTIPLIER)) {
|
|
14
|
+
if (lower.includes(key))
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
return 1.0; // default to sonnet
|
|
18
|
+
}
|
|
19
|
+
function extractModelName(record) {
|
|
20
|
+
const model = record.message?.model;
|
|
21
|
+
if (model && typeof model === 'string')
|
|
22
|
+
return model;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
5
25
|
// ── Helper: compute efficiency score ──
|
|
6
|
-
function computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit) {
|
|
26
|
+
function computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit, modelName = 'sonnet') {
|
|
7
27
|
let score = 0;
|
|
8
28
|
// 1. Cache efficiency (30 pts)
|
|
9
29
|
score += cacheHitRate >= 95 ? 30 : cacheHitRate >= 90 ? 25 : cacheHitRate >= 80 ? 20 : cacheHitRate >= 60 ? 10 : 0;
|
|
@@ -15,10 +35,11 @@ function computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, dup
|
|
|
15
35
|
+ (repeatedEditRate < 10 ? 5 : repeatedEditRate < 20 ? 3 : 0);
|
|
16
36
|
// 4. Session termination (10 pts)
|
|
17
37
|
score += sessionExit === 'clean' ? 10 : sessionExit === 'unknown' ? 5 : 0;
|
|
18
|
-
// 5. Cost efficiency (15 pts)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
38
|
+
// 5. Cost efficiency (15 pts) — normalized by model cost multiplier
|
|
39
|
+
const costPerEdit = tokensPerEdit * getModelCostMultiplier(modelName);
|
|
40
|
+
score += costPerEdit > 0 && costPerEdit < 30000 ? 15
|
|
41
|
+
: costPerEdit < 50000 ? 10
|
|
42
|
+
: costPerEdit < 80000 ? 5 : 0;
|
|
22
43
|
// 6. Error penalty
|
|
23
44
|
score = Math.max(0, score - (toolErrorCount * 5));
|
|
24
45
|
return score;
|
|
@@ -62,12 +83,13 @@ function computeGrade(cacheHitRate, toolErrorCount, requests, readEditRatio) {
|
|
|
62
83
|
};
|
|
63
84
|
}
|
|
64
85
|
// ── Helper: compute danger level ──
|
|
65
|
-
function getDangerLevel(avgContextSize) {
|
|
66
|
-
|
|
86
|
+
function getDangerLevel(avgContextSize, modelName = 'sonnet') {
|
|
87
|
+
const normalizedCost = avgContextSize * getModelCostMultiplier(modelName);
|
|
88
|
+
if (normalizedCost < 10000)
|
|
67
89
|
return 'optimal';
|
|
68
|
-
if (
|
|
90
|
+
if (normalizedCost < 20000)
|
|
69
91
|
return 'safe';
|
|
70
|
-
if (
|
|
92
|
+
if (normalizedCost > 50000)
|
|
71
93
|
return 'critical';
|
|
72
94
|
return 'caution';
|
|
73
95
|
}
|
|
@@ -126,31 +148,38 @@ export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
|
126
148
|
let recordIndex = 0;
|
|
127
149
|
let toolResultCount = 0, toolErrorCount = 0;
|
|
128
150
|
let humanTurns = 0, autoTurns = 0, commandTurns = 0;
|
|
151
|
+
let sessionModel = 'sonnet';
|
|
129
152
|
for (const record of records) {
|
|
130
|
-
if (record.type === 'assistant'
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
if (record.type === 'assistant') {
|
|
154
|
+
// Extract model name from first assistant message
|
|
155
|
+
const model = extractModelName(record);
|
|
156
|
+
if (model && sessionModel === 'sonnet')
|
|
157
|
+
sessionModel = model;
|
|
158
|
+
if (record.message?.usage) {
|
|
159
|
+
const usage = record.message.usage;
|
|
160
|
+
inputTokens += usage.input_tokens || 0;
|
|
161
|
+
// outputTokens += usage.output_tokens || 0;
|
|
162
|
+
cacheRead += usage.cache_read_input_tokens || 0;
|
|
163
|
+
requests++;
|
|
164
|
+
if (record.message?.content && Array.isArray(record.message.content)) {
|
|
165
|
+
for (const block of record.message.content) {
|
|
166
|
+
if (block.type === 'tool_use') {
|
|
167
|
+
const toolName = block.name?.toLowerCase() || '';
|
|
168
|
+
if (toolName.includes('read') || toolName.includes('view') || toolName.includes('grep') || toolName.includes('glob') || toolName.includes('list')) {
|
|
169
|
+
readCount++;
|
|
170
|
+
const input = block.input || {};
|
|
171
|
+
const fp = input.file_path || input.path || '';
|
|
172
|
+
if (firstSpecReadIndex === -1 && (fp.includes('.claude/') || fp.includes('CLAUDE.md'))) {
|
|
173
|
+
firstSpecReadIndex = recordIndex;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (toolName.includes('edit') || toolName.includes('write') || toolName.includes('replace')) {
|
|
177
|
+
editCount++;
|
|
178
|
+
const input = block.input;
|
|
179
|
+
const filePath = input?.file_path || input?.filePath;
|
|
180
|
+
if (filePath)
|
|
181
|
+
filesEdited.push(filePath);
|
|
146
182
|
}
|
|
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
183
|
}
|
|
155
184
|
}
|
|
156
185
|
}
|
|
@@ -224,8 +253,7 @@ export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
|
224
253
|
lastRecordRaw?.type === 'system' ? 'forced' : 'unknown';
|
|
225
254
|
const totalContextSent = inputTokens + cacheRead;
|
|
226
255
|
const avgContextSize = requests > 0 ? Math.round(totalContextSent / requests) : 0;
|
|
227
|
-
const dangerLevel = getDangerLevel(avgContextSize);
|
|
228
|
-
const limitImpact = Math.round((totalContextSent / 44000) * 100);
|
|
256
|
+
const dangerLevel = getDangerLevel(avgContextSize, sessionModel);
|
|
229
257
|
const uniqueFilesRead = new Set(filesReadList).size;
|
|
230
258
|
const duplicateReads = filesReadList.length - uniqueFilesRead;
|
|
231
259
|
const duplicateReadRate = filesReadList.length > 0 ? Math.round((duplicateReads / filesReadList.length) * 100) : 0;
|
|
@@ -236,7 +264,7 @@ export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
|
236
264
|
const tokensPerEdit = editCount > 0 ? Math.round(totalContextSent / editCount) : 0;
|
|
237
265
|
const toolErrorRate = toolResultCount > 0 ? Math.round((toolErrorCount / toolResultCount) * 1000) / 10 : 0;
|
|
238
266
|
const cacheHitRate = (inputTokens + cacheRead) > 0 ? (cacheRead / (inputTokens + cacheRead)) * 100 : 0;
|
|
239
|
-
const efficiencyScore = computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit);
|
|
267
|
+
const efficiencyScore = computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit, sessionModel);
|
|
240
268
|
const sei = computeSEI(postSpecReadTotal, postSpecReadErrors, cacheRead, new Set(specFilesRead).size);
|
|
241
269
|
const grade = computeGrade(cacheHitRate, toolErrorCount, requests, readEditRatio);
|
|
242
270
|
// Timestamps & duration
|
|
@@ -262,7 +290,6 @@ export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
|
262
290
|
cache_hit_rate: Math.round(cacheHitRate * 10) / 10,
|
|
263
291
|
spec_trigger_rate: specTriggerRate,
|
|
264
292
|
danger_level: dangerLevel,
|
|
265
|
-
limit_impact: limitImpact,
|
|
266
293
|
},
|
|
267
294
|
tool_efficiency: {
|
|
268
295
|
read_edit_ratio: readEditRatio,
|
|
@@ -280,10 +307,8 @@ export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
|
280
307
|
human_turns: humanTurns,
|
|
281
308
|
auto_turns: effectiveAutoTurns,
|
|
282
309
|
autonomy_rate: autonomyRate,
|
|
283
|
-
ht_per_edit: editCount > 0 ? Math.round((humanTurns / editCount) * 10) / 10 : 0,
|
|
284
310
|
},
|
|
285
311
|
workflow_autonomy: {
|
|
286
|
-
repeated_edit_rate: repeatedEditRate,
|
|
287
312
|
efficiency_score: efficiencyScore,
|
|
288
313
|
sei,
|
|
289
314
|
},
|
|
@@ -295,15 +320,14 @@ export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
|
295
320
|
sessions.push({
|
|
296
321
|
session_id: file.replace('.jsonl', ''),
|
|
297
322
|
project: project.name,
|
|
323
|
+
model: sessionModel,
|
|
298
324
|
start_time: startTs,
|
|
299
325
|
end_time: endTs,
|
|
300
326
|
git_branch: firstTimestampRecord?.gitBranch || '',
|
|
301
327
|
total_requests: requests,
|
|
302
328
|
avg_context_size: avgContextSize,
|
|
303
329
|
danger_level: dangerLevel,
|
|
304
|
-
limit_impact: limitImpact,
|
|
305
330
|
total_context_tokens: totalContextSent,
|
|
306
|
-
total_output_tokens: 0,
|
|
307
331
|
duplicate_read_rate: duplicateReadRate,
|
|
308
332
|
read_edit_ratio: readEditRatio,
|
|
309
333
|
repeated_edit_rate: repeatedEditRate,
|
|
@@ -318,7 +342,7 @@ export function getSessionsList(projectsDir, projectPath, startDate, endDate) {
|
|
|
318
342
|
human_turns: humanTurns,
|
|
319
343
|
auto_turns: effectiveAutoTurns,
|
|
320
344
|
command_turns: commandTurns,
|
|
321
|
-
|
|
345
|
+
edit_count: editCount,
|
|
322
346
|
spec_efficiency: sei,
|
|
323
347
|
grade_breakdown: grade.breakdown,
|
|
324
348
|
anthropic_metrics: anthropicMetrics,
|
|
@@ -377,6 +401,7 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
377
401
|
let firstSpecReadIndex = -1;
|
|
378
402
|
let postSpecReadTotal = 0, postSpecReadErrors = 0;
|
|
379
403
|
let recordIndex = 0;
|
|
404
|
+
let sessionModel = 'sonnet';
|
|
380
405
|
const toolIdMap = new Map();
|
|
381
406
|
const errorMap = new Map();
|
|
382
407
|
// Track pending questions from interactive tools (AskFollowupQuestion etc.)
|
|
@@ -384,6 +409,8 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
384
409
|
// Track which files were read by the assistant via tool_use
|
|
385
410
|
// so we can deduplicate them from user's "context load" display
|
|
386
411
|
let lastAssistantReadFiles = new Set();
|
|
412
|
+
// ── Skill tracking: detect slash commands and associate subsequent work ──
|
|
413
|
+
let activeSkill = null;
|
|
387
414
|
let messageId = 0;
|
|
388
415
|
for (const record of records) {
|
|
389
416
|
if (record.type === 'user') {
|
|
@@ -405,6 +432,8 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
405
432
|
const cmdArgs = argsMatch ? argsMatch[1].trim() : '';
|
|
406
433
|
content = cmdArgs ? `${cmdName} ${cmdArgs}` : cmdName;
|
|
407
434
|
messageSubtype = 'command';
|
|
435
|
+
// Set active skill from slash command
|
|
436
|
+
activeSkill = cmdMatch ? cmdMatch[1].replace(/^\//, '') : null;
|
|
408
437
|
}
|
|
409
438
|
else if (raw.startsWith('This session is being continued')) {
|
|
410
439
|
content = '(세션 이어하기 — 이전 대화 요약 자동 삽입)';
|
|
@@ -413,6 +442,8 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
413
442
|
else {
|
|
414
443
|
content = raw;
|
|
415
444
|
messageSubtype = 'human';
|
|
445
|
+
// User typed a message → reset active skill
|
|
446
|
+
activeSkill = null;
|
|
416
447
|
}
|
|
417
448
|
}
|
|
418
449
|
else if (Array.isArray(msgContent)) {
|
|
@@ -495,11 +526,16 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
495
526
|
timestamp: record.timestamp || '',
|
|
496
527
|
content,
|
|
497
528
|
files_read: fileList,
|
|
498
|
-
tool_uses: toolUses
|
|
529
|
+
tool_uses: toolUses,
|
|
530
|
+
skill_name: messageSubtype === 'command' ? activeSkill || undefined : undefined,
|
|
499
531
|
});
|
|
500
532
|
}
|
|
501
533
|
}
|
|
502
534
|
else if (record.type === 'assistant') {
|
|
535
|
+
// Extract model name from first assistant message
|
|
536
|
+
const model = extractModelName(record);
|
|
537
|
+
if (model && sessionModel === 'sonnet')
|
|
538
|
+
sessionModel = model;
|
|
503
539
|
const usage = record.message?.usage;
|
|
504
540
|
const content = [];
|
|
505
541
|
const toolUses = [];
|
|
@@ -520,6 +556,7 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
520
556
|
const nameLower = toolName.toLowerCase();
|
|
521
557
|
if (nameLower === 'read' || nameLower === 'view') {
|
|
522
558
|
const fp = input.file_path || input.filePath || '';
|
|
559
|
+
const isSpec = fp.includes('.claude/') || fp.includes('CLAUDE.md');
|
|
523
560
|
if (fp) {
|
|
524
561
|
detail = fp;
|
|
525
562
|
// Track that this file was read by assistant tool
|
|
@@ -543,18 +580,24 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
543
580
|
firstSpecReadIndex = recordIndex;
|
|
544
581
|
}
|
|
545
582
|
}
|
|
583
|
+
toolUses.push({ name: toolName, detail: detail || undefined, category: isSpec ? 'read_spec' : 'read_code' });
|
|
584
|
+
continue;
|
|
546
585
|
}
|
|
547
586
|
else if (nameLower === 'edit' || nameLower === 'replace') {
|
|
548
587
|
detail = input.file_path || input.filePath || '';
|
|
549
588
|
editCount++;
|
|
550
589
|
if (detail)
|
|
551
590
|
filesEdited.push(detail);
|
|
591
|
+
toolUses.push({ name: toolName, detail: detail || undefined, category: 'edit' });
|
|
592
|
+
continue;
|
|
552
593
|
}
|
|
553
594
|
else if (nameLower === 'write') {
|
|
554
595
|
detail = input.file_path || input.filePath || '';
|
|
555
596
|
editCount++;
|
|
556
597
|
if (detail)
|
|
557
598
|
filesEdited.push(detail);
|
|
599
|
+
toolUses.push({ name: toolName, detail: detail || undefined, category: 'edit' });
|
|
600
|
+
continue;
|
|
558
601
|
}
|
|
559
602
|
else if (nameLower === 'bash') {
|
|
560
603
|
detail = input.command || '';
|
|
@@ -625,7 +668,15 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
625
668
|
filesEdited.push(detail);
|
|
626
669
|
}
|
|
627
670
|
}
|
|
628
|
-
toolUses.push({
|
|
671
|
+
toolUses.push({
|
|
672
|
+
name: toolName, detail: detail || undefined,
|
|
673
|
+
category: nameLower.includes('read') || nameLower.includes('view') || nameLower.includes('list')
|
|
674
|
+
? (detail && (detail.includes('.claude/') || detail.includes('CLAUDE.md')) ? 'read_spec' : 'read_code')
|
|
675
|
+
: nameLower.includes('grep') || nameLower.includes('glob') || nameLower.includes('search')
|
|
676
|
+
? 'search'
|
|
677
|
+
: nameLower.includes('edit') || nameLower.includes('write') || nameLower.includes('replace')
|
|
678
|
+
? 'edit' : 'other'
|
|
679
|
+
});
|
|
629
680
|
}
|
|
630
681
|
}
|
|
631
682
|
}
|
|
@@ -642,7 +693,8 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
642
693
|
content: content.join('\n'),
|
|
643
694
|
usage,
|
|
644
695
|
files_read: [],
|
|
645
|
-
tool_uses: toolUses
|
|
696
|
+
tool_uses: toolUses,
|
|
697
|
+
skill_name: activeSkill || undefined,
|
|
646
698
|
});
|
|
647
699
|
}
|
|
648
700
|
recordIndex++;
|
|
@@ -655,8 +707,7 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
655
707
|
messages.length > 0 && messages[messages.length - 1].type === 'assistant' ? 'clean' : 'unknown';
|
|
656
708
|
const totalContextSent = inputTokens + cacheRead;
|
|
657
709
|
const avgContextSize = requests > 0 ? Math.round(totalContextSent / requests) : 0;
|
|
658
|
-
const dangerLevel = getDangerLevel(avgContextSize);
|
|
659
|
-
const limitImpact = Math.round((totalContextSent / 44000) * 100);
|
|
710
|
+
const dangerLevel = getDangerLevel(avgContextSize, sessionModel);
|
|
660
711
|
const readEditRatio = editCount > 0 ? Math.round((readCount / editCount) * 10) / 10 : readCount;
|
|
661
712
|
const uniqueFilesRead = new Set(allReadFiles).size;
|
|
662
713
|
const duplicateReads = allReadFiles.length - uniqueFilesRead;
|
|
@@ -675,13 +726,14 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
675
726
|
const repeatedEditRate = filesEdited.length > 0 ? Math.round((repeatedEdits / filesEdited.length) * 100) : 0;
|
|
676
727
|
const tokensPerEdit = editCount > 0 ? Math.round(totalContextSent / editCount) : 0;
|
|
677
728
|
const cacheHitRate = (inputTokens + cacheRead) > 0 ? (cacheRead / (inputTokens + cacheRead)) * 100 : 0;
|
|
678
|
-
const efficiencyScore = computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit);
|
|
729
|
+
const efficiencyScore = computeEfficiencyScore(cacheHitRate, toolErrorRate, toolErrorCount, duplicateReadRate, readEditRatio, repeatedEditRate, sessionExit, tokensPerEdit, sessionModel);
|
|
679
730
|
const specFilesReadList = allReadFiles.filter(f => f.includes('.claude/') || f.includes('CLAUDE.md'));
|
|
680
731
|
const sei = computeSEI(postSpecReadTotal, postSpecReadErrors, cacheRead, specFilesReadList.length);
|
|
681
732
|
const grade = computeGrade(cacheHitRate, toolErrorCount, requests, readEditRatio);
|
|
682
733
|
return {
|
|
683
734
|
session_id: sessionId,
|
|
684
735
|
project: foundProject,
|
|
736
|
+
model: sessionModel,
|
|
685
737
|
start_time: firstRecord.timestamp || '',
|
|
686
738
|
end_time: lastRecord.timestamp || '',
|
|
687
739
|
git_branch: firstRecord?.gitBranch || '',
|
|
@@ -690,10 +742,38 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
690
742
|
total_requests: requests,
|
|
691
743
|
avg_context_size: avgContextSize,
|
|
692
744
|
danger_level: dangerLevel,
|
|
693
|
-
limit_impact: limitImpact,
|
|
694
745
|
total_context_tokens: totalContextSent,
|
|
695
746
|
files_read: [...new Set(filesRead)],
|
|
696
|
-
spec_files_read: [...new Set(specFilesRead)]
|
|
747
|
+
spec_files_read: [...new Set(specFilesRead)],
|
|
748
|
+
skills_loaded: (() => {
|
|
749
|
+
const skills = [];
|
|
750
|
+
const seen = new Set();
|
|
751
|
+
// Collect slash commands used
|
|
752
|
+
for (const msg of messages) {
|
|
753
|
+
if (msg.skill_name && !seen.has(`cmd:${msg.skill_name}`)) {
|
|
754
|
+
seen.add(`cmd:${msg.skill_name}`);
|
|
755
|
+
skills.push({ name: msg.skill_name, type: 'command' });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// Collect .claude/ spec files read (commands, settings, etc.)
|
|
759
|
+
for (const msg of messages) {
|
|
760
|
+
for (const tu of msg.tool_uses) {
|
|
761
|
+
if (tu.category === 'read_spec' && tu.detail) {
|
|
762
|
+
const p = tu.detail;
|
|
763
|
+
if (!seen.has(`file:${p}`)) {
|
|
764
|
+
seen.add(`file:${p}`);
|
|
765
|
+
// Extract a friendly name from the path
|
|
766
|
+
const match = p.match(/\.claude\/commands\/([^/]+?)(?:\.\w+)?$/) ||
|
|
767
|
+
p.match(/\.claude\/([^/]+?)(?:\.\w+)?$/) ||
|
|
768
|
+
p.match(/([^/]+?)(?:\.\w+)?$/);
|
|
769
|
+
const name = match ? match[1] : p;
|
|
770
|
+
skills.push({ name, type: 'spec_file', path: p });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return skills;
|
|
776
|
+
})()
|
|
697
777
|
},
|
|
698
778
|
quality: {
|
|
699
779
|
read_count: readCount,
|
|
@@ -716,7 +796,10 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
716
796
|
+ (readEditRatio >= 5 && readEditRatio <= 20 ? 10 : readEditRatio >= 3 ? 5 : 0)
|
|
717
797
|
+ (repeatedEditRate < 10 ? 5 : repeatedEditRate < 20 ? 3 : 0),
|
|
718
798
|
termination: sessionExit === 'clean' ? 10 : sessionExit === 'unknown' ? 5 : 0,
|
|
719
|
-
cost_efficiency:
|
|
799
|
+
cost_efficiency: (() => {
|
|
800
|
+
const costPerEdit = tokensPerEdit * getModelCostMultiplier(sessionModel);
|
|
801
|
+
return costPerEdit > 0 && costPerEdit < 30000 ? 15 : costPerEdit < 50000 ? 10 : costPerEdit < 80000 ? 5 : 0;
|
|
802
|
+
})()
|
|
720
803
|
},
|
|
721
804
|
spec_efficiency: sei,
|
|
722
805
|
grade_breakdown: grade.breakdown
|
|
@@ -735,7 +818,6 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
735
818
|
cache_hit_rate: Math.round(cacheHitRate * 10) / 10,
|
|
736
819
|
spec_trigger_rate: specTriggerRate,
|
|
737
820
|
danger_level: dangerLevel,
|
|
738
|
-
limit_impact: limitImpact,
|
|
739
821
|
},
|
|
740
822
|
tool_efficiency: {
|
|
741
823
|
read_edit_ratio: readEditRatio,
|
|
@@ -754,10 +836,8 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
754
836
|
human_turns: humanTurns,
|
|
755
837
|
auto_turns: autoTurns,
|
|
756
838
|
autonomy_rate: autonomyRate,
|
|
757
|
-
ht_per_edit: editCount > 0 ? Math.round((humanTurns / editCount) * 10) / 10 : 0,
|
|
758
839
|
},
|
|
759
840
|
workflow_autonomy: {
|
|
760
|
-
repeated_edit_rate: repeatedEditRate,
|
|
761
841
|
efficiency_score: Math.round(efficiencyScore),
|
|
762
842
|
sei,
|
|
763
843
|
},
|
|
@@ -775,7 +855,7 @@ export function getSessionDetail(projectsDir, sessionId, projectPath) {
|
|
|
775
855
|
export function getAnalytics(projectsDir, projectPath, startDate, endDate) {
|
|
776
856
|
const sessions = getSessionsList(projectsDir, projectPath, startDate, endDate);
|
|
777
857
|
const projects = getProjectsList(projectsDir);
|
|
778
|
-
let totalInput = 0,
|
|
858
|
+
let totalInput = 0, totalCacheRead = 0;
|
|
779
859
|
const projectsToScan = getProjectsToScan(projectsDir, projectPath);
|
|
780
860
|
for (const projectDir of projectsToScan) {
|
|
781
861
|
if (!fs.existsSync(projectDir.path))
|
|
@@ -788,7 +868,6 @@ export function getAnalytics(projectsDir, projectPath, startDate, endDate) {
|
|
|
788
868
|
if (record.type === 'assistant' && record.message?.usage) {
|
|
789
869
|
const usage = record.message.usage;
|
|
790
870
|
totalInput += usage.input_tokens || 0;
|
|
791
|
-
totalOutput += usage.output_tokens || 0;
|
|
792
871
|
totalCacheRead += usage.cache_read_input_tokens || 0;
|
|
793
872
|
}
|
|
794
873
|
}
|
|
@@ -798,32 +877,44 @@ export function getAnalytics(projectsDir, projectPath, startDate, endDate) {
|
|
|
798
877
|
const totalRequests = sessions.reduce((sum, s) => sum + s.total_requests, 0);
|
|
799
878
|
const avgContextSize = totalRequests > 0 ? Math.round(totalContextSent / totalRequests) : 0;
|
|
800
879
|
const dangerLevel = getDangerLevel(avgContextSize);
|
|
801
|
-
const limitImpact = Math.round((totalContextSent / 44000) * 100);
|
|
802
880
|
const avgEfficiency = sessions.length > 0 ? Math.round(sessions.reduce((sum, s) => sum + s.efficiency_score, 0) / sessions.length) : 0;
|
|
803
881
|
const avgToolErrorRate = sessions.length > 0 ? Math.round(sessions.reduce((sum, s) => sum + s.tool_error_rate, 0) / sessions.length * 10) / 10 : 0;
|
|
804
|
-
// Hypothesis Check
|
|
882
|
+
// Hypothesis Check — Anthropic-aligned metrics (HT/E, SEI, P99)
|
|
805
883
|
const withSpec = sessions.filter(s => s.has_spec_context && s.total_requests > 0);
|
|
806
884
|
const withoutSpec = sessions.filter(s => !s.has_spec_context && s.total_requests > 0);
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const
|
|
815
|
-
const
|
|
816
|
-
const
|
|
817
|
-
? Math.round((1 -
|
|
885
|
+
// HT/E: Human Turns per Edit (lower = more autonomous)
|
|
886
|
+
const computeAvgHTE = (arr) => {
|
|
887
|
+
const valid = arr.filter(s => s.edit_count > 0);
|
|
888
|
+
if (valid.length === 0)
|
|
889
|
+
return 0;
|
|
890
|
+
return Math.round(valid.reduce((sum, s) => sum + (s.human_turns / s.edit_count), 0) / valid.length * 100) / 100;
|
|
891
|
+
};
|
|
892
|
+
const hteWithSpec = computeAvgHTE(withSpec);
|
|
893
|
+
const hteWithoutSpec = computeAvgHTE(withoutSpec);
|
|
894
|
+
const hteImprovement = hteWithoutSpec > 0
|
|
895
|
+
? Math.round((1 - hteWithSpec / hteWithoutSpec) * 1000) / 10 : 0;
|
|
896
|
+
// SEI: Spec Efficiency Index comparison
|
|
897
|
+
const computeAvgSEI = (arr) => {
|
|
898
|
+
const valid = arr.filter(s => s.spec_efficiency && s.spec_efficiency.sei_score > 0);
|
|
899
|
+
if (valid.length === 0)
|
|
900
|
+
return 0;
|
|
901
|
+
return Math.round(valid.reduce((sum, s) => sum + (s.spec_efficiency?.sei_score || 0), 0) / valid.length * 10) / 10;
|
|
902
|
+
};
|
|
903
|
+
// P99 duration: 99th percentile tail-end (minutes)
|
|
904
|
+
const computeP99Duration = (arr) => {
|
|
905
|
+
if (arr.length === 0)
|
|
906
|
+
return 0;
|
|
907
|
+
const sorted = [...arr].map(s => s.duration_minutes).sort((a, b) => a - b);
|
|
908
|
+
const idx = Math.min(Math.floor(sorted.length * 0.99), sorted.length - 1);
|
|
909
|
+
return sorted[idx];
|
|
910
|
+
};
|
|
818
911
|
return {
|
|
819
912
|
summary: {
|
|
820
913
|
total_sessions: sessions.length,
|
|
821
914
|
total_projects: projects.length,
|
|
822
915
|
total_context_tokens: totalContextSent,
|
|
823
|
-
total_output_tokens: totalOutput,
|
|
824
916
|
avg_context_size: avgContextSize,
|
|
825
917
|
danger_level: dangerLevel,
|
|
826
|
-
limit_impact: limitImpact,
|
|
827
918
|
optimal_sessions: sessions.filter(s => s.danger_level === 'optimal').length,
|
|
828
919
|
safe_sessions: sessions.filter(s => s.danger_level === 'safe').length,
|
|
829
920
|
caution_sessions: sessions.filter(s => s.danger_level === 'caution').length,
|
|
@@ -837,16 +928,13 @@ export function getAnalytics(projectsDir, projectPath, startDate, endDate) {
|
|
|
837
928
|
clean_exits: sessions.filter(s => s.session_exit === 'clean').length,
|
|
838
929
|
forced_exits: sessions.filter(s => s.session_exit === 'forced').length,
|
|
839
930
|
hypothesis_check: {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
normalized_improvement: normalizedImprovement,
|
|
848
|
-
avg_duration_with_spec: avg(withSpec, s => s.duration_minutes),
|
|
849
|
-
avg_duration_without_spec: avg(withoutSpec, s => s.duration_minutes),
|
|
931
|
+
hte_with_spec: hteWithSpec,
|
|
932
|
+
hte_without_spec: hteWithoutSpec,
|
|
933
|
+
hte_improvement: hteImprovement,
|
|
934
|
+
avg_sei_with_spec: computeAvgSEI(withSpec),
|
|
935
|
+
avg_sei_without_spec: computeAvgSEI(withoutSpec),
|
|
936
|
+
p99_duration_with_spec: computeP99Duration(withSpec),
|
|
937
|
+
p99_duration_without_spec: computeP99Duration(withoutSpec),
|
|
850
938
|
sessions_with_spec: withSpec.length,
|
|
851
939
|
sessions_without_spec: withoutSpec.length
|
|
852
940
|
},
|
|
@@ -860,8 +948,6 @@ export function getAnalytics(projectsDir, projectPath, startDate, endDate) {
|
|
|
860
948
|
total_tool_errors: sessions.reduce((sum, s) => sum + s.anthropic_metrics.api_reliability.tool_error_count, 0),
|
|
861
949
|
avg_autonomy_rate: sessions.length > 0
|
|
862
950
|
? Math.round(sessions.reduce((sum, s) => sum + s.anthropic_metrics.user_intervention.autonomy_rate, 0) / sessions.length * 10) / 10 : 0,
|
|
863
|
-
avg_ht_per_edit: sessions.length > 0
|
|
864
|
-
? Math.round(sessions.reduce((sum, s) => sum + s.anthropic_metrics.user_intervention.ht_per_edit, 0) / sessions.length * 10) / 10 : 0,
|
|
865
951
|
workflow_completion_rate: sessions.length > 0
|
|
866
952
|
? Math.round(sessions.filter(s => s.anthropic_metrics.workflow_autonomy.efficiency_score >= 60).length / sessions.length * 1000) / 10 : 0,
|
|
867
953
|
grade_consistency: sessions.length > 0
|
package/dist/server/index.js
CHANGED
|
@@ -94,18 +94,20 @@ app.post('/api/refresh', (req, res) => {
|
|
|
94
94
|
// Static file serving (SPA)
|
|
95
95
|
// ════════════════════════════════════════════════════════════
|
|
96
96
|
const distPath = path.resolve(__dirname, '../client');
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
const distPathAlt = path.resolve(process.cwd(), 'dist/client');
|
|
98
|
+
const resolvedDistPath = fs.existsSync(distPath) ? distPath : distPathAlt;
|
|
99
|
+
const indexHtmlPath = path.join(resolvedDistPath, 'index.html');
|
|
100
|
+
if (fs.existsSync(resolvedDistPath)) {
|
|
101
|
+
app.use(express.static(resolvedDistPath));
|
|
100
102
|
app.use((req, res, next) => {
|
|
101
103
|
if (req.path.startsWith('/api'))
|
|
102
104
|
return next();
|
|
103
105
|
res.sendFile(indexHtmlPath);
|
|
104
106
|
});
|
|
105
|
-
console.log(`📦 Serving frontend from: ${
|
|
107
|
+
console.log(`📦 Serving frontend from: ${resolvedDistPath}`);
|
|
106
108
|
}
|
|
107
109
|
else {
|
|
108
|
-
console.log(`⚠️ Frontend not found at: ${
|
|
110
|
+
console.log(`⚠️ Frontend not found at: ${resolvedDistPath}`);
|
|
109
111
|
console.log(` Run in dev mode: npm run dev`);
|
|
110
112
|
}
|
|
111
113
|
// ════════════════════════════════════════════════════════════
|