claude-session-insights 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/bin/cli.js +49 -0
- package/package.json +30 -0
- package/public/index.html +1560 -0
- package/src/ai-analyze.js +277 -0
- package/src/export.js +79 -0
- package/src/parser.js +273 -0
- package/src/scorer.js +464 -0
- package/src/server.js +229 -0
- package/src/summarizer.js +253 -0
package/src/scorer.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// Efficiency scoring engine
|
|
2
|
+
// Computes per-session score (0-100), overall score, tips, badges, and summaries.
|
|
3
|
+
|
|
4
|
+
import { generateOverallSummary, generateSessionSummary } from "./summarizer.js";
|
|
5
|
+
|
|
6
|
+
const WEIGHTS = {
|
|
7
|
+
toolRatio: 0.3,
|
|
8
|
+
cacheHitRate: 0.25,
|
|
9
|
+
contextManagement: 0.2,
|
|
10
|
+
modelFit: 0.15,
|
|
11
|
+
promptSpecificity: 0.1,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Score a single dimension 0-100
|
|
15
|
+
function scoreToolRatio(session) {
|
|
16
|
+
const { toolCalls, userMessages } = session.totals;
|
|
17
|
+
if (userMessages === 0) return 50;
|
|
18
|
+
const ratio = toolCalls / userMessages;
|
|
19
|
+
// < 2 is great (100), 2-5 is ok (50-100), > 5 is poor (0-50)
|
|
20
|
+
if (ratio <= 2) return 100;
|
|
21
|
+
if (ratio <= 5) return 100 - ((ratio - 2) / 3) * 50;
|
|
22
|
+
if (ratio <= 10) return 50 - ((ratio - 5) / 5) * 50;
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scoreCacheHitRate(session) {
|
|
27
|
+
const rate = session.totals.cacheHitRate;
|
|
28
|
+
// > 60% is great, < 20% is poor
|
|
29
|
+
if (rate >= 0.75) return 100;
|
|
30
|
+
if (rate >= 0.6) return 80 + ((rate - 0.6) / 0.15) * 20;
|
|
31
|
+
if (rate >= 0.2) return 20 + ((rate - 0.2) / 0.4) * 60;
|
|
32
|
+
return rate / 0.2 * 20;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function scoreContextManagement(session) {
|
|
36
|
+
const assistantTurns = session.turns.filter((t) => t.role === "assistant" && t.cost);
|
|
37
|
+
if (assistantTurns.length < 5) return 70; // too short to judge
|
|
38
|
+
|
|
39
|
+
// Find cost inflection: where rolling avg of cost-per-turn doubles
|
|
40
|
+
const costs = assistantTurns.map((t) => t.cost);
|
|
41
|
+
const windowSize = 3;
|
|
42
|
+
let baselineCost = 0;
|
|
43
|
+
for (let i = 0; i < Math.min(windowSize, costs.length); i++) {
|
|
44
|
+
baselineCost += costs[i];
|
|
45
|
+
}
|
|
46
|
+
baselineCost /= Math.min(windowSize, costs.length);
|
|
47
|
+
|
|
48
|
+
if (baselineCost === 0) return 70;
|
|
49
|
+
|
|
50
|
+
let inflectionTurn = null;
|
|
51
|
+
for (let i = windowSize; i <= costs.length - windowSize; i++) {
|
|
52
|
+
let windowAvg = 0;
|
|
53
|
+
for (let j = i; j < i + windowSize; j++) {
|
|
54
|
+
windowAvg += costs[j];
|
|
55
|
+
}
|
|
56
|
+
windowAvg /= windowSize;
|
|
57
|
+
if (windowAvg > baselineCost * 2) {
|
|
58
|
+
inflectionTurn = i;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!inflectionTurn) return 90; // no runaway cost, good
|
|
64
|
+
|
|
65
|
+
// Did they clear near the inflection?
|
|
66
|
+
const clearedNearInflection = session.clearPoints.some(
|
|
67
|
+
(cp) => Math.abs(cp - inflectionTurn) <= 5
|
|
68
|
+
);
|
|
69
|
+
if (clearedNearInflection) return 85;
|
|
70
|
+
|
|
71
|
+
// How much of the session ran past inflection?
|
|
72
|
+
const fractionPastInflection = (assistantTurns.length - inflectionTurn) / assistantTurns.length;
|
|
73
|
+
return Math.max(0, 70 - fractionPastInflection * 70);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scoreModelFit(session) {
|
|
77
|
+
const model = session.model || "";
|
|
78
|
+
const { userMessages, toolCalls, estimatedCost } = session.totals;
|
|
79
|
+
const isOpus = model.includes("opus");
|
|
80
|
+
|
|
81
|
+
if (!isOpus) return 80;
|
|
82
|
+
|
|
83
|
+
// Tool-to-message ratio: high ratio = mechanical work (edits, grep, bash)
|
|
84
|
+
const toolRatio = userMessages > 0 ? toolCalls / userMessages : 0;
|
|
85
|
+
|
|
86
|
+
// Cost per user message: how expensive is each interaction?
|
|
87
|
+
const costPerMsg = userMessages > 0 ? estimatedCost / userMessages : 0;
|
|
88
|
+
|
|
89
|
+
// Quick questions: few messages, low engagement — definitely Sonnet territory
|
|
90
|
+
if (userMessages <= 3) return 20;
|
|
91
|
+
|
|
92
|
+
// High tool ratio (>5x) = mostly automated work, Sonnet handles this fine
|
|
93
|
+
if (toolRatio > 5) return 40;
|
|
94
|
+
|
|
95
|
+
// Expensive per-message but tool-heavy: implementation work, not deep reasoning
|
|
96
|
+
if (toolRatio > 3 && costPerMsg > 0.5) return 50;
|
|
97
|
+
|
|
98
|
+
// Moderate sessions: some back-and-forth, some tool use
|
|
99
|
+
if (toolRatio > 2) return 60;
|
|
100
|
+
|
|
101
|
+
// Low tool ratio (<2x) with many messages = discussion/review/reasoning
|
|
102
|
+
// This is where Opus genuinely shines
|
|
103
|
+
if (userMessages >= 10 && toolRatio <= 2) return 90;
|
|
104
|
+
|
|
105
|
+
return 75;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scorePromptSpecificity(session) {
|
|
109
|
+
const userTurns = session.turns.filter((t) => t.role === "user");
|
|
110
|
+
if (userTurns.length === 0) return 50;
|
|
111
|
+
|
|
112
|
+
let vagueCount = 0;
|
|
113
|
+
for (let i = 0; i < userTurns.length; i++) {
|
|
114
|
+
const turn = userTurns[i];
|
|
115
|
+
if (turn.promptLength < 30) {
|
|
116
|
+
// Check if the next assistant response was expensive
|
|
117
|
+
const turnIdx = session.turns.indexOf(turn);
|
|
118
|
+
const nextAssistant = session.turns
|
|
119
|
+
.slice(turnIdx + 1)
|
|
120
|
+
.find((t) => t.role === "assistant");
|
|
121
|
+
if (nextAssistant && nextAssistant.tokens.input + nextAssistant.tokens.output > 50_000) {
|
|
122
|
+
vagueCount++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const vagueRate = vagueCount / userTurns.length;
|
|
128
|
+
if (vagueRate === 0) return 100;
|
|
129
|
+
if (vagueRate < 0.1) return 80;
|
|
130
|
+
if (vagueRate < 0.3) return 50;
|
|
131
|
+
return 20;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function scoreSession(session) {
|
|
135
|
+
const dimensions = {
|
|
136
|
+
toolRatio: Math.round(scoreToolRatio(session)),
|
|
137
|
+
cacheHitRate: Math.round(scoreCacheHitRate(session)),
|
|
138
|
+
contextManagement: Math.round(scoreContextManagement(session)),
|
|
139
|
+
modelFit: Math.round(scoreModelFit(session)),
|
|
140
|
+
promptSpecificity: Math.round(scorePromptSpecificity(session)),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const score = Math.round(
|
|
144
|
+
Object.entries(WEIGHTS).reduce(
|
|
145
|
+
(sum, [key, weight]) => sum + dimensions[key] * weight,
|
|
146
|
+
0
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const suggestedModel = suggestModel(session, dimensions);
|
|
151
|
+
return { score, dimensions, suggestedModel };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function suggestModel(session, dimensions) {
|
|
155
|
+
const model = session.model || "";
|
|
156
|
+
const isOpus = model.includes("opus");
|
|
157
|
+
const isHaiku = model.includes("haiku");
|
|
158
|
+
|
|
159
|
+
if (isOpus && dimensions.modelFit <= 20) return "haiku";
|
|
160
|
+
if (isOpus && dimensions.modelFit <= 60) return "sonnet";
|
|
161
|
+
if (isHaiku && dimensions.modelFit < 60) return "sonnet";
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Tips ---
|
|
167
|
+
|
|
168
|
+
function findCostInflection(session) {
|
|
169
|
+
const assistantTurns = session.turns.filter((t) => t.role === "assistant" && t.cost);
|
|
170
|
+
if (assistantTurns.length < 6) return null;
|
|
171
|
+
|
|
172
|
+
const costs = assistantTurns.map((t) => t.cost);
|
|
173
|
+
const windowSize = 3;
|
|
174
|
+
let baseline = 0;
|
|
175
|
+
for (let i = 0; i < windowSize; i++) baseline += costs[i];
|
|
176
|
+
baseline /= windowSize;
|
|
177
|
+
|
|
178
|
+
if (baseline === 0) return null;
|
|
179
|
+
|
|
180
|
+
for (let i = windowSize; i <= costs.length - windowSize; i++) {
|
|
181
|
+
let avg = 0;
|
|
182
|
+
for (let j = i; j < i + windowSize; j++) avg += costs[j];
|
|
183
|
+
avg /= windowSize;
|
|
184
|
+
if (avg > baseline * 2) return i;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function generateTips(session) {
|
|
190
|
+
const tips = [];
|
|
191
|
+
const { toolCalls, userMessages, cacheHitRate } = session.totals;
|
|
192
|
+
const model = session.model || "";
|
|
193
|
+
|
|
194
|
+
// High tool ratio
|
|
195
|
+
if (userMessages > 0 && toolCalls / userMessages > 5) {
|
|
196
|
+
tips.push({
|
|
197
|
+
type: "high-tool-ratio",
|
|
198
|
+
severity: "warning",
|
|
199
|
+
message: `${toolCalls} tool calls across ${userMessages} messages (${(toolCalls / userMessages).toFixed(1)}x ratio). Try specifying file paths and line numbers to reduce searching.`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Low cache hit rate
|
|
204
|
+
if (cacheHitRate < 0.2 && session.totals.totalTokens > 50_000) {
|
|
205
|
+
tips.push({
|
|
206
|
+
type: "low-cache-hits",
|
|
207
|
+
severity: "warning",
|
|
208
|
+
message: `Only ${(cacheHitRate * 100).toFixed(0)}% cache hit rate. Group related tasks in one session to warm the cache.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Cost inflection
|
|
213
|
+
const inflection = findCostInflection(session);
|
|
214
|
+
if (inflection !== null) {
|
|
215
|
+
const clearedNear = session.clearPoints.some((cp) => Math.abs(cp - inflection) <= 5);
|
|
216
|
+
if (!clearedNear) {
|
|
217
|
+
tips.push({
|
|
218
|
+
type: "cost-inflection",
|
|
219
|
+
severity: "info",
|
|
220
|
+
message: `Cost per turn doubled around turn ${inflection}. Consider using /clear around that point.`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Opus on simple task
|
|
226
|
+
if (model.includes("opus") && userMessages <= 10 && session.totals.totalTokens < 200_000) {
|
|
227
|
+
tips.push({
|
|
228
|
+
type: "model-mismatch",
|
|
229
|
+
severity: "info",
|
|
230
|
+
message: `Opus used for a ${userMessages}-message session. Sonnet handles quick tasks at 5x lower cost.`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return tips;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Badges ---
|
|
238
|
+
|
|
239
|
+
const BADGE_DEFINITIONS = [
|
|
240
|
+
{
|
|
241
|
+
id: "surgical-prompter",
|
|
242
|
+
name: "Surgical Prompter",
|
|
243
|
+
description: "Your prompts guide Claude straight to the answer — minimal tool thrashing means less time and tokens wasted on searching and retrying.",
|
|
244
|
+
criteria: "Tool call ratio < 2x across 5+ sessions",
|
|
245
|
+
test: (sessions) => {
|
|
246
|
+
const qualifying = sessions.filter(
|
|
247
|
+
(s) => s.totals.userMessages > 0 && s.totals.toolCalls / s.totals.userMessages < 2
|
|
248
|
+
);
|
|
249
|
+
return qualifying.length >= 5;
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "cache-whisperer",
|
|
254
|
+
name: "Cache Whisperer",
|
|
255
|
+
description: "You structure sessions so Claude reuses cached context instead of re-reading files — this dramatically cuts input token costs.",
|
|
256
|
+
criteria: "Cache hit rate > 75% across 5+ sessions",
|
|
257
|
+
test: (sessions) => {
|
|
258
|
+
const qualifying = sessions.filter(
|
|
259
|
+
(s) => s.totals.totalTokens > 10_000 && s.totals.cacheHitRate > 0.75
|
|
260
|
+
);
|
|
261
|
+
return qualifying.length >= 5;
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: "clean-slate",
|
|
266
|
+
name: "Clean Slate",
|
|
267
|
+
description: "You clear context before costs spiral — resetting at the right moment keeps sessions fast and cheap instead of letting them bloat.",
|
|
268
|
+
criteria: "Uses /clear near cost inflection in 3+ sessions",
|
|
269
|
+
test: (sessions) => {
|
|
270
|
+
let count = 0;
|
|
271
|
+
for (const s of sessions) {
|
|
272
|
+
const inflection = findCostInflection(s);
|
|
273
|
+
if (inflection !== null && s.clearPoints.some((cp) => Math.abs(cp - inflection) <= 5)) {
|
|
274
|
+
count++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return count >= 3;
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "model-sniper",
|
|
282
|
+
name: "Model Sniper",
|
|
283
|
+
description: "You pick the right model for the job — using Sonnet for quick tasks and reserving Opus for complex ones saves serious money.",
|
|
284
|
+
criteria: "Appropriate model selection > 90% of sessions",
|
|
285
|
+
test: (sessions) => {
|
|
286
|
+
if (sessions.length < 5) return false;
|
|
287
|
+
const appropriate = sessions.filter((s) => {
|
|
288
|
+
const model = s.model || "";
|
|
289
|
+
if (!model.includes("opus")) return true;
|
|
290
|
+
return s.totals.userMessages > 10 || s.totals.totalTokens > 200_000;
|
|
291
|
+
});
|
|
292
|
+
return appropriate.length / sessions.length > 0.9;
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "efficiency-diamond",
|
|
297
|
+
name: "Efficiency Diamond",
|
|
298
|
+
description: "Consistently high efficiency across all dimensions — you've built habits that keep every session lean and effective.",
|
|
299
|
+
criteria: "Overall score > 85 sustained over 7 days",
|
|
300
|
+
test: (sessions, scoredSessions) => {
|
|
301
|
+
if (!scoredSessions || scoredSessions.length < 5) return false;
|
|
302
|
+
const avg =
|
|
303
|
+
scoredSessions.reduce((sum, s) => sum + s.score, 0) / scoredSessions.length;
|
|
304
|
+
return avg > 85;
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
const NEGATIVE_BADGE_DEFINITIONS = [
|
|
310
|
+
{
|
|
311
|
+
id: "opus-addict",
|
|
312
|
+
name: "Opus Addict",
|
|
313
|
+
description: "You're reaching for the most expensive model even when a cheaper one would do the job just as well — that's a lot of money left on the table.",
|
|
314
|
+
criteria: ">70% of sessions use Opus when Sonnet would suffice",
|
|
315
|
+
test: (sessions, scoredSessions) => {
|
|
316
|
+
if (!scoredSessions || scoredSessions.length < 5) return false;
|
|
317
|
+
const opusMisuse = scoredSessions.filter((s) => {
|
|
318
|
+
const model = s.model || "";
|
|
319
|
+
return model.includes("opus") && s.dimensions.modelFit <= 60;
|
|
320
|
+
});
|
|
321
|
+
return opusMisuse.length / scoredSessions.length > 0.7;
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: "token-furnace",
|
|
326
|
+
name: "Token Furnace",
|
|
327
|
+
description: "Your sessions burn through tokens like firewood — try being more specific in prompts and clearing context when it gets stale.",
|
|
328
|
+
criteria: "Average cost per user message > $0.50 across 5+ sessions",
|
|
329
|
+
test: (sessions) => {
|
|
330
|
+
const qualifying = sessions.filter((s) => s.totals.userMessages >= 3);
|
|
331
|
+
if (qualifying.length < 5) return false;
|
|
332
|
+
const avgCostPerMsg =
|
|
333
|
+
qualifying.reduce((sum, s) => sum + s.totals.estimatedCost / s.totals.userMessages, 0) /
|
|
334
|
+
qualifying.length;
|
|
335
|
+
return avgCostPerMsg > 0.5;
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: "context-hoarder",
|
|
340
|
+
name: "Context Hoarder",
|
|
341
|
+
description: "You let context bloat until every turn costs a fortune — a well-timed /clear can cut costs dramatically.",
|
|
342
|
+
criteria: "Cost inflection without /clear in 50%+ of long sessions",
|
|
343
|
+
test: (sessions) => {
|
|
344
|
+
const longSessions = sessions.filter(
|
|
345
|
+
(s) => s.turns.filter((t) => t.role === "assistant" && t.cost).length >= 6
|
|
346
|
+
);
|
|
347
|
+
if (longSessions.length < 3) return false;
|
|
348
|
+
let inflatedCount = 0;
|
|
349
|
+
for (const s of longSessions) {
|
|
350
|
+
const inflection = findCostInflection(s);
|
|
351
|
+
if (inflection !== null && !s.clearPoints.some((cp) => Math.abs(cp - inflection) <= 5)) {
|
|
352
|
+
inflatedCount++;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return inflatedCount / longSessions.length >= 0.5;
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: "vague-commander",
|
|
360
|
+
name: "Vague Commander",
|
|
361
|
+
description: "Claude spends more time guessing what you want than doing it — adding file paths, line numbers, and specifics to your prompts would save a lot of tokens.",
|
|
362
|
+
criteria: ">30% of prompts are vague and trigger expensive responses",
|
|
363
|
+
test: (sessions) => {
|
|
364
|
+
let totalUser = 0;
|
|
365
|
+
let vagueCount = 0;
|
|
366
|
+
for (const s of sessions) {
|
|
367
|
+
const userTurns = s.turns.filter((t) => t.role === "user");
|
|
368
|
+
totalUser += userTurns.length;
|
|
369
|
+
for (const turn of userTurns) {
|
|
370
|
+
if (turn.promptLength < 30) {
|
|
371
|
+
const turnIdx = s.turns.indexOf(turn);
|
|
372
|
+
const nextAssistant = s.turns.slice(turnIdx + 1).find((t) => t.role === "assistant");
|
|
373
|
+
if (nextAssistant && nextAssistant.tokens.input + nextAssistant.tokens.output > 50_000) {
|
|
374
|
+
vagueCount++;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return totalUser >= 10 && vagueCount / totalUser > 0.3;
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
export function evaluateBadges(sessions, scoredSessions) {
|
|
385
|
+
const positive = BADGE_DEFINITIONS.filter((b) => b.test(sessions, scoredSessions)).map((b) => ({
|
|
386
|
+
id: b.id,
|
|
387
|
+
name: b.name,
|
|
388
|
+
description: b.description,
|
|
389
|
+
criteria: b.criteria,
|
|
390
|
+
negative: false,
|
|
391
|
+
}));
|
|
392
|
+
const negative = NEGATIVE_BADGE_DEFINITIONS.filter((b) => b.test(sessions, scoredSessions)).map((b) => ({
|
|
393
|
+
id: b.id,
|
|
394
|
+
name: b.name,
|
|
395
|
+
description: b.description,
|
|
396
|
+
criteria: b.criteria,
|
|
397
|
+
negative: true,
|
|
398
|
+
}));
|
|
399
|
+
return [...positive, ...negative];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// --- Aggregate scoring ---
|
|
403
|
+
|
|
404
|
+
export function scoreAllSessions(sessions) {
|
|
405
|
+
const scored = sessions.map((session) => {
|
|
406
|
+
const { score, dimensions, suggestedModel } = scoreSession(session);
|
|
407
|
+
const tips = generateTips(session);
|
|
408
|
+
const summary = generateSessionSummary({ ...session, score, dimensions, tips });
|
|
409
|
+
return { ...session, score, dimensions, suggestedModel, tips, summary };
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const recentSessions = scored.filter((s) => {
|
|
413
|
+
if (!s.startTime) return false;
|
|
414
|
+
const age = Date.now() - new Date(s.startTime).getTime();
|
|
415
|
+
return age < 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const sessionsForOverall = recentSessions.length > 0 ? recentSessions : scored;
|
|
419
|
+
const overallScore =
|
|
420
|
+
sessionsForOverall.length > 0
|
|
421
|
+
? Math.round(
|
|
422
|
+
sessionsForOverall.reduce((sum, s) => sum + s.score, 0) / sessionsForOverall.length
|
|
423
|
+
)
|
|
424
|
+
: 0;
|
|
425
|
+
|
|
426
|
+
const badges = evaluateBadges(sessions, scored);
|
|
427
|
+
|
|
428
|
+
// Collect all tips, deduplicate by type, keep top ones
|
|
429
|
+
const allTips = scored.flatMap((s) =>
|
|
430
|
+
s.tips.map((t) => ({ ...t, sessionId: s.id, project: s.project }))
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// Daily scores
|
|
434
|
+
const dailyMap = {};
|
|
435
|
+
for (const s of scored) {
|
|
436
|
+
if (!s.startTime) continue;
|
|
437
|
+
const date = s.startTime.slice(0, 10);
|
|
438
|
+
if (!dailyMap[date]) dailyMap[date] = { date, scores: [], sessions: 0, tokens: 0, cost: 0 };
|
|
439
|
+
dailyMap[date].scores.push(s.score);
|
|
440
|
+
dailyMap[date].sessions++;
|
|
441
|
+
dailyMap[date].tokens += s.totals.totalTokens;
|
|
442
|
+
dailyMap[date].cost += s.totals.estimatedCost;
|
|
443
|
+
}
|
|
444
|
+
const dailyScores = Object.values(dailyMap)
|
|
445
|
+
.map((d) => ({
|
|
446
|
+
date: d.date,
|
|
447
|
+
score: Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length),
|
|
448
|
+
sessions: d.sessions,
|
|
449
|
+
tokens: d.tokens,
|
|
450
|
+
cost: d.cost,
|
|
451
|
+
}))
|
|
452
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
453
|
+
|
|
454
|
+
const overallSummary = generateOverallSummary({ sessions: scored, overallScore, badges, tips: allTips });
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
sessions: scored,
|
|
458
|
+
overallScore,
|
|
459
|
+
badges,
|
|
460
|
+
tips: allTips,
|
|
461
|
+
dailyScores,
|
|
462
|
+
overallSummary,
|
|
463
|
+
};
|
|
464
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { watch } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { parseAllSessions } from "./parser.js";
|
|
9
|
+
import { scoreAllSessions } from "./scorer.js";
|
|
10
|
+
import { streamAIAnalysis, getCachedAnalysis, clearCachedAnalysis, getAvailableModels, killActiveProcesses, detectDefaultModel, buildPrompt, buildDataSnapshot } from "./ai-analyze.js";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf8"));
|
|
15
|
+
const ROOT_DIR = join(__dirname, "..");
|
|
16
|
+
const PUBLIC_DIR = join(ROOT_DIR, "public");
|
|
17
|
+
|
|
18
|
+
let cachedData = null;
|
|
19
|
+
let cachedAccountInfo = null;
|
|
20
|
+
|
|
21
|
+
async function getAccountInfo() {
|
|
22
|
+
if (cachedAccountInfo) return cachedAccountInfo;
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execFileAsync("claude", ["auth", "status", "--json"]);
|
|
25
|
+
cachedAccountInfo = JSON.parse(stdout);
|
|
26
|
+
} catch {
|
|
27
|
+
cachedAccountInfo = { subscriptionType: null };
|
|
28
|
+
}
|
|
29
|
+
return cachedAccountInfo;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getData(forceRefresh = false) {
|
|
33
|
+
if (cachedData && !forceRefresh) return cachedData;
|
|
34
|
+
const sessions = await parseAllSessions();
|
|
35
|
+
cachedData = scoreAllSessions(sessions);
|
|
36
|
+
return cachedData;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function json(res, data, status = 200) {
|
|
40
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
41
|
+
res.end(JSON.stringify(data));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startServer(port = 6543) {
|
|
45
|
+
// SSE clients for live reload
|
|
46
|
+
const sseClients = new Set();
|
|
47
|
+
|
|
48
|
+
// Watch for file changes in src/ and public/
|
|
49
|
+
for (const dir of ["src", "public"]) {
|
|
50
|
+
watch(join(ROOT_DIR, dir), { recursive: true }, (event, filename) => {
|
|
51
|
+
if (!filename) return;
|
|
52
|
+
console.log(`[reload] ${dir}/${filename} changed`);
|
|
53
|
+
// Bust data cache on src/ changes
|
|
54
|
+
if (dir === "src") cachedData = null;
|
|
55
|
+
for (const client of sseClients) {
|
|
56
|
+
client.write(`data: ${dir}/${filename}\n\n`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const server = createServer(async (req, res) => {
|
|
62
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// SSE endpoint for live reload
|
|
66
|
+
if (url.pathname === "/api/reload") {
|
|
67
|
+
res.writeHead(200, {
|
|
68
|
+
"Content-Type": "text/event-stream",
|
|
69
|
+
"Cache-Control": "no-cache",
|
|
70
|
+
Connection: "keep-alive",
|
|
71
|
+
});
|
|
72
|
+
sseClients.add(res);
|
|
73
|
+
req.on("close", () => sseClients.delete(res));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (url.pathname === "/api/data") {
|
|
78
|
+
const [data, account] = await Promise.all([getData(), getAccountInfo()]);
|
|
79
|
+
// Strip turn-level detail to keep payload small
|
|
80
|
+
const light = {
|
|
81
|
+
overallScore: data.overallScore,
|
|
82
|
+
badges: data.badges,
|
|
83
|
+
tips: data.tips.slice(0, 20),
|
|
84
|
+
dailyScores: data.dailyScores,
|
|
85
|
+
overallSummary: data.overallSummary,
|
|
86
|
+
version: pkg.version,
|
|
87
|
+
account: {
|
|
88
|
+
subscriptionType: account.subscriptionType || null,
|
|
89
|
+
orgName: account.orgName || null,
|
|
90
|
+
email: account.email || null,
|
|
91
|
+
},
|
|
92
|
+
sessions: data.sessions.map((s) => ({
|
|
93
|
+
id: s.id,
|
|
94
|
+
project: s.project,
|
|
95
|
+
model: s.model,
|
|
96
|
+
startTime: s.startTime,
|
|
97
|
+
title: s.title,
|
|
98
|
+
score: s.score,
|
|
99
|
+
dimensions: s.dimensions,
|
|
100
|
+
tips: s.tips,
|
|
101
|
+
totals: s.totals,
|
|
102
|
+
suggestedModel: s.suggestedModel,
|
|
103
|
+
summary: s.summary,
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
return json(res, light);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Session detail — full turn data
|
|
110
|
+
const sessionMatch = url.pathname.match(/^\/api\/session\/(.+)$/);
|
|
111
|
+
if (sessionMatch) {
|
|
112
|
+
const data = await getData();
|
|
113
|
+
const session = data.sessions.find((s) => s.id === sessionMatch[1]);
|
|
114
|
+
if (!session) {
|
|
115
|
+
res.writeHead(404);
|
|
116
|
+
return res.end("Session not found");
|
|
117
|
+
}
|
|
118
|
+
return json(res, {
|
|
119
|
+
id: session.id,
|
|
120
|
+
project: session.project,
|
|
121
|
+
model: session.model,
|
|
122
|
+
startTime: session.startTime,
|
|
123
|
+
endTime: session.endTime,
|
|
124
|
+
title: session.title,
|
|
125
|
+
score: session.score,
|
|
126
|
+
dimensions: session.dimensions,
|
|
127
|
+
totals: session.totals,
|
|
128
|
+
clearPoints: session.clearPoints,
|
|
129
|
+
suggestedModel: session.suggestedModel,
|
|
130
|
+
summary: session.summary,
|
|
131
|
+
turns: session.turns.map((t) => ({
|
|
132
|
+
role: t.role,
|
|
133
|
+
timestamp: t.timestamp,
|
|
134
|
+
tokens: t.tokens,
|
|
135
|
+
cost: t.cost || 0,
|
|
136
|
+
toolCalls: t.toolCalls,
|
|
137
|
+
model: t.model,
|
|
138
|
+
promptPreview: t.promptPreview,
|
|
139
|
+
textPreview: t.textPreview,
|
|
140
|
+
promptLength: t.promptLength,
|
|
141
|
+
})),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (url.pathname === "/api/ai-analyze" && req.method === "POST") {
|
|
146
|
+
const data = await getData();
|
|
147
|
+
const modelId = url.searchParams.get("model") || "";
|
|
148
|
+
res.writeHead(200, {
|
|
149
|
+
"Content-Type": "text/event-stream",
|
|
150
|
+
"Cache-Control": "no-cache",
|
|
151
|
+
Connection: "keep-alive",
|
|
152
|
+
});
|
|
153
|
+
streamAIAnalysis(data, res, modelId || undefined);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (url.pathname === "/api/ai-analyze" && req.method === "DELETE") {
|
|
158
|
+
clearCachedAnalysis();
|
|
159
|
+
return json(res, { ok: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (url.pathname === "/api/ai-prompt" && req.method === "GET") {
|
|
163
|
+
const data = await getData();
|
|
164
|
+
const dataSnapshot = buildDataSnapshot(data);
|
|
165
|
+
const prompt = buildPrompt(dataSnapshot);
|
|
166
|
+
return json(res, { prompt });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (url.pathname === "/api/ai-analyze" && req.method === "GET") {
|
|
170
|
+
const cached = getCachedAnalysis();
|
|
171
|
+
const { models, defaultModel, defaultModelLabel } = getAvailableModels();
|
|
172
|
+
return json(res, {
|
|
173
|
+
...(cached || { content: null }),
|
|
174
|
+
models,
|
|
175
|
+
defaultModel,
|
|
176
|
+
defaultModelLabel,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (url.pathname === "/api/refresh") {
|
|
181
|
+
const data = await getData(true);
|
|
182
|
+
return json(res, { sessions: data.sessions.length, overallScore: data.overallScore });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Serve static files
|
|
186
|
+
let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
187
|
+
const fullPath = join(PUBLIC_DIR, filePath);
|
|
188
|
+
|
|
189
|
+
// Basic security: prevent path traversal
|
|
190
|
+
if (!fullPath.startsWith(PUBLIC_DIR)) {
|
|
191
|
+
res.writeHead(403);
|
|
192
|
+
return res.end("Forbidden");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ext = filePath.split(".").pop();
|
|
196
|
+
const types = { html: "text/html", js: "text/javascript", css: "text/css", png: "image/png", svg: "image/svg+xml" };
|
|
197
|
+
|
|
198
|
+
const content = await readFile(fullPath);
|
|
199
|
+
res.writeHead(200, { "Content-Type": types[ext] || "text/plain" });
|
|
200
|
+
res.end(content);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err.code === "ENOENT") {
|
|
203
|
+
res.writeHead(404);
|
|
204
|
+
res.end("Not found");
|
|
205
|
+
} else {
|
|
206
|
+
console.error(err);
|
|
207
|
+
res.writeHead(500);
|
|
208
|
+
res.end("Internal error");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
server.listen(port, () => {
|
|
214
|
+
console.log(`claude-session-insights running at http://localhost:${port}`);
|
|
215
|
+
detectDefaultModel();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Graceful shutdown for --watch restarts
|
|
219
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
220
|
+
process.on(sig, () => {
|
|
221
|
+
killActiveProcesses();
|
|
222
|
+
server.close(() => process.exit(0));
|
|
223
|
+
// Force exit after 1s if connections linger
|
|
224
|
+
setTimeout(() => process.exit(0), 1000);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return server;
|
|
229
|
+
}
|