claude-session-viewer 0.3.2 → 0.3.3
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/dist/client/assets/{index-cY4kqyQm.js → index-Cuqsi5in.js} +15 -15
- package/dist/client/assets/index-D1OSiyaX.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/claude/config.js +36 -0
- package/dist/server/claude/projects/repository.js +40 -0
- package/dist/server/claude/projects/service.js +41 -0
- package/dist/server/claude/sessions/agentMapping.js +100 -0
- package/dist/server/claude/sessions/agents.js +100 -0
- package/dist/server/claude/sessions/filters.js +23 -0
- package/dist/server/claude/sessions/repository.js +33 -0
- package/dist/server/claude/sessions/service.js +86 -0
- package/dist/server/claude/sessions/title.js +52 -0
- package/dist/server/constants.js +11 -0
- package/dist/server/index.js +6 -782
- package/dist/server/routes/sessions.js +95 -0
- package/dist/server/routes/statistics.js +328 -0
- package/dist/server/shared/constants.js +11 -0
- package/dist/server/types.js +1 -0
- package/dist/server/utils/jsonl.js +12 -0
- package/dist/server/utils/text.js +34 -0
- package/dist/server/utils/tokenStats.js +66 -0
- package/package.json +1 -1
- package/dist/client/assets/index-i0yZUese.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -2,21 +2,23 @@ import Fastify from 'fastify';
|
|
|
2
2
|
import fastifyStatic from '@fastify/static';
|
|
3
3
|
import websocket from '@fastify/websocket';
|
|
4
4
|
import { existsSync } from 'fs';
|
|
5
|
-
import { readdir, readFile, stat } from 'fs/promises';
|
|
6
|
-
import { homedir } from 'os';
|
|
7
5
|
import { dirname, join, resolve } from 'path';
|
|
8
6
|
import { fileURLToPath } from 'url';
|
|
9
7
|
import chokidar from 'chokidar';
|
|
10
8
|
import getPort from 'get-port';
|
|
11
|
-
|
|
9
|
+
import { CLAUDE_DIR, DEFAULT_PORT } from './constants.js';
|
|
10
|
+
import { registerSessionRoutes } from './routes/sessions.js';
|
|
11
|
+
import { registerStatisticsRoutes } from './routes/statistics.js';
|
|
12
12
|
const SERVER_DIR = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const CLIENT_DIST_DIR = resolve(SERVER_DIR, '../client');
|
|
14
|
-
const DEFAULT_PORT = 9090;
|
|
15
14
|
const server = Fastify({
|
|
16
15
|
logger: true
|
|
17
16
|
});
|
|
18
17
|
// Plugins
|
|
19
18
|
await server.register(websocket);
|
|
19
|
+
// Register API routes
|
|
20
|
+
await registerSessionRoutes(server);
|
|
21
|
+
await registerStatisticsRoutes(server);
|
|
20
22
|
if (existsSync(CLIENT_DIST_DIR)) {
|
|
21
23
|
await server.register(fastifyStatic, {
|
|
22
24
|
root: CLIENT_DIST_DIR
|
|
@@ -30,784 +32,6 @@ if (existsSync(CLIENT_DIST_DIR)) {
|
|
|
30
32
|
reply.sendFile('index.html');
|
|
31
33
|
});
|
|
32
34
|
}
|
|
33
|
-
// Helper: Parse JSONL file
|
|
34
|
-
async function parseJsonl(filePath) {
|
|
35
|
-
const content = await readFile(filePath, 'utf-8');
|
|
36
|
-
return content
|
|
37
|
-
.split('\n')
|
|
38
|
-
.filter(line => line.trim())
|
|
39
|
-
.map(line => JSON.parse(line));
|
|
40
|
-
}
|
|
41
|
-
// Helper: Clean text by removing tags
|
|
42
|
-
function cleanText(text) {
|
|
43
|
-
return text
|
|
44
|
-
.replace(/<ide_selection>[\s\S]*?<\/ide_selection>/g, ' ')
|
|
45
|
-
.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, ' ')
|
|
46
|
-
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
|
|
47
|
-
.replace(/\s+/g, ' ')
|
|
48
|
-
.trim();
|
|
49
|
-
}
|
|
50
|
-
function extractFirstText(content) {
|
|
51
|
-
if (Array.isArray(content)) {
|
|
52
|
-
for (const item of content) {
|
|
53
|
-
if (item.type === 'text' && item.text) {
|
|
54
|
-
const cleaned = cleanText(item.text);
|
|
55
|
-
if (cleaned) {
|
|
56
|
-
return cleaned;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
if (typeof content === 'string') {
|
|
63
|
-
const cleaned = cleanText(content);
|
|
64
|
-
return cleaned || null;
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
// Helper: Extract title from session messages
|
|
69
|
-
function extractSessionTitle(messages) {
|
|
70
|
-
// First, try to find queue-operation / enqueue message
|
|
71
|
-
for (const msg of messages) {
|
|
72
|
-
if (msg.type === 'queue-operation' && msg.operation === 'enqueue' && msg.content) {
|
|
73
|
-
const firstText = extractFirstText(msg.content);
|
|
74
|
-
if (firstText) {
|
|
75
|
-
return firstText.substring(0, 100).trim();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
// Fallback: Find first user message with actual text content
|
|
80
|
-
for (const msg of messages) {
|
|
81
|
-
if (msg.type === 'user' && msg.message?.content) {
|
|
82
|
-
const firstText = extractFirstText(msg.message.content);
|
|
83
|
-
if (firstText) {
|
|
84
|
-
return firstText.substring(0, 100).trim();
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return 'Untitled Session';
|
|
89
|
-
}
|
|
90
|
-
// Remove user's home directory prefix from project directory name
|
|
91
|
-
// e.g., "-Users-hanyeol-Projects-hanyeol-claude-session-viewer" → "Projects-hanyeol-claude-session-viewer"
|
|
92
|
-
function getProjectDisplayName(projectDirName) {
|
|
93
|
-
const userHomePath = homedir().split('/').filter(Boolean).join('-');
|
|
94
|
-
const prefix = `-${userHomePath}-`;
|
|
95
|
-
if (projectDirName.startsWith(prefix)) {
|
|
96
|
-
return projectDirName.slice(prefix.length);
|
|
97
|
-
}
|
|
98
|
-
return projectDirName;
|
|
99
|
-
}
|
|
100
|
-
function collectAgentDescriptions(messages) {
|
|
101
|
-
const agentDescriptions = new Map();
|
|
102
|
-
const toolUseDescriptions = new Map();
|
|
103
|
-
const toolResultAgentIds = new Map();
|
|
104
|
-
for (const msg of messages) {
|
|
105
|
-
if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
|
|
106
|
-
for (const item of msg.message.content) {
|
|
107
|
-
if (item.type === 'tool_use' && item.name === 'Task' && item.input?.description) {
|
|
108
|
-
toolUseDescriptions.set(item.id, item.input.description);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
const agentId = msg.agentId || msg.toolUseResult?.agentId;
|
|
113
|
-
if (agentId && msg.message?.content && Array.isArray(msg.message.content)) {
|
|
114
|
-
for (const item of msg.message.content) {
|
|
115
|
-
if (item.type === 'tool_result' && item.tool_use_id) {
|
|
116
|
-
toolResultAgentIds.set(item.tool_use_id, agentId);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
for (const [toolUseId, description] of toolUseDescriptions.entries()) {
|
|
122
|
-
const agentId = toolResultAgentIds.get(toolUseId);
|
|
123
|
-
if (agentId) {
|
|
124
|
-
agentDescriptions.set(`agent-${agentId}`, description);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return agentDescriptions;
|
|
128
|
-
}
|
|
129
|
-
function attachAgentSessionsFromMap(session, agentDescriptions, agentSessionsMap) {
|
|
130
|
-
if (agentDescriptions.size === 0)
|
|
131
|
-
return;
|
|
132
|
-
session.agentSessions = [];
|
|
133
|
-
for (const [agentSessionId, description] of agentDescriptions) {
|
|
134
|
-
const agentSession = agentSessionsMap.get(agentSessionId);
|
|
135
|
-
if (agentSession) {
|
|
136
|
-
agentSession.title = description;
|
|
137
|
-
session.agentSessions.push(agentSession);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
session.agentSessions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
141
|
-
}
|
|
142
|
-
async function loadAgentSessionsFromFiles(projectPath, projectName, agentDescriptions) {
|
|
143
|
-
const agentSessions = [];
|
|
144
|
-
for (const [agentSessionId, description] of agentDescriptions) {
|
|
145
|
-
const agentFile = join(projectPath, `${agentSessionId}.jsonl`);
|
|
146
|
-
try {
|
|
147
|
-
const agentMessages = await parseJsonl(agentFile);
|
|
148
|
-
const agentFileStat = await stat(agentFile);
|
|
149
|
-
agentSessions.push({
|
|
150
|
-
id: agentSessionId,
|
|
151
|
-
project: projectName,
|
|
152
|
-
timestamp: agentFileStat.mtime.toISOString(),
|
|
153
|
-
messages: agentMessages,
|
|
154
|
-
messageCount: agentMessages.length,
|
|
155
|
-
title: description,
|
|
156
|
-
isAgent: true
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
// Skip if agent file not found
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
agentSessions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
164
|
-
return agentSessions;
|
|
165
|
-
}
|
|
166
|
-
function findAgentTitleFromParentMessages(messages, agentId) {
|
|
167
|
-
const agentDescriptions = collectAgentDescriptions(messages);
|
|
168
|
-
const description = agentDescriptions.get(`agent-${agentId}`);
|
|
169
|
-
return description || null;
|
|
170
|
-
}
|
|
171
|
-
// Helper: Get all sessions from a project directory
|
|
172
|
-
async function getProjectSessions(projectPath) {
|
|
173
|
-
const files = await readdir(projectPath);
|
|
174
|
-
const allSessions = [];
|
|
175
|
-
const agentSessionsMap = new Map();
|
|
176
|
-
// First pass: collect all sessions
|
|
177
|
-
for (const file of files) {
|
|
178
|
-
if (file.endsWith('.jsonl')) {
|
|
179
|
-
const filePath = join(projectPath, file);
|
|
180
|
-
const fileStat = await stat(filePath);
|
|
181
|
-
// Skip empty files
|
|
182
|
-
if (fileStat.size === 0)
|
|
183
|
-
continue;
|
|
184
|
-
try {
|
|
185
|
-
const messages = await parseJsonl(filePath);
|
|
186
|
-
// Filter: Skip sessions with only 1 message that is assistant-only
|
|
187
|
-
if (messages.length === 1 && messages[0].type === 'assistant') {
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
// Extract project name from path
|
|
191
|
-
const projectName = getProjectDisplayName(projectPath.split('/').pop() || 'unknown');
|
|
192
|
-
// Extract session title
|
|
193
|
-
const title = extractSessionTitle(messages);
|
|
194
|
-
const sessionId = file.replace('.jsonl', '');
|
|
195
|
-
const isAgent = sessionId.startsWith('agent-');
|
|
196
|
-
const session = {
|
|
197
|
-
id: sessionId,
|
|
198
|
-
project: projectName,
|
|
199
|
-
timestamp: fileStat.mtime.toISOString(),
|
|
200
|
-
messages,
|
|
201
|
-
messageCount: messages.length,
|
|
202
|
-
title,
|
|
203
|
-
isAgent
|
|
204
|
-
};
|
|
205
|
-
if (isAgent) {
|
|
206
|
-
agentSessionsMap.set(sessionId, session);
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
allSessions.push(session);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
catch (error) {
|
|
213
|
-
console.error(`Error parsing ${file}:`, error);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// Second pass: attach agent sessions to their parent sessions
|
|
218
|
-
for (const session of allSessions) {
|
|
219
|
-
const agentDescriptions = collectAgentDescriptions(session.messages);
|
|
220
|
-
attachAgentSessionsFromMap(session, agentDescriptions, agentSessionsMap);
|
|
221
|
-
}
|
|
222
|
-
return allSessions;
|
|
223
|
-
}
|
|
224
|
-
// Claude API Pricing (as of January 2025)
|
|
225
|
-
// Prices per 1M tokens
|
|
226
|
-
const PRICING = {
|
|
227
|
-
'claude-sonnet-4-5-20250929': {
|
|
228
|
-
input: 3.0,
|
|
229
|
-
output: 15.0,
|
|
230
|
-
cacheCreation: 3.75,
|
|
231
|
-
cacheRead: 0.30
|
|
232
|
-
},
|
|
233
|
-
'claude-sonnet-4-20250514': {
|
|
234
|
-
input: 3.0,
|
|
235
|
-
output: 15.0,
|
|
236
|
-
cacheCreation: 3.75,
|
|
237
|
-
cacheRead: 0.30
|
|
238
|
-
},
|
|
239
|
-
'claude-opus-4-20250514': {
|
|
240
|
-
input: 15.0,
|
|
241
|
-
output: 75.0,
|
|
242
|
-
cacheCreation: 18.75,
|
|
243
|
-
cacheRead: 1.50
|
|
244
|
-
},
|
|
245
|
-
'claude-haiku-4-20250515': {
|
|
246
|
-
input: 0.80,
|
|
247
|
-
output: 4.0,
|
|
248
|
-
cacheCreation: 1.0,
|
|
249
|
-
cacheRead: 0.08
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
// Helper: Calculate cost for token usage
|
|
253
|
-
function calculateCost(usage, model) {
|
|
254
|
-
const pricing = PRICING[model] || PRICING['claude-sonnet-4-5-20250929'];
|
|
255
|
-
return {
|
|
256
|
-
inputCost: (usage.inputTokens / 1000000) * pricing.input,
|
|
257
|
-
outputCost: (usage.outputTokens / 1000000) * pricing.output,
|
|
258
|
-
cacheCreationCost: (usage.cacheCreationTokens / 1000000) * pricing.cacheCreation,
|
|
259
|
-
cacheReadCost: (usage.cacheReadTokens / 1000000) * pricing.cacheRead,
|
|
260
|
-
totalCost: (usage.inputTokens / 1000000) * pricing.input +
|
|
261
|
-
(usage.outputTokens / 1000000) * pricing.output +
|
|
262
|
-
(usage.cacheCreationTokens / 1000000) * pricing.cacheCreation +
|
|
263
|
-
(usage.cacheReadTokens / 1000000) * pricing.cacheRead
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
// Helper: Extract token usage from message
|
|
267
|
-
function extractTokenUsage(message) {
|
|
268
|
-
if (message.type !== 'assistant' || !message.message?.usage) {
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
const usage = message.message.usage;
|
|
272
|
-
const model = message.message.model || 'unknown';
|
|
273
|
-
return {
|
|
274
|
-
usage: {
|
|
275
|
-
inputTokens: usage.input_tokens || 0,
|
|
276
|
-
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
|
|
277
|
-
cacheReadTokens: usage.cache_read_input_tokens || 0,
|
|
278
|
-
outputTokens: usage.output_tokens || 0,
|
|
279
|
-
totalTokens: (usage.input_tokens || 0) +
|
|
280
|
-
(usage.cache_creation_input_tokens || 0) +
|
|
281
|
-
(usage.cache_read_input_tokens || 0) +
|
|
282
|
-
(usage.output_tokens || 0)
|
|
283
|
-
},
|
|
284
|
-
model
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
// Helper: Aggregate token usage
|
|
288
|
-
function aggregateTokenUsage(usages) {
|
|
289
|
-
return usages.reduce((acc, usage) => ({
|
|
290
|
-
inputTokens: acc.inputTokens + usage.inputTokens,
|
|
291
|
-
cacheCreationTokens: acc.cacheCreationTokens + usage.cacheCreationTokens,
|
|
292
|
-
cacheReadTokens: acc.cacheReadTokens + usage.cacheReadTokens,
|
|
293
|
-
outputTokens: acc.outputTokens + usage.outputTokens,
|
|
294
|
-
totalTokens: acc.totalTokens + usage.totalTokens
|
|
295
|
-
}), {
|
|
296
|
-
inputTokens: 0,
|
|
297
|
-
cacheCreationTokens: 0,
|
|
298
|
-
cacheReadTokens: 0,
|
|
299
|
-
outputTokens: 0,
|
|
300
|
-
totalTokens: 0
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
// API: Get token statistics
|
|
304
|
-
server.get('/api/statistics/tokens', async (request, reply) => {
|
|
305
|
-
try {
|
|
306
|
-
const projectsDir = join(CLAUDE_DIR, 'projects');
|
|
307
|
-
const projects = await readdir(projectsDir);
|
|
308
|
-
// Parse days parameter (default to 30)
|
|
309
|
-
const daysParam = request.query.days || '30';
|
|
310
|
-
const cutoffDate = new Date();
|
|
311
|
-
if (daysParam !== 'all') {
|
|
312
|
-
const days = parseInt(daysParam, 10);
|
|
313
|
-
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
314
|
-
cutoffDate.setHours(0, 0, 0, 0);
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
// For 'all', set to a very old date to include everything
|
|
318
|
-
cutoffDate.setFullYear(2000, 0, 1);
|
|
319
|
-
}
|
|
320
|
-
// Data structures for aggregation
|
|
321
|
-
const dailyMap = new Map();
|
|
322
|
-
const projectMap = new Map();
|
|
323
|
-
const modelMap = new Map();
|
|
324
|
-
let totalMessages = 0;
|
|
325
|
-
let totalSessions = 0;
|
|
326
|
-
let minDate = null;
|
|
327
|
-
let maxDate = null;
|
|
328
|
-
let totalCacheCreation = 0;
|
|
329
|
-
let totalCacheRead = 0;
|
|
330
|
-
let ephemeral5mTokens = 0;
|
|
331
|
-
let ephemeral1hTokens = 0;
|
|
332
|
-
const allUsages = [];
|
|
333
|
-
const allCosts = [];
|
|
334
|
-
// Productivity metrics
|
|
335
|
-
const toolUsageMap = new Map();
|
|
336
|
-
let totalAgentSessions = 0;
|
|
337
|
-
// Trend analysis data structures
|
|
338
|
-
const hourlyMap = new Map();
|
|
339
|
-
const weekdayMap = new Map();
|
|
340
|
-
// Initialize hourly map (0-23)
|
|
341
|
-
for (let hour = 0; hour < 24; hour++) {
|
|
342
|
-
hourlyMap.set(hour, {
|
|
343
|
-
usage: { inputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
344
|
-
sessionIds: new Set(),
|
|
345
|
-
messageCount: 0
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
// Initialize weekday map (0-6, Sunday-Saturday)
|
|
349
|
-
for (let weekday = 0; weekday < 7; weekday++) {
|
|
350
|
-
weekdayMap.set(weekday, {
|
|
351
|
-
usage: { inputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
352
|
-
sessionIds: new Set(),
|
|
353
|
-
messageCount: 0
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
// Process all projects
|
|
357
|
-
for (const project of projects) {
|
|
358
|
-
const projectPath = join(projectsDir, project);
|
|
359
|
-
const projectStat = await stat(projectPath);
|
|
360
|
-
if (!projectStat.isDirectory())
|
|
361
|
-
continue;
|
|
362
|
-
const files = await readdir(projectPath);
|
|
363
|
-
const displayName = getProjectDisplayName(project);
|
|
364
|
-
// Initialize project entry
|
|
365
|
-
if (!projectMap.has(project)) {
|
|
366
|
-
projectMap.set(project, {
|
|
367
|
-
usage: {
|
|
368
|
-
inputTokens: 0,
|
|
369
|
-
cacheCreationTokens: 0,
|
|
370
|
-
cacheReadTokens: 0,
|
|
371
|
-
outputTokens: 0,
|
|
372
|
-
totalTokens: 0
|
|
373
|
-
},
|
|
374
|
-
sessionIds: new Set(),
|
|
375
|
-
displayName
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
for (const file of files) {
|
|
379
|
-
if (!file.endsWith('.jsonl'))
|
|
380
|
-
continue;
|
|
381
|
-
const filePath = join(projectPath, file);
|
|
382
|
-
const fileStat = await stat(filePath);
|
|
383
|
-
// Skip empty files
|
|
384
|
-
if (fileStat.size === 0)
|
|
385
|
-
continue;
|
|
386
|
-
// Skip agent sessions (they're counted in parent sessions)
|
|
387
|
-
const sessionId = file.replace('.jsonl', '');
|
|
388
|
-
if (sessionId.startsWith('agent-'))
|
|
389
|
-
continue;
|
|
390
|
-
try {
|
|
391
|
-
const messages = await parseJsonl(filePath);
|
|
392
|
-
// Skip sessions with only 1 assistant message
|
|
393
|
-
if (messages.length === 1 && messages[0].type === 'assistant') {
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
totalSessions++;
|
|
397
|
-
const projectData = projectMap.get(project);
|
|
398
|
-
projectData.sessionIds.add(sessionId);
|
|
399
|
-
// Check if this session has agent sessions
|
|
400
|
-
const agentDescriptions = collectAgentDescriptions(messages);
|
|
401
|
-
if (agentDescriptions.size > 0) {
|
|
402
|
-
totalAgentSessions++;
|
|
403
|
-
}
|
|
404
|
-
// Process each message
|
|
405
|
-
for (const message of messages) {
|
|
406
|
-
const tokenData = extractTokenUsage(message);
|
|
407
|
-
if (!tokenData)
|
|
408
|
-
continue;
|
|
409
|
-
// Aggregate by date
|
|
410
|
-
const messageDate = new Date(message.timestamp);
|
|
411
|
-
// Skip messages older than cutoff date
|
|
412
|
-
if (messageDate < cutoffDate)
|
|
413
|
-
continue;
|
|
414
|
-
totalMessages++;
|
|
415
|
-
const { usage, model } = tokenData;
|
|
416
|
-
// Aggregate overall
|
|
417
|
-
allUsages.push(usage);
|
|
418
|
-
allCosts.push(calculateCost(usage, model));
|
|
419
|
-
const dateKey = messageDate.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
420
|
-
if (!minDate || messageDate < minDate)
|
|
421
|
-
minDate = messageDate;
|
|
422
|
-
if (!maxDate || messageDate > maxDate)
|
|
423
|
-
maxDate = messageDate;
|
|
424
|
-
if (!dailyMap.has(dateKey)) {
|
|
425
|
-
dailyMap.set(dateKey, {
|
|
426
|
-
usage: { inputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
427
|
-
sessionIds: new Set()
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
const dailyData = dailyMap.get(dateKey);
|
|
431
|
-
dailyData.usage.inputTokens += usage.inputTokens;
|
|
432
|
-
dailyData.usage.cacheCreationTokens += usage.cacheCreationTokens;
|
|
433
|
-
dailyData.usage.cacheReadTokens += usage.cacheReadTokens;
|
|
434
|
-
dailyData.usage.outputTokens += usage.outputTokens;
|
|
435
|
-
dailyData.usage.totalTokens += usage.totalTokens;
|
|
436
|
-
dailyData.sessionIds.add(sessionId);
|
|
437
|
-
// Aggregate by hour of day
|
|
438
|
-
const hour = messageDate.getHours();
|
|
439
|
-
const hourData = hourlyMap.get(hour);
|
|
440
|
-
hourData.usage.inputTokens += usage.inputTokens;
|
|
441
|
-
hourData.usage.cacheCreationTokens += usage.cacheCreationTokens;
|
|
442
|
-
hourData.usage.cacheReadTokens += usage.cacheReadTokens;
|
|
443
|
-
hourData.usage.outputTokens += usage.outputTokens;
|
|
444
|
-
hourData.usage.totalTokens += usage.totalTokens;
|
|
445
|
-
hourData.sessionIds.add(sessionId);
|
|
446
|
-
hourData.messageCount++;
|
|
447
|
-
// Aggregate by day of week
|
|
448
|
-
const weekday = messageDate.getDay();
|
|
449
|
-
const weekdayData = weekdayMap.get(weekday);
|
|
450
|
-
weekdayData.usage.inputTokens += usage.inputTokens;
|
|
451
|
-
weekdayData.usage.cacheCreationTokens += usage.cacheCreationTokens;
|
|
452
|
-
weekdayData.usage.cacheReadTokens += usage.cacheReadTokens;
|
|
453
|
-
weekdayData.usage.outputTokens += usage.outputTokens;
|
|
454
|
-
weekdayData.usage.totalTokens += usage.totalTokens;
|
|
455
|
-
weekdayData.sessionIds.add(sessionId);
|
|
456
|
-
weekdayData.messageCount++;
|
|
457
|
-
// Aggregate by project
|
|
458
|
-
projectData.usage.inputTokens += usage.inputTokens;
|
|
459
|
-
projectData.usage.cacheCreationTokens += usage.cacheCreationTokens;
|
|
460
|
-
projectData.usage.cacheReadTokens += usage.cacheReadTokens;
|
|
461
|
-
projectData.usage.outputTokens += usage.outputTokens;
|
|
462
|
-
projectData.usage.totalTokens += usage.totalTokens;
|
|
463
|
-
// Aggregate by model
|
|
464
|
-
if (!modelMap.has(model)) {
|
|
465
|
-
modelMap.set(model, {
|
|
466
|
-
usage: { inputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
467
|
-
messageCount: 0
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
const modelData = modelMap.get(model);
|
|
471
|
-
modelData.usage.inputTokens += usage.inputTokens;
|
|
472
|
-
modelData.usage.cacheCreationTokens += usage.cacheCreationTokens;
|
|
473
|
-
modelData.usage.cacheReadTokens += usage.cacheReadTokens;
|
|
474
|
-
modelData.usage.outputTokens += usage.outputTokens;
|
|
475
|
-
modelData.usage.totalTokens += usage.totalTokens;
|
|
476
|
-
modelData.messageCount++;
|
|
477
|
-
// Cache stats
|
|
478
|
-
totalCacheCreation += usage.cacheCreationTokens;
|
|
479
|
-
totalCacheRead += usage.cacheReadTokens;
|
|
480
|
-
const cacheCreation = message.message?.usage?.cache_creation;
|
|
481
|
-
if (cacheCreation) {
|
|
482
|
-
ephemeral5mTokens += cacheCreation.ephemeral_5m_input_tokens || 0;
|
|
483
|
-
ephemeral1hTokens += cacheCreation.ephemeral_1h_input_tokens || 0;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
// Track tool usage for all messages
|
|
487
|
-
const toolUseIds = new Set();
|
|
488
|
-
const successfulToolUseIds = new Set();
|
|
489
|
-
// First pass: collect all tool_use instances
|
|
490
|
-
for (const message of messages) {
|
|
491
|
-
// Skip messages older than cutoff date
|
|
492
|
-
const messageDate = new Date(message.timestamp);
|
|
493
|
-
if (messageDate < cutoffDate)
|
|
494
|
-
continue;
|
|
495
|
-
if (message.type === 'assistant' && message.message?.content && Array.isArray(message.message.content)) {
|
|
496
|
-
for (const item of message.message.content) {
|
|
497
|
-
if (item.type === 'tool_use' && item.name && item.id) {
|
|
498
|
-
const toolName = item.name;
|
|
499
|
-
if (!toolUsageMap.has(toolName)) {
|
|
500
|
-
toolUsageMap.set(toolName, { total: 0, successful: 0 });
|
|
501
|
-
}
|
|
502
|
-
// Only count each tool_use_id once
|
|
503
|
-
if (!toolUseIds.has(item.id)) {
|
|
504
|
-
toolUseIds.add(item.id);
|
|
505
|
-
const toolStats = toolUsageMap.get(toolName);
|
|
506
|
-
toolStats.total++;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
// Second pass: find successful tool results
|
|
513
|
-
for (const message of messages) {
|
|
514
|
-
// Skip messages older than cutoff date
|
|
515
|
-
const messageDate = new Date(message.timestamp);
|
|
516
|
-
if (messageDate < cutoffDate)
|
|
517
|
-
continue;
|
|
518
|
-
if (message.message?.content && Array.isArray(message.message.content)) {
|
|
519
|
-
for (const item of message.message.content) {
|
|
520
|
-
if (item.type === 'tool_result' && !item.is_error && item.tool_use_id) {
|
|
521
|
-
// Only count each successful tool_result once
|
|
522
|
-
if (!successfulToolUseIds.has(item.tool_use_id)) {
|
|
523
|
-
successfulToolUseIds.add(item.tool_use_id);
|
|
524
|
-
// Find the corresponding tool_use to get the tool name
|
|
525
|
-
let found = false;
|
|
526
|
-
for (const msg of messages) {
|
|
527
|
-
if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
|
|
528
|
-
for (const toolUseItem of msg.message.content) {
|
|
529
|
-
if (toolUseItem.type === 'tool_use' && toolUseItem.id === item.tool_use_id && toolUseItem.name) {
|
|
530
|
-
const toolName = toolUseItem.name;
|
|
531
|
-
const toolStats = toolUsageMap.get(toolName);
|
|
532
|
-
if (toolStats) {
|
|
533
|
-
toolStats.successful++;
|
|
534
|
-
}
|
|
535
|
-
found = true;
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
if (found)
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
catch (error) {
|
|
550
|
-
console.error(`Error processing ${file}:`, error);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
// Calculate overall totals
|
|
555
|
-
const totalUsage = aggregateTokenUsage(allUsages);
|
|
556
|
-
const totalCost = allCosts.reduce((acc, cost) => ({
|
|
557
|
-
inputCost: acc.inputCost + cost.inputCost,
|
|
558
|
-
outputCost: acc.outputCost + cost.outputCost,
|
|
559
|
-
cacheCreationCost: acc.cacheCreationCost + cost.cacheCreationCost,
|
|
560
|
-
cacheReadCost: acc.cacheReadCost + cost.cacheReadCost,
|
|
561
|
-
totalCost: acc.totalCost + cost.totalCost
|
|
562
|
-
}), { inputCost: 0, outputCost: 0, cacheCreationCost: 0, cacheReadCost: 0, totalCost: 0 });
|
|
563
|
-
// Calculate cache efficiency
|
|
564
|
-
const totalPotentialInput = totalUsage.inputTokens + totalCacheCreation + totalCacheRead;
|
|
565
|
-
const cacheHitRate = totalPotentialInput > 0 ? (totalCacheRead / totalPotentialInput) * 100 : 0;
|
|
566
|
-
// Estimate savings (cache read is ~10x cheaper than regular input for Sonnet)
|
|
567
|
-
const savedCost = (totalCacheRead / 1000000) * (PRICING['claude-sonnet-4-5-20250929'].input - PRICING['claude-sonnet-4-5-20250929'].cacheRead);
|
|
568
|
-
// Convert maps to arrays
|
|
569
|
-
const daily = Array.from(dailyMap.entries())
|
|
570
|
-
.map(([date, data]) => ({
|
|
571
|
-
date,
|
|
572
|
-
usage: data.usage,
|
|
573
|
-
sessionCount: data.sessionIds.size
|
|
574
|
-
}))
|
|
575
|
-
.sort((a, b) => a.date.localeCompare(b.date));
|
|
576
|
-
const byProject = Array.from(projectMap.entries())
|
|
577
|
-
.map(([project, data]) => ({
|
|
578
|
-
project,
|
|
579
|
-
displayName: data.displayName,
|
|
580
|
-
usage: data.usage,
|
|
581
|
-
sessionCount: data.sessionIds.size
|
|
582
|
-
}))
|
|
583
|
-
.sort((a, b) => b.usage.totalTokens - a.usage.totalTokens);
|
|
584
|
-
const byModel = Array.from(modelMap.entries())
|
|
585
|
-
.map(([model, data]) => ({
|
|
586
|
-
model,
|
|
587
|
-
usage: data.usage,
|
|
588
|
-
messageCount: data.messageCount
|
|
589
|
-
}))
|
|
590
|
-
.sort((a, b) => b.usage.totalTokens - a.usage.totalTokens);
|
|
591
|
-
// Calculate productivity metrics
|
|
592
|
-
const toolUsage = Array.from(toolUsageMap.entries())
|
|
593
|
-
.map(([toolName, stats]) => ({
|
|
594
|
-
toolName,
|
|
595
|
-
totalUses: stats.total,
|
|
596
|
-
successfulUses: stats.successful,
|
|
597
|
-
successRate: stats.total > 0 ? (stats.successful / stats.total) * 100 : 0
|
|
598
|
-
}))
|
|
599
|
-
.sort((a, b) => b.totalUses - a.totalUses);
|
|
600
|
-
// Debug: log tools with success rate > 100%
|
|
601
|
-
const problematicTools = toolUsage.filter(t => t.successRate > 100);
|
|
602
|
-
if (problematicTools.length > 0) {
|
|
603
|
-
console.log('⚠️ Tools with success rate > 100%:', JSON.stringify(problematicTools, null, 2));
|
|
604
|
-
}
|
|
605
|
-
const totalToolCalls = Array.from(toolUsageMap.values()).reduce((sum, stats) => sum + stats.total, 0);
|
|
606
|
-
const agentUsageRate = totalSessions > 0 ? (totalAgentSessions / totalSessions) * 100 : 0;
|
|
607
|
-
// Convert trend maps to arrays
|
|
608
|
-
const weekdayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
609
|
-
const byHour = Array.from(hourlyMap.entries())
|
|
610
|
-
.map(([hour, data]) => ({
|
|
611
|
-
hour,
|
|
612
|
-
sessionCount: data.sessionIds.size,
|
|
613
|
-
messageCount: data.messageCount,
|
|
614
|
-
usage: data.usage
|
|
615
|
-
}))
|
|
616
|
-
.sort((a, b) => a.hour - b.hour);
|
|
617
|
-
const byWeekday = Array.from(weekdayMap.entries())
|
|
618
|
-
.map(([weekday, data]) => ({
|
|
619
|
-
weekday,
|
|
620
|
-
weekdayName: weekdayNames[weekday],
|
|
621
|
-
sessionCount: data.sessionIds.size,
|
|
622
|
-
messageCount: data.messageCount,
|
|
623
|
-
usage: data.usage
|
|
624
|
-
}))
|
|
625
|
-
.sort((a, b) => a.weekday - b.weekday);
|
|
626
|
-
const statistics = {
|
|
627
|
-
overview: {
|
|
628
|
-
total: totalUsage,
|
|
629
|
-
totalSessions,
|
|
630
|
-
totalMessages,
|
|
631
|
-
dateRange: {
|
|
632
|
-
start: minDate?.toISOString() || new Date().toISOString(),
|
|
633
|
-
end: maxDate?.toISOString() || new Date().toISOString()
|
|
634
|
-
}
|
|
635
|
-
},
|
|
636
|
-
daily,
|
|
637
|
-
byProject,
|
|
638
|
-
byModel,
|
|
639
|
-
cache: {
|
|
640
|
-
totalCacheCreation,
|
|
641
|
-
totalCacheRead,
|
|
642
|
-
ephemeral5mTokens,
|
|
643
|
-
ephemeral1hTokens,
|
|
644
|
-
cacheHitRate,
|
|
645
|
-
estimatedSavings: savedCost
|
|
646
|
-
},
|
|
647
|
-
cost: totalCost,
|
|
648
|
-
productivity: {
|
|
649
|
-
toolUsage,
|
|
650
|
-
totalToolCalls,
|
|
651
|
-
agentSessions: totalAgentSessions,
|
|
652
|
-
totalSessions,
|
|
653
|
-
agentUsageRate
|
|
654
|
-
},
|
|
655
|
-
trends: {
|
|
656
|
-
byHour,
|
|
657
|
-
byWeekday
|
|
658
|
-
}
|
|
659
|
-
};
|
|
660
|
-
console.log('Token statistics summary:', {
|
|
661
|
-
totalSessions,
|
|
662
|
-
totalMessages,
|
|
663
|
-
dailyEntries: daily.length,
|
|
664
|
-
projectEntries: byProject.length,
|
|
665
|
-
modelEntries: byModel.length,
|
|
666
|
-
sampleDaily: daily[0]
|
|
667
|
-
});
|
|
668
|
-
return statistics;
|
|
669
|
-
}
|
|
670
|
-
catch (error) {
|
|
671
|
-
console.error('Error calculating token statistics:', error);
|
|
672
|
-
return reply.code(500).send({ error: 'Internal server error' });
|
|
673
|
-
}
|
|
674
|
-
});
|
|
675
|
-
// API: Get all sessions grouped by project
|
|
676
|
-
server.get('/api/sessions', async (request, reply) => {
|
|
677
|
-
try {
|
|
678
|
-
const projectsDir = join(CLAUDE_DIR, 'projects');
|
|
679
|
-
const projects = await readdir(projectsDir);
|
|
680
|
-
const projectGroups = [];
|
|
681
|
-
for (const project of projects) {
|
|
682
|
-
const projectPath = join(projectsDir, project);
|
|
683
|
-
const projectStat = await stat(projectPath);
|
|
684
|
-
if (projectStat.isDirectory()) {
|
|
685
|
-
const sessions = await getProjectSessions(projectPath);
|
|
686
|
-
if (sessions.length > 0) {
|
|
687
|
-
// Sort sessions by timestamp descending
|
|
688
|
-
sessions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
689
|
-
const displayName = getProjectDisplayName(project);
|
|
690
|
-
projectGroups.push({
|
|
691
|
-
name: project,
|
|
692
|
-
displayName,
|
|
693
|
-
sessionCount: sessions.length,
|
|
694
|
-
lastActivity: sessions[0].timestamp, // Most recent session
|
|
695
|
-
sessions
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
// Sort project groups by last activity descending
|
|
701
|
-
projectGroups.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
|
|
702
|
-
return { projects: projectGroups };
|
|
703
|
-
}
|
|
704
|
-
catch (error) {
|
|
705
|
-
console.error('Error reading sessions:', error);
|
|
706
|
-
return { projects: [] };
|
|
707
|
-
}
|
|
708
|
-
});
|
|
709
|
-
// Helper: Inject agentId into Task tool_use content
|
|
710
|
-
function injectAgentIdsIntoMessages(messages) {
|
|
711
|
-
const toolUseToAgentId = new Map();
|
|
712
|
-
for (const msg of messages) {
|
|
713
|
-
const agentId = msg.agentId || msg.toolUseResult?.agentId;
|
|
714
|
-
const content = msg.message?.content;
|
|
715
|
-
if (!agentId || !Array.isArray(content))
|
|
716
|
-
continue;
|
|
717
|
-
for (const item of content) {
|
|
718
|
-
if (item.type === 'tool_result' && item.tool_use_id) {
|
|
719
|
-
toolUseToAgentId.set(item.tool_use_id, agentId);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
return messages.map((msg) => {
|
|
724
|
-
const content = msg.message?.content;
|
|
725
|
-
if (!Array.isArray(content))
|
|
726
|
-
return msg;
|
|
727
|
-
const updatedContent = content.map((item) => {
|
|
728
|
-
if (item.type !== 'tool_use' || item.name !== 'Task' || !item.id)
|
|
729
|
-
return item;
|
|
730
|
-
const agentId = toolUseToAgentId.get(item.id);
|
|
731
|
-
return agentId ? { ...item, agentId } : item;
|
|
732
|
-
});
|
|
733
|
-
return {
|
|
734
|
-
...msg,
|
|
735
|
-
message: {
|
|
736
|
-
...msg.message,
|
|
737
|
-
content: updatedContent
|
|
738
|
-
}
|
|
739
|
-
};
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
// API: Get session by ID
|
|
743
|
-
server.get('/api/sessions/:id', async (request, reply) => {
|
|
744
|
-
try {
|
|
745
|
-
const { id } = request.params;
|
|
746
|
-
const projectsDir = join(CLAUDE_DIR, 'projects');
|
|
747
|
-
const projects = await readdir(projectsDir);
|
|
748
|
-
const isAgent = id.startsWith('agent-');
|
|
749
|
-
for (const project of projects) {
|
|
750
|
-
const projectPath = join(projectsDir, project);
|
|
751
|
-
const sessionFile = join(projectPath, `${id}.jsonl`);
|
|
752
|
-
try {
|
|
753
|
-
const messages = await parseJsonl(sessionFile);
|
|
754
|
-
const fileStat = await stat(sessionFile);
|
|
755
|
-
const projectName = getProjectDisplayName(project);
|
|
756
|
-
let title = extractSessionTitle(messages);
|
|
757
|
-
// For agent sessions, try to find the description from parent session
|
|
758
|
-
if (isAgent) {
|
|
759
|
-
const agentId = id.replace('agent-', '');
|
|
760
|
-
const files = await readdir(projectPath);
|
|
761
|
-
for (const file of files) {
|
|
762
|
-
if (!file.startsWith('agent-') && file.endsWith('.jsonl')) {
|
|
763
|
-
try {
|
|
764
|
-
const parentMessages = await parseJsonl(join(projectPath, file));
|
|
765
|
-
const description = findAgentTitleFromParentMessages(parentMessages, agentId);
|
|
766
|
-
if (description) {
|
|
767
|
-
title = description;
|
|
768
|
-
break;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
catch {
|
|
772
|
-
continue;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
// If this is a main session (not agent), attach agent sessions
|
|
778
|
-
let agentSessions;
|
|
779
|
-
if (!isAgent) {
|
|
780
|
-
const agentDescriptions = collectAgentDescriptions(messages);
|
|
781
|
-
if (agentDescriptions.size > 0) {
|
|
782
|
-
agentSessions = await loadAgentSessionsFromFiles(projectPath, projectName, agentDescriptions);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
// Inject agentId into tool_result content
|
|
786
|
-
const messagesWithAgentIds = injectAgentIdsIntoMessages(messages);
|
|
787
|
-
return {
|
|
788
|
-
session: {
|
|
789
|
-
id,
|
|
790
|
-
project: projectName,
|
|
791
|
-
timestamp: fileStat.mtime.toISOString(),
|
|
792
|
-
messages: messagesWithAgentIds,
|
|
793
|
-
messageCount: messages.length,
|
|
794
|
-
title,
|
|
795
|
-
isAgent,
|
|
796
|
-
agentSessions
|
|
797
|
-
}
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
catch {
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
return reply.code(404).send({ error: 'Session not found' });
|
|
805
|
-
}
|
|
806
|
-
catch (error) {
|
|
807
|
-
console.error('Error reading session:', error);
|
|
808
|
-
return reply.code(500).send({ error: 'Internal server error' });
|
|
809
|
-
}
|
|
810
|
-
});
|
|
811
35
|
// WebSocket: Watch for file changes
|
|
812
36
|
server.register(async function (fastify) {
|
|
813
37
|
fastify.get('/ws', { websocket: true }, (socket) => {
|