claude-session-viewer 0.3.2 → 0.3.4

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.
@@ -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
- const CLAUDE_DIR = join(homedir(), '.claude');
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) => {