ccanalyzer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +16 -0
- package/package.json +30 -0
- package/src/parser.js +316 -0
- package/src/public/app.js +923 -0
- package/src/public/index.html +38 -0
- package/src/public/style.css +738 -0
- package/src/server.js +46 -0
package/bin/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { startServer } = require('../src/server');
|
|
4
|
+
|
|
5
|
+
const port = parseInt(process.env.PORT || '3737', 10);
|
|
6
|
+
|
|
7
|
+
startServer(port).then(({ url }) => {
|
|
8
|
+
console.log(`\n ccanalyser running at ${url}\n`);
|
|
9
|
+
try {
|
|
10
|
+
// Try to open browser
|
|
11
|
+
import('open').then(m => m.default(url)).catch(() => {});
|
|
12
|
+
} catch {}
|
|
13
|
+
}).catch(err => {
|
|
14
|
+
console.error('Failed to start server:', err.message);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ccanalyzer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Web-based analyzer for Claude Code sessions",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ccanalyzer": "./bin/index.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "./src/server.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"express": "^4.18.2",
|
|
18
|
+
"open": "^9.1.0"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["claude", "claude-code", "analytics", "sessions", "ccanalyzer"],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/olivier-j/ccanalyzer.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/olivier-j/ccanalyzer"
|
|
30
|
+
}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
6
|
+
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
|
7
|
+
|
|
8
|
+
const MODEL_PRICING = {
|
|
9
|
+
'claude-opus-4': { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
10
|
+
'claude-opus-3': { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
11
|
+
'claude-sonnet-4': { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
12
|
+
'claude-sonnet-3-7': { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
13
|
+
'claude-sonnet-3-5': { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
14
|
+
'claude-haiku-4': { input: 0.8, output: 4, cache_write: 1.0, cache_read: 0.08 },
|
|
15
|
+
'claude-haiku-3': { input: 0.25, output: 1.25, cache_write: 0.3, cache_read: 0.03 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function getPricing(model) {
|
|
19
|
+
if (!model) return null;
|
|
20
|
+
for (const [key, p] of Object.entries(MODEL_PRICING)) {
|
|
21
|
+
if (model.startsWith(key)) return p;
|
|
22
|
+
}
|
|
23
|
+
return { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function calcCost(usage, model) {
|
|
27
|
+
const p = getPricing(model);
|
|
28
|
+
if (!p) return 0;
|
|
29
|
+
const M = 1_000_000;
|
|
30
|
+
return (
|
|
31
|
+
(usage.input_tokens || 0) * p.input / M +
|
|
32
|
+
(usage.output_tokens || 0) * p.output / M +
|
|
33
|
+
(usage.cache_creation_input_tokens || 0) * p.cache_write / M +
|
|
34
|
+
(usage.cache_read_input_tokens || 0) * p.cache_read / M
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decodePath(dirName) {
|
|
39
|
+
return '/' + dirName.replace(/^-/, '').split('-').join('/');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decodeProjectName(dirName) {
|
|
43
|
+
const full = decodePath(dirName);
|
|
44
|
+
const parts = full.split('/').filter(Boolean);
|
|
45
|
+
return parts[parts.length - 1] || full;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseLines(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
return fs.readFileSync(filePath, 'utf8').trim().split('\n')
|
|
51
|
+
.filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
} catch { return []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseSessionFile(filePath, lightMode = false) {
|
|
57
|
+
const entries = parseLines(filePath);
|
|
58
|
+
const messages = [];
|
|
59
|
+
let title = null;
|
|
60
|
+
let sessionId = null;
|
|
61
|
+
let firstTimestamp = null;
|
|
62
|
+
let lastTimestamp = null;
|
|
63
|
+
const totalUsage = { input: 0, output: 0, cache_write: 0, cache_read: 0 };
|
|
64
|
+
let totalCost = 0;
|
|
65
|
+
let model = null;
|
|
66
|
+
let cwd = null;
|
|
67
|
+
let gitBranch = null;
|
|
68
|
+
let mainAgentMessages = 0;
|
|
69
|
+
let subAgentMessages = 0;
|
|
70
|
+
// Map toolUseId → { timestamp, uuid } for agent spawn detection
|
|
71
|
+
const toolUseMap = {};
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!sessionId && entry.sessionId) sessionId = entry.sessionId;
|
|
75
|
+
if (!cwd && entry.cwd) cwd = entry.cwd;
|
|
76
|
+
if (!gitBranch && entry.gitBranch) gitBranch = entry.gitBranch;
|
|
77
|
+
|
|
78
|
+
if (entry.type === 'ai-title') { title = entry.aiTitle; continue; }
|
|
79
|
+
|
|
80
|
+
if (entry.type === 'user' || entry.type === 'assistant') {
|
|
81
|
+
const ts = entry.timestamp ? new Date(entry.timestamp) : null;
|
|
82
|
+
if (ts) {
|
|
83
|
+
if (!firstTimestamp || ts < firstTimestamp) firstTimestamp = ts;
|
|
84
|
+
if (!lastTimestamp || ts > lastTimestamp) lastTimestamp = ts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (entry.type === 'assistant') {
|
|
88
|
+
const msg = entry.message || {};
|
|
89
|
+
if (msg.model && msg.model !== '<synthetic>') model = msg.model;
|
|
90
|
+
const usage = msg.usage;
|
|
91
|
+
if (usage) {
|
|
92
|
+
totalUsage.input += usage.input_tokens || 0;
|
|
93
|
+
totalUsage.output += usage.output_tokens || 0;
|
|
94
|
+
totalUsage.cache_write += usage.cache_creation_input_tokens || 0;
|
|
95
|
+
totalUsage.cache_read += usage.cache_read_input_tokens || 0;
|
|
96
|
+
totalCost += calcCost(usage, msg.model || model);
|
|
97
|
+
}
|
|
98
|
+
// Index tool_use IDs → for linking to spawned agents
|
|
99
|
+
if (!lightMode && Array.isArray(msg.content)) {
|
|
100
|
+
for (const block of msg.content) {
|
|
101
|
+
if (block && block.type === 'tool_use' && block.id) {
|
|
102
|
+
toolUseMap[block.id] = { timestamp: entry.timestamp, uuid: entry.uuid };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (entry.isSidechain) subAgentMessages++;
|
|
109
|
+
else mainAgentMessages++;
|
|
110
|
+
|
|
111
|
+
if (!lightMode) {
|
|
112
|
+
messages.push({
|
|
113
|
+
uuid: entry.uuid,
|
|
114
|
+
parentUuid: entry.parentUuid,
|
|
115
|
+
type: entry.type,
|
|
116
|
+
timestamp: entry.timestamp,
|
|
117
|
+
isSidechain: entry.isSidechain || false,
|
|
118
|
+
content: entry.type === 'user'
|
|
119
|
+
? (entry.message?.content || '')
|
|
120
|
+
: (entry.message?.content || []),
|
|
121
|
+
usage: entry.message?.usage || null,
|
|
122
|
+
model: entry.message?.model || null,
|
|
123
|
+
stopReason: entry.message?.stop_reason || null,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
sessionId,
|
|
131
|
+
title: title || 'Session sans titre',
|
|
132
|
+
cwd,
|
|
133
|
+
gitBranch,
|
|
134
|
+
model,
|
|
135
|
+
firstTimestamp: firstTimestamp?.toISOString() || null,
|
|
136
|
+
lastTimestamp: lastTimestamp?.toISOString() || null,
|
|
137
|
+
messageCount: lightMode ? (mainAgentMessages + subAgentMessages) : messages.length,
|
|
138
|
+
mainAgentMessages,
|
|
139
|
+
subAgentMessages,
|
|
140
|
+
totalUsage,
|
|
141
|
+
totalCost,
|
|
142
|
+
messages: lightMode ? [] : messages,
|
|
143
|
+
toolUseMap: lightMode ? {} : toolUseMap,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseAgentFile(filePath) {
|
|
148
|
+
return parseSessionFile(filePath, true);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseSubagents(sessionDir) {
|
|
152
|
+
const subagentsDir = path.join(sessionDir, 'subagents');
|
|
153
|
+
if (!fs.existsSync(subagentsDir)) return [];
|
|
154
|
+
|
|
155
|
+
const metaFiles = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.meta.json'));
|
|
156
|
+
const agents = [];
|
|
157
|
+
|
|
158
|
+
for (const metaFile of metaFiles) {
|
|
159
|
+
const agentId = metaFile.replace('.meta.json', '');
|
|
160
|
+
const jsonlPath = path.join(subagentsDir, agentId + '.jsonl');
|
|
161
|
+
if (!fs.existsSync(jsonlPath)) continue;
|
|
162
|
+
|
|
163
|
+
let meta = {};
|
|
164
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(subagentsDir, metaFile), 'utf8')); } catch {}
|
|
165
|
+
|
|
166
|
+
const parsed = parseAgentFile(jsonlPath);
|
|
167
|
+
agents.push({
|
|
168
|
+
agentId,
|
|
169
|
+
meta,
|
|
170
|
+
firstTimestamp: parsed.firstTimestamp,
|
|
171
|
+
lastTimestamp: parsed.lastTimestamp,
|
|
172
|
+
messageCount: parsed.messageCount,
|
|
173
|
+
totalUsage: parsed.totalUsage,
|
|
174
|
+
totalCost: parsed.totalCost,
|
|
175
|
+
model: parsed.model,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Sort by start time
|
|
180
|
+
agents.sort((a, b) => {
|
|
181
|
+
const ta = a.firstTimestamp ? new Date(a.firstTimestamp) : new Date(0);
|
|
182
|
+
const tb = b.firstTimestamp ? new Date(b.firstTimestamp) : new Date(0);
|
|
183
|
+
return ta - tb;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return agents;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getAllProjects() {
|
|
190
|
+
if (!fs.existsSync(PROJECTS_DIR)) return [];
|
|
191
|
+
|
|
192
|
+
const projectDirs = fs.readdirSync(PROJECTS_DIR).filter(d => {
|
|
193
|
+
return fs.statSync(path.join(PROJECTS_DIR, d)).isDirectory();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const projects = [];
|
|
197
|
+
for (const dirName of projectDirs) {
|
|
198
|
+
const projectPath = path.join(PROJECTS_DIR, dirName);
|
|
199
|
+
const sessionFiles = fs.readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
|
|
200
|
+
if (sessionFiles.length === 0) continue;
|
|
201
|
+
|
|
202
|
+
let totalCost = 0;
|
|
203
|
+
let totalMessages = 0;
|
|
204
|
+
let lastActivity = null;
|
|
205
|
+
let firstActivity = null;
|
|
206
|
+
const totalUsage = { input: 0, output: 0, cache_write: 0, cache_read: 0 };
|
|
207
|
+
const sessions = [];
|
|
208
|
+
|
|
209
|
+
for (const sf of sessionFiles) {
|
|
210
|
+
try {
|
|
211
|
+
const session = parseSessionFile(path.join(projectPath, sf), true);
|
|
212
|
+
totalCost += session.totalCost;
|
|
213
|
+
totalMessages += session.messageCount;
|
|
214
|
+
totalUsage.input += session.totalUsage.input;
|
|
215
|
+
totalUsage.output += session.totalUsage.output;
|
|
216
|
+
totalUsage.cache_write += session.totalUsage.cache_write;
|
|
217
|
+
totalUsage.cache_read += session.totalUsage.cache_read;
|
|
218
|
+
|
|
219
|
+
if (session.lastTimestamp) {
|
|
220
|
+
const d = new Date(session.lastTimestamp);
|
|
221
|
+
if (!lastActivity || d > new Date(lastActivity)) lastActivity = session.lastTimestamp;
|
|
222
|
+
}
|
|
223
|
+
if (session.firstTimestamp) {
|
|
224
|
+
const d = new Date(session.firstTimestamp);
|
|
225
|
+
if (!firstActivity || d < new Date(firstActivity)) firstActivity = session.firstTimestamp;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check if this session has subagents
|
|
229
|
+
const sessionId = sf.replace('.jsonl', '');
|
|
230
|
+
const sessionDir = path.join(projectPath, sessionId);
|
|
231
|
+
const hasSubagents = fs.existsSync(path.join(sessionDir, 'subagents'));
|
|
232
|
+
|
|
233
|
+
sessions.push({
|
|
234
|
+
sessionId: session.sessionId || sessionId,
|
|
235
|
+
file: sf,
|
|
236
|
+
title: session.title,
|
|
237
|
+
cwd: session.cwd,
|
|
238
|
+
gitBranch: session.gitBranch,
|
|
239
|
+
model: session.model,
|
|
240
|
+
firstTimestamp: session.firstTimestamp,
|
|
241
|
+
lastTimestamp: session.lastTimestamp,
|
|
242
|
+
messageCount: session.messageCount,
|
|
243
|
+
mainAgentMessages: session.mainAgentMessages,
|
|
244
|
+
subAgentMessages: session.subAgentMessages,
|
|
245
|
+
totalUsage: session.totalUsage,
|
|
246
|
+
totalCost: session.totalCost,
|
|
247
|
+
hasSubagents,
|
|
248
|
+
});
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
sessions.sort((a, b) => {
|
|
253
|
+
const da = a.lastTimestamp ? new Date(a.lastTimestamp) : new Date(0);
|
|
254
|
+
const db = b.lastTimestamp ? new Date(b.lastTimestamp) : new Date(0);
|
|
255
|
+
return db - da;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
projects.push({
|
|
259
|
+
dirName,
|
|
260
|
+
name: decodeProjectName(dirName),
|
|
261
|
+
path: decodePath(dirName),
|
|
262
|
+
sessionCount: sessions.length,
|
|
263
|
+
totalMessages,
|
|
264
|
+
totalCost,
|
|
265
|
+
totalUsage,
|
|
266
|
+
firstActivity,
|
|
267
|
+
lastActivity,
|
|
268
|
+
sessions,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
projects.sort((a, b) => {
|
|
273
|
+
const da = a.lastActivity ? new Date(a.lastActivity) : new Date(0);
|
|
274
|
+
const db = b.lastActivity ? new Date(b.lastActivity) : new Date(0);
|
|
275
|
+
return db - da;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return projects;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getSessionDetail(dirName, sessionFile) {
|
|
282
|
+
const filePath = path.join(PROJECTS_DIR, dirName, sessionFile);
|
|
283
|
+
if (!fs.existsSync(filePath)) throw new Error('Session not found');
|
|
284
|
+
|
|
285
|
+
const session = parseSessionFile(filePath, false);
|
|
286
|
+
|
|
287
|
+
// Load subagents if the session directory exists
|
|
288
|
+
const sessionId = sessionFile.replace('.jsonl', '');
|
|
289
|
+
const sessionDir = path.join(PROJECTS_DIR, dirName, sessionId);
|
|
290
|
+
const agents = fs.existsSync(sessionDir) ? parseSubagents(sessionDir) : [];
|
|
291
|
+
|
|
292
|
+
// Link each agent to its spawn event in main session via toolUseId
|
|
293
|
+
for (const agent of agents) {
|
|
294
|
+
if (agent.meta.toolUseId && session.toolUseMap[agent.meta.toolUseId]) {
|
|
295
|
+
agent.spawnedAt = session.toolUseMap[agent.meta.toolUseId].timestamp;
|
|
296
|
+
agent.spawnedByUuid = session.toolUseMap[agent.meta.toolUseId].uuid;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { ...session, agents };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getAgentDetail(dirName, sessionFile, agentId) {
|
|
304
|
+
const sessionId = sessionFile.replace('.jsonl', '');
|
|
305
|
+
const agentPath = path.join(PROJECTS_DIR, dirName, sessionId, 'subagents', agentId + '.jsonl');
|
|
306
|
+
if (!fs.existsSync(agentPath)) throw new Error('Agent not found');
|
|
307
|
+
return parseSessionFile(agentPath, false);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function getStatsCache() {
|
|
311
|
+
const statsFile = path.join(CLAUDE_DIR, 'stats-cache.json');
|
|
312
|
+
if (!fs.existsSync(statsFile)) return null;
|
|
313
|
+
try { return JSON.parse(fs.readFileSync(statsFile, 'utf8')); } catch { return null; }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = { getAllProjects, getSessionDetail, getAgentDetail, getStatsCache, calcCost };
|