claude-roi 0.1.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 +191 -0
- package/package.json +34 -0
- package/src/cache.js +86 -0
- package/src/claude-parser.js +462 -0
- package/src/correlator.js +103 -0
- package/src/dashboard.html +995 -0
- package/src/git-analyzer.js +170 -0
- package/src/index.js +138 -0
- package/src/metrics.js +396 -0
- package/src/server.js +116 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Pricing per million tokens — from https://docs.anthropic.com/en/docs/about-claude/pricing
|
|
6
|
+
// Cache reads = 0.1x base input, Cache writes (5min) = 1.25x base input
|
|
7
|
+
const PRICING = {
|
|
8
|
+
// Opus 4.5 / 4.6: $5 input, $25 output
|
|
9
|
+
'opus-new': { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
10
|
+
// Opus 4.0 / 4.1: $15 input, $75 output
|
|
11
|
+
'opus-old': { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
|
|
12
|
+
// Sonnet 4.0 / 4.5 / 4.6: $3 input, $15 output
|
|
13
|
+
sonnet: { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
14
|
+
// Haiku 4.5: $1 input, $5 output
|
|
15
|
+
'haiku-new': { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
|
|
16
|
+
// Haiku 3.5: $0.80 input, $4 output
|
|
17
|
+
'haiku-35': { input: 0.80, output: 4, cacheRead: 0.08, cacheWrite: 1.00 },
|
|
18
|
+
// Haiku 3: $0.25 input, $1.25 output
|
|
19
|
+
'haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.30 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const PER_MIL = 1_000_000;
|
|
23
|
+
|
|
24
|
+
function getModelFamily(modelName) {
|
|
25
|
+
if (!modelName) return null;
|
|
26
|
+
const lower = modelName.toLowerCase();
|
|
27
|
+
if (lower.includes('opus')) return 'opus';
|
|
28
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
29
|
+
if (lower.includes('haiku')) return 'haiku';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getPricingTier(modelName) {
|
|
34
|
+
if (!modelName) return null;
|
|
35
|
+
const lower = modelName.toLowerCase();
|
|
36
|
+
// Opus 4.5/4.6 = new pricing, Opus 4.0/4.1 = old pricing
|
|
37
|
+
if (lower.includes('opus')) {
|
|
38
|
+
if (lower.includes('4-5') || lower.includes('4-6') || lower.includes('4.5') || lower.includes('4.6')) return 'opus-new';
|
|
39
|
+
return 'opus-old';
|
|
40
|
+
}
|
|
41
|
+
// All Sonnet versions (3.7, 4.0, 4.5, 4.6) share $3/$15 pricing
|
|
42
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
43
|
+
// Haiku version detection
|
|
44
|
+
if (lower.includes('haiku')) {
|
|
45
|
+
if (lower.includes('4-5') || lower.includes('4.5') || lower.includes('4-6') || lower.includes('4.6')) return 'haiku-new';
|
|
46
|
+
if (lower.includes('3-5') || lower.includes('3.5')) return 'haiku-35';
|
|
47
|
+
return 'haiku-3'; // Haiku 3 (claude-3-haiku)
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function calculateCost(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, modelName) {
|
|
53
|
+
const tier = getPricingTier(modelName);
|
|
54
|
+
if (!tier) return 0;
|
|
55
|
+
const p = PRICING[tier];
|
|
56
|
+
return (
|
|
57
|
+
(inputTokens * p.input / PER_MIL) +
|
|
58
|
+
(outputTokens * p.output / PER_MIL) +
|
|
59
|
+
(cacheReadTokens * p.cacheRead / PER_MIL) +
|
|
60
|
+
(cacheCreationTokens * p.cacheWrite / PER_MIL)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function calculateCostBreakdown(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, modelName) {
|
|
65
|
+
const tier = getPricingTier(modelName);
|
|
66
|
+
if (!tier) return { inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheCreationCost: 0, totalCost: 0 };
|
|
67
|
+
const p = PRICING[tier];
|
|
68
|
+
const inputCost = inputTokens * p.input / PER_MIL;
|
|
69
|
+
const outputCost = outputTokens * p.output / PER_MIL;
|
|
70
|
+
const cacheReadCost = cacheReadTokens * p.cacheRead / PER_MIL;
|
|
71
|
+
const cacheCreationCost = cacheCreationTokens * p.cacheWrite / PER_MIL;
|
|
72
|
+
return {
|
|
73
|
+
inputCost,
|
|
74
|
+
outputCost,
|
|
75
|
+
cacheReadCost,
|
|
76
|
+
cacheCreationCost,
|
|
77
|
+
totalCost: inputCost + outputCost + cacheReadCost + cacheCreationCost,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toRelativePath(absolutePath, repoPath) {
|
|
82
|
+
if (!absolutePath) return null;
|
|
83
|
+
// Handle worktree paths: .claude/worktrees/<name>/src/file.js → src/file.js
|
|
84
|
+
const wtMatch = absolutePath.match(/\.claude\/worktrees\/[^/]+\/(.+)/);
|
|
85
|
+
if (wtMatch) return wtMatch[1];
|
|
86
|
+
// Normal: strip repo root prefix
|
|
87
|
+
if (repoPath && absolutePath.startsWith(repoPath)) {
|
|
88
|
+
let rel = absolutePath.slice(repoPath.length);
|
|
89
|
+
if (rel.startsWith('/')) rel = rel.slice(1);
|
|
90
|
+
return rel;
|
|
91
|
+
}
|
|
92
|
+
// Fallback: return just the filename
|
|
93
|
+
return absolutePath.split('/').pop();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractToolUse(session, msg) {
|
|
97
|
+
const content = msg.content;
|
|
98
|
+
if (!Array.isArray(content)) return;
|
|
99
|
+
|
|
100
|
+
for (const block of content) {
|
|
101
|
+
if (block.type !== 'tool_use') continue;
|
|
102
|
+
const toolName = block.name;
|
|
103
|
+
if (!toolName) continue;
|
|
104
|
+
|
|
105
|
+
// Count tool calls
|
|
106
|
+
session.toolCalls[toolName] = (session.toolCalls[toolName] || 0) + 1;
|
|
107
|
+
|
|
108
|
+
// Track files written/read
|
|
109
|
+
const filePath = block.input?.file_path;
|
|
110
|
+
if (!filePath) continue;
|
|
111
|
+
|
|
112
|
+
if (toolName === 'Write' || toolName === 'Edit') {
|
|
113
|
+
if (!session.filesWritten.includes(filePath)) {
|
|
114
|
+
session.filesWritten.push(filePath);
|
|
115
|
+
}
|
|
116
|
+
} else if (toolName === 'Read') {
|
|
117
|
+
if (!session.filesRead.includes(filePath)) {
|
|
118
|
+
session.filesRead.push(filePath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createEmptySession(sessionId) {
|
|
125
|
+
return {
|
|
126
|
+
sessionId,
|
|
127
|
+
repoPath: null,
|
|
128
|
+
projectName: null,
|
|
129
|
+
gitBranch: null,
|
|
130
|
+
startTime: null,
|
|
131
|
+
endTime: null,
|
|
132
|
+
durationMinutes: 0,
|
|
133
|
+
totalInputTokens: 0,
|
|
134
|
+
totalOutputTokens: 0,
|
|
135
|
+
cacheCreationTokens: 0,
|
|
136
|
+
cacheReadTokens: 0,
|
|
137
|
+
cost: { inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheCreationCost: 0, totalCost: 0 },
|
|
138
|
+
model: null,
|
|
139
|
+
modelBreakdown: {},
|
|
140
|
+
toolCalls: {},
|
|
141
|
+
filesWritten: [],
|
|
142
|
+
filesRead: [],
|
|
143
|
+
userMessageCount: 0,
|
|
144
|
+
assistantMessageCount: 0,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function parseSessionFile(filePath) {
|
|
149
|
+
const sessionId = path.basename(filePath, '.jsonl');
|
|
150
|
+
const session = createEmptySession(sessionId);
|
|
151
|
+
const seenRequestIds = new Set();
|
|
152
|
+
const modelTokens = {}; // model -> { input, output, cacheRead, cacheCreate }
|
|
153
|
+
|
|
154
|
+
const rl = createInterface({
|
|
155
|
+
input: createReadStream(filePath),
|
|
156
|
+
crlfDelay: Infinity,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for await (const line of rl) {
|
|
160
|
+
if (!line.trim()) continue;
|
|
161
|
+
|
|
162
|
+
let obj;
|
|
163
|
+
try {
|
|
164
|
+
obj = JSON.parse(line);
|
|
165
|
+
} catch {
|
|
166
|
+
continue; // skip malformed lines
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (obj.type === 'user' && obj.message) {
|
|
170
|
+
// Extract repo path from cwd (most reliable source)
|
|
171
|
+
if (!session.repoPath && obj.cwd) {
|
|
172
|
+
session.repoPath = obj.cwd;
|
|
173
|
+
}
|
|
174
|
+
if (!session.gitBranch && obj.gitBranch) {
|
|
175
|
+
session.gitBranch = obj.gitBranch;
|
|
176
|
+
}
|
|
177
|
+
if (obj.sessionId) {
|
|
178
|
+
session.sessionId = obj.sessionId;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Track timestamps
|
|
182
|
+
if (obj.timestamp) {
|
|
183
|
+
if (!session.startTime || obj.timestamp < session.startTime) {
|
|
184
|
+
session.startTime = obj.timestamp;
|
|
185
|
+
}
|
|
186
|
+
if (!session.endTime || obj.timestamp > session.endTime) {
|
|
187
|
+
session.endTime = obj.timestamp;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Count user messages (only actual user content, not tool results)
|
|
192
|
+
const content = obj.message.content;
|
|
193
|
+
if (Array.isArray(content)) {
|
|
194
|
+
const hasUserText = content.some(b => b.type === 'text');
|
|
195
|
+
if (hasUserText) session.userMessageCount++;
|
|
196
|
+
} else if (typeof content === 'string') {
|
|
197
|
+
session.userMessageCount++;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (obj.type !== 'assistant' || !obj.message) continue;
|
|
204
|
+
|
|
205
|
+
const msg = obj.message;
|
|
206
|
+
|
|
207
|
+
// Skip synthetic/error messages
|
|
208
|
+
if (msg.model === '<synthetic>') continue;
|
|
209
|
+
|
|
210
|
+
// Track timestamps
|
|
211
|
+
if (obj.timestamp) {
|
|
212
|
+
if (!session.startTime || obj.timestamp < session.startTime) {
|
|
213
|
+
session.startTime = obj.timestamp;
|
|
214
|
+
}
|
|
215
|
+
if (!session.endTime || obj.timestamp > session.endTime) {
|
|
216
|
+
session.endTime = obj.timestamp;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Deduplicate by requestId to avoid double-counting tokens
|
|
221
|
+
const requestId = obj.requestId;
|
|
222
|
+
const isNewRequest = requestId && !seenRequestIds.has(requestId);
|
|
223
|
+
if (requestId) seenRequestIds.add(requestId);
|
|
224
|
+
|
|
225
|
+
// Accumulate usage only for new requests
|
|
226
|
+
if (isNewRequest || !requestId) {
|
|
227
|
+
const usage = msg.usage;
|
|
228
|
+
if (usage) {
|
|
229
|
+
const input = usage.input_tokens || 0;
|
|
230
|
+
const output = usage.output_tokens || 0;
|
|
231
|
+
const cacheRead = usage.cache_read_input_tokens || 0;
|
|
232
|
+
const cacheCreate = usage.cache_creation_input_tokens || 0;
|
|
233
|
+
const model = msg.model || 'unknown';
|
|
234
|
+
|
|
235
|
+
session.totalInputTokens += input;
|
|
236
|
+
session.totalOutputTokens += output;
|
|
237
|
+
session.cacheReadTokens += cacheRead;
|
|
238
|
+
session.cacheCreationTokens += cacheCreate;
|
|
239
|
+
|
|
240
|
+
// Track per-model breakdown
|
|
241
|
+
if (!modelTokens[model]) {
|
|
242
|
+
modelTokens[model] = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
243
|
+
}
|
|
244
|
+
modelTokens[model].input += input;
|
|
245
|
+
modelTokens[model].output += output;
|
|
246
|
+
modelTokens[model].cacheRead += cacheRead;
|
|
247
|
+
modelTokens[model].cacheCreate += cacheCreate;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
session.assistantMessageCount++;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Always extract tool use info (different content blocks can appear in split messages)
|
|
254
|
+
extractToolUse(session, msg);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Compute costs from model breakdown
|
|
258
|
+
let maxTokens = 0;
|
|
259
|
+
let primaryModel = null;
|
|
260
|
+
|
|
261
|
+
for (const [model, tokens] of Object.entries(modelTokens)) {
|
|
262
|
+
const cost = calculateCost(tokens.input, tokens.output, tokens.cacheRead, tokens.cacheCreate, model);
|
|
263
|
+
const totalTokens = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheCreate;
|
|
264
|
+
session.modelBreakdown[model] = { tokens: totalTokens, cost };
|
|
265
|
+
|
|
266
|
+
if (totalTokens > maxTokens) {
|
|
267
|
+
maxTokens = totalTokens;
|
|
268
|
+
primaryModel = model;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
session.model = primaryModel;
|
|
272
|
+
|
|
273
|
+
// Calculate total cost breakdown
|
|
274
|
+
session.cost = calculateCostBreakdown(
|
|
275
|
+
session.totalInputTokens,
|
|
276
|
+
session.totalOutputTokens,
|
|
277
|
+
session.cacheReadTokens,
|
|
278
|
+
session.cacheCreationTokens,
|
|
279
|
+
primaryModel
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// If multiple models used, recalculate cost from per-model breakdown for accuracy
|
|
283
|
+
if (Object.keys(modelTokens).length > 1) {
|
|
284
|
+
let totalCost = 0;
|
|
285
|
+
let inputCost = 0;
|
|
286
|
+
let outputCost = 0;
|
|
287
|
+
let cacheReadCost = 0;
|
|
288
|
+
let cacheCreationCost = 0;
|
|
289
|
+
|
|
290
|
+
for (const [model, tokens] of Object.entries(modelTokens)) {
|
|
291
|
+
const breakdown = calculateCostBreakdown(tokens.input, tokens.output, tokens.cacheRead, tokens.cacheCreate, model);
|
|
292
|
+
inputCost += breakdown.inputCost;
|
|
293
|
+
outputCost += breakdown.outputCost;
|
|
294
|
+
cacheReadCost += breakdown.cacheReadCost;
|
|
295
|
+
cacheCreationCost += breakdown.cacheCreationCost;
|
|
296
|
+
totalCost += breakdown.totalCost;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
session.cost = { inputCost, outputCost, cacheReadCost, cacheCreationCost, totalCost };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Calculate duration
|
|
303
|
+
if (session.startTime && session.endTime) {
|
|
304
|
+
const start = new Date(session.startTime).getTime();
|
|
305
|
+
const end = new Date(session.endTime).getTime();
|
|
306
|
+
session.durationMinutes = Math.round((end - start) / 60000 * 10) / 10;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Normalize filesWritten to relative paths (for file-based commit correlation)
|
|
310
|
+
// Resolve the actual git root from repoPath (which may be a worktree path)
|
|
311
|
+
let gitRoot = session.repoPath;
|
|
312
|
+
if (gitRoot) {
|
|
313
|
+
const wtRootMatch = gitRoot.match(/^(.+?)\/\.claude\/worktrees\/[^/]+$/);
|
|
314
|
+
if (wtRootMatch) gitRoot = wtRootMatch[1];
|
|
315
|
+
}
|
|
316
|
+
if (gitRoot) {
|
|
317
|
+
session.filesWritten = session.filesWritten
|
|
318
|
+
.map(fp => toRelativePath(fp, gitRoot))
|
|
319
|
+
.filter(Boolean);
|
|
320
|
+
session.filesRead = session.filesRead
|
|
321
|
+
.map(fp => toRelativePath(fp, gitRoot))
|
|
322
|
+
.filter(Boolean);
|
|
323
|
+
// Also normalize repoPath to the actual git root
|
|
324
|
+
session.repoPath = gitRoot;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return session;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function parseSessionWithSubagents(projectDir, sessionId) {
|
|
331
|
+
const mainFile = path.join(projectDir, `${sessionId}.jsonl`);
|
|
332
|
+
const session = await parseSessionFile(mainFile);
|
|
333
|
+
|
|
334
|
+
// Check for subagent directory
|
|
335
|
+
const subagentDir = path.join(projectDir, sessionId, 'subagents');
|
|
336
|
+
if (existsSync(subagentDir)) {
|
|
337
|
+
try {
|
|
338
|
+
const agentFiles = readdirSync(subagentDir).filter(f => f.endsWith('.jsonl'));
|
|
339
|
+
for (const af of agentFiles) {
|
|
340
|
+
const subSession = await parseSessionFile(path.join(subagentDir, af));
|
|
341
|
+
mergeSubagentIntoSession(session, subSession);
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
// Skip if subagent directory is unreadable
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return session;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function mergeSubagentIntoSession(parent, sub) {
|
|
352
|
+
parent.totalInputTokens += sub.totalInputTokens;
|
|
353
|
+
parent.totalOutputTokens += sub.totalOutputTokens;
|
|
354
|
+
parent.cacheCreationTokens += sub.cacheCreationTokens;
|
|
355
|
+
parent.cacheReadTokens += sub.cacheReadTokens;
|
|
356
|
+
|
|
357
|
+
parent.cost.inputCost += sub.cost.inputCost;
|
|
358
|
+
parent.cost.outputCost += sub.cost.outputCost;
|
|
359
|
+
parent.cost.cacheReadCost += sub.cost.cacheReadCost;
|
|
360
|
+
parent.cost.cacheCreationCost += sub.cost.cacheCreationCost;
|
|
361
|
+
parent.cost.totalCost += sub.cost.totalCost;
|
|
362
|
+
|
|
363
|
+
parent.assistantMessageCount += sub.assistantMessageCount;
|
|
364
|
+
parent.userMessageCount += sub.userMessageCount;
|
|
365
|
+
|
|
366
|
+
// Merge model breakdown
|
|
367
|
+
for (const [model, data] of Object.entries(sub.modelBreakdown)) {
|
|
368
|
+
if (!parent.modelBreakdown[model]) {
|
|
369
|
+
parent.modelBreakdown[model] = { tokens: 0, cost: 0 };
|
|
370
|
+
}
|
|
371
|
+
parent.modelBreakdown[model].tokens += data.tokens;
|
|
372
|
+
parent.modelBreakdown[model].cost += data.cost;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Merge tool calls
|
|
376
|
+
for (const [tool, count] of Object.entries(sub.toolCalls)) {
|
|
377
|
+
parent.toolCalls[tool] = (parent.toolCalls[tool] || 0) + count;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Merge files
|
|
381
|
+
for (const f of sub.filesWritten) {
|
|
382
|
+
if (!parent.filesWritten.includes(f)) parent.filesWritten.push(f);
|
|
383
|
+
}
|
|
384
|
+
for (const f of sub.filesRead) {
|
|
385
|
+
if (!parent.filesRead.includes(f)) parent.filesRead.push(f);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function parseAllProjects(claudeDir, days, projectFilter) {
|
|
390
|
+
if (!existsSync(claudeDir)) {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const cutoffDate = new Date();
|
|
395
|
+
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
396
|
+
const cutoffMs = cutoffDate.getTime();
|
|
397
|
+
|
|
398
|
+
const sessions = [];
|
|
399
|
+
const fileIndex = {};
|
|
400
|
+
const projectFolders = readdirSync(claudeDir).filter(f => {
|
|
401
|
+
if (f.startsWith('.')) return false;
|
|
402
|
+
const fullPath = path.join(claudeDir, f);
|
|
403
|
+
return statSync(fullPath).isDirectory();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
for (const folder of projectFolders) {
|
|
407
|
+
// Apply project filter if specified
|
|
408
|
+
if (projectFilter) {
|
|
409
|
+
const folderLower = folder.toLowerCase();
|
|
410
|
+
if (!folderLower.includes(projectFilter.toLowerCase())) continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const projectDir = path.join(claudeDir, folder);
|
|
414
|
+
const projectName = folder.split('-').pop() || folder;
|
|
415
|
+
|
|
416
|
+
let files;
|
|
417
|
+
try {
|
|
418
|
+
files = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
419
|
+
} catch {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for (const file of files) {
|
|
424
|
+
const filePath = path.join(projectDir, file);
|
|
425
|
+
|
|
426
|
+
// Quick filter by mtime
|
|
427
|
+
try {
|
|
428
|
+
const stat = statSync(filePath);
|
|
429
|
+
if (stat.mtimeMs < cutoffMs) continue;
|
|
430
|
+
fileIndex[filePath] = stat.mtimeMs;
|
|
431
|
+
} catch {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const sessionId = path.basename(file, '.jsonl');
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const session = await parseSessionWithSubagents(projectDir, sessionId);
|
|
439
|
+
|
|
440
|
+
// Skip empty sessions (no messages)
|
|
441
|
+
if (!session.startTime || (session.userMessageCount === 0 && session.assistantMessageCount === 0)) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Apply date filter on session start time
|
|
446
|
+
if (new Date(session.startTime).getTime() < cutoffMs) continue;
|
|
447
|
+
|
|
448
|
+
session.projectName = projectName;
|
|
449
|
+
sessions.push(session);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
process.stderr.write(`Warning: Failed to parse ${filePath}: ${err.message}\n`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Sort by start time descending
|
|
457
|
+
sessions.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
|
458
|
+
|
|
459
|
+
return { sessions, fileIndex };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export { calculateCost, getModelFamily, PRICING };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const FALLBACK_BUFFER_MS = 2 * 60 * 60 * 1000; // 2 hours for time-only fallback
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Correlate sessions to commits using file-based matching.
|
|
5
|
+
*
|
|
6
|
+
* Primary: match commits whose changed files overlap with session.filesWritten.
|
|
7
|
+
* Time constraint: commit is on the same calendar day or the next day.
|
|
8
|
+
* Fallback: for sessions with no filesWritten (chat-only), use time window
|
|
9
|
+
* [sessionStart, sessionEnd + 2 hours].
|
|
10
|
+
*/
|
|
11
|
+
export function correlateSessions(sessions, commitsByRepo) {
|
|
12
|
+
const result = [];
|
|
13
|
+
const claimedCommits = new Set();
|
|
14
|
+
|
|
15
|
+
// Sort sessions by end time so earlier sessions claim commits first
|
|
16
|
+
const sorted = [...sessions].sort(
|
|
17
|
+
(a, b) => new Date(a.endTime).getTime() - new Date(b.endTime).getTime()
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
for (const session of sorted) {
|
|
21
|
+
const repoAnalysis = commitsByRepo[session.repoPath];
|
|
22
|
+
const repoCommits = repoAnalysis?.commits || [];
|
|
23
|
+
|
|
24
|
+
const sessionStart = new Date(session.startTime).getTime();
|
|
25
|
+
const sessionEnd = new Date(session.endTime).getTime();
|
|
26
|
+
const sessionFiles = new Set(session.filesWritten || []);
|
|
27
|
+
|
|
28
|
+
let matched;
|
|
29
|
+
|
|
30
|
+
if (sessionFiles.size > 0) {
|
|
31
|
+
// PRIMARY: File-based correlation
|
|
32
|
+
// Time window: same calendar day as session, or next calendar day
|
|
33
|
+
const sessionDay = new Date(session.startTime);
|
|
34
|
+
const dayStart = new Date(sessionDay.getFullYear(), sessionDay.getMonth(), sessionDay.getDate()).getTime();
|
|
35
|
+
const dayEnd = dayStart + 2 * 24 * 60 * 60 * 1000; // end of next calendar day
|
|
36
|
+
|
|
37
|
+
matched = repoCommits.filter(c => {
|
|
38
|
+
if (claimedCommits.has(c.hash)) return false;
|
|
39
|
+
if (c.timestampMs < dayStart || c.timestampMs >= dayEnd) return false;
|
|
40
|
+
// Check file overlap: any commit file matches a session file
|
|
41
|
+
return c.files.some(f => sessionFiles.has(f.path));
|
|
42
|
+
});
|
|
43
|
+
} else {
|
|
44
|
+
// FALLBACK: Time-based for chat-only sessions (no files written)
|
|
45
|
+
const windowEnd = sessionEnd + FALLBACK_BUFFER_MS;
|
|
46
|
+
matched = repoCommits.filter(c =>
|
|
47
|
+
c.timestampMs >= sessionStart &&
|
|
48
|
+
c.timestampMs <= windowEnd &&
|
|
49
|
+
!claimedCommits.has(c.hash)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Claim matched commits
|
|
54
|
+
for (const c of matched) {
|
|
55
|
+
claimedCommits.add(c.hash);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const linesAdded = matched.reduce((s, c) => s + c.totalAdded, 0);
|
|
59
|
+
const linesDeleted = matched.reduce((s, c) => s + c.totalDeleted, 0);
|
|
60
|
+
const netLines = linesAdded - linesDeleted;
|
|
61
|
+
const filesChanged = new Set(matched.flatMap(c => c.files.map(f => f.path))).size;
|
|
62
|
+
const commitsOnMain = matched.filter(c => c.onMain).length;
|
|
63
|
+
|
|
64
|
+
const messageCount = session.userMessageCount + session.assistantMessageCount;
|
|
65
|
+
const isOrphaned = messageCount > 10 && matched.length === 0;
|
|
66
|
+
|
|
67
|
+
// Calculate which session files were committed vs not
|
|
68
|
+
const committedFiles = new Set(matched.flatMap(c => c.files.map(f => f.path)));
|
|
69
|
+
const uncommittedFiles = [...sessionFiles].filter(f => !committedFiles.has(f));
|
|
70
|
+
|
|
71
|
+
result.push({
|
|
72
|
+
...session,
|
|
73
|
+
commits: matched,
|
|
74
|
+
commitCount: matched.length,
|
|
75
|
+
commitsOnMain,
|
|
76
|
+
linesAdded,
|
|
77
|
+
linesDeleted,
|
|
78
|
+
netLines,
|
|
79
|
+
filesChanged,
|
|
80
|
+
isOrphaned,
|
|
81
|
+
matchedByFiles: sessionFiles.size > 0,
|
|
82
|
+
uncommittedFiles,
|
|
83
|
+
costPerCommit: matched.length > 0 ? session.cost.totalCost / matched.length : null,
|
|
84
|
+
costPerLine: linesAdded > 0 ? session.cost.totalCost / linesAdded : null,
|
|
85
|
+
costPerNetLine: netLines > 0 ? session.cost.totalCost / netLines : null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Identify organic commits (not claimed by any session)
|
|
90
|
+
const organicCommits = [];
|
|
91
|
+
for (const [, analysis] of Object.entries(commitsByRepo)) {
|
|
92
|
+
for (const commit of analysis.commits) {
|
|
93
|
+
if (!claimedCommits.has(commit.hash)) {
|
|
94
|
+
organicCommits.push({ ...commit });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Re-sort result by start time descending (most recent first for display)
|
|
100
|
+
result.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
|
101
|
+
|
|
102
|
+
return { correlatedSessions: result, organicCommits };
|
|
103
|
+
}
|