claude-code-templates 1.8.0 → 1.8.1
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 +246 -0
- package/package.json +26 -12
- package/src/analytics/core/ConversationAnalyzer.js +754 -0
- package/src/analytics/core/FileWatcher.js +285 -0
- package/src/analytics/core/ProcessDetector.js +242 -0
- package/src/analytics/core/SessionAnalyzer.js +597 -0
- package/src/analytics/core/StateCalculator.js +190 -0
- package/src/analytics/data/DataCache.js +550 -0
- package/src/analytics/notifications/NotificationManager.js +448 -0
- package/src/analytics/notifications/WebSocketServer.js +526 -0
- package/src/analytics/utils/PerformanceMonitor.js +455 -0
- package/src/analytics-web/assets/js/main.js +312 -0
- package/src/analytics-web/components/Charts.js +114 -0
- package/src/analytics-web/components/ConversationTable.js +437 -0
- package/src/analytics-web/components/Dashboard.js +573 -0
- package/src/analytics-web/components/SessionTimer.js +596 -0
- package/src/analytics-web/index.html +882 -49
- package/src/analytics-web/index.html.original +1939 -0
- package/src/analytics-web/services/DataService.js +357 -0
- package/src/analytics-web/services/StateService.js +276 -0
- package/src/analytics-web/services/WebSocketService.js +523 -0
- package/src/analytics.js +626 -2317
- package/src/analytics.log +0 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ConversationAnalyzer - Handles conversation data loading, parsing, and analysis
|
|
7
|
+
* Extracted from monolithic analytics.js for better maintainability
|
|
8
|
+
*/
|
|
9
|
+
class ConversationAnalyzer {
|
|
10
|
+
constructor(claudeDir, dataCache = null) {
|
|
11
|
+
this.claudeDir = claudeDir;
|
|
12
|
+
this.dataCache = dataCache;
|
|
13
|
+
this.data = {
|
|
14
|
+
conversations: [],
|
|
15
|
+
activeProjects: [],
|
|
16
|
+
summary: {},
|
|
17
|
+
orphanProcesses: [],
|
|
18
|
+
realtimeStats: {}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Main data loading orchestrator method
|
|
24
|
+
* @param {Object} stateCalculator - StateCalculator instance
|
|
25
|
+
* @param {Object} processDetector - ProcessDetector instance
|
|
26
|
+
* @returns {Promise<Object>} Complete analyzed data
|
|
27
|
+
*/
|
|
28
|
+
async loadInitialData(stateCalculator, processDetector) {
|
|
29
|
+
console.log(chalk.yellow('📊 Analyzing Claude Code data...'));
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Load conversation files
|
|
33
|
+
const conversations = await this.loadConversations(stateCalculator);
|
|
34
|
+
this.data.conversations = conversations;
|
|
35
|
+
|
|
36
|
+
// Load active projects
|
|
37
|
+
const projects = await this.loadActiveProjects();
|
|
38
|
+
this.data.activeProjects = projects;
|
|
39
|
+
|
|
40
|
+
// Detect active Claude processes and enrich data
|
|
41
|
+
const enrichmentResult = await processDetector.enrichWithRunningProcesses(
|
|
42
|
+
this.data.conversations,
|
|
43
|
+
this.claudeDir,
|
|
44
|
+
stateCalculator
|
|
45
|
+
);
|
|
46
|
+
this.data.conversations = enrichmentResult.conversations;
|
|
47
|
+
this.data.orphanProcesses = enrichmentResult.orphanProcesses;
|
|
48
|
+
|
|
49
|
+
// Calculate summary statistics with caching
|
|
50
|
+
this.data.summary = await this.calculateSummary(conversations, projects);
|
|
51
|
+
|
|
52
|
+
// Update realtime stats
|
|
53
|
+
this.updateRealtimeStats();
|
|
54
|
+
|
|
55
|
+
console.log(chalk.green('✅ Data analysis complete'));
|
|
56
|
+
console.log(chalk.gray(`Found ${conversations.length} conversations across ${projects.length} projects`));
|
|
57
|
+
|
|
58
|
+
return this.data;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(chalk.red('Error loading Claude data:'), error.message);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load and parse all conversation files recursively
|
|
67
|
+
* @param {Object} stateCalculator - StateCalculator instance for status determination
|
|
68
|
+
* @returns {Promise<Array>} Array of conversation objects
|
|
69
|
+
*/
|
|
70
|
+
async loadConversations(stateCalculator) {
|
|
71
|
+
const conversations = [];
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Search for .jsonl files recursively in all subdirectories
|
|
75
|
+
const findJsonlFiles = async (dir) => {
|
|
76
|
+
const files = [];
|
|
77
|
+
const items = await fs.readdir(dir);
|
|
78
|
+
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
const itemPath = path.join(dir, item);
|
|
81
|
+
const stats = await fs.stat(itemPath);
|
|
82
|
+
|
|
83
|
+
if (stats.isDirectory()) {
|
|
84
|
+
// Recursively search subdirectories
|
|
85
|
+
const subFiles = await findJsonlFiles(itemPath);
|
|
86
|
+
files.push(...subFiles);
|
|
87
|
+
} else if (item.endsWith('.jsonl')) {
|
|
88
|
+
files.push(itemPath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return files;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const jsonlFiles = await findJsonlFiles(this.claudeDir);
|
|
96
|
+
console.log(chalk.blue(`Found ${jsonlFiles.length} conversation files`));
|
|
97
|
+
|
|
98
|
+
for (const filePath of jsonlFiles) {
|
|
99
|
+
const stats = await this.getFileStats(filePath);
|
|
100
|
+
const filename = path.basename(filePath);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Extract project name from path
|
|
104
|
+
const projectFromPath = this.extractProjectFromPath(filePath);
|
|
105
|
+
|
|
106
|
+
// Use cached parsed conversation if available
|
|
107
|
+
const parsedMessages = await this.getParsedConversation(filePath);
|
|
108
|
+
|
|
109
|
+
// Calculate real token usage and extract model info with caching
|
|
110
|
+
const tokenUsage = await this.getCachedTokenUsage(filePath, parsedMessages);
|
|
111
|
+
const modelInfo = await this.getCachedModelInfo(filePath, parsedMessages);
|
|
112
|
+
|
|
113
|
+
const conversation = {
|
|
114
|
+
id: filename.replace('.jsonl', ''),
|
|
115
|
+
filename: filename,
|
|
116
|
+
filePath: filePath,
|
|
117
|
+
messageCount: parsedMessages.length,
|
|
118
|
+
fileSize: stats.size,
|
|
119
|
+
lastModified: stats.mtime,
|
|
120
|
+
created: stats.birthtime,
|
|
121
|
+
tokens: tokenUsage.total > 0 ? tokenUsage.total : this.estimateTokens(await this.getFileContent(filePath)),
|
|
122
|
+
tokenUsage: tokenUsage,
|
|
123
|
+
modelInfo: modelInfo,
|
|
124
|
+
project: projectFromPath || this.extractProjectFromConversation(parsedMessages),
|
|
125
|
+
status: stateCalculator.determineConversationStatus(parsedMessages, stats.mtime),
|
|
126
|
+
conversationState: stateCalculator.determineConversationState(parsedMessages, stats.mtime),
|
|
127
|
+
statusSquares: await this.getCachedStatusSquares(filePath, parsedMessages),
|
|
128
|
+
parsedMessages: parsedMessages, // Include parsed messages for session analysis
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
conversations.push(conversation);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.warn(chalk.yellow(`Warning: Could not parse ${filename}:`, error.message));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return conversations.sort((a, b) => b.lastModified - a.lastModified);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error(chalk.red('Error loading conversations:'), error.message);
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load active Claude projects from directory structure
|
|
146
|
+
* @returns {Promise<Array>} Array of project objects
|
|
147
|
+
*/
|
|
148
|
+
async loadActiveProjects() {
|
|
149
|
+
const projects = [];
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const files = await fs.readdir(this.claudeDir);
|
|
153
|
+
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
const filePath = path.join(this.claudeDir, file);
|
|
156
|
+
const stats = await fs.stat(filePath);
|
|
157
|
+
|
|
158
|
+
if (stats.isDirectory() && !file.startsWith('.')) {
|
|
159
|
+
const projectPath = filePath;
|
|
160
|
+
const todoFiles = await this.findTodoFiles(projectPath);
|
|
161
|
+
|
|
162
|
+
const project = {
|
|
163
|
+
name: file,
|
|
164
|
+
path: projectPath,
|
|
165
|
+
lastActivity: stats.mtime,
|
|
166
|
+
todoFiles: todoFiles.length,
|
|
167
|
+
status: this.determineProjectStatus(stats.mtime),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
projects.push(project);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return projects.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(chalk.red('Error loading projects:'), error.message);
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get file content with caching support
|
|
183
|
+
* @param {string} filepath - Path to file
|
|
184
|
+
* @returns {Promise<string>} File content
|
|
185
|
+
*/
|
|
186
|
+
async getFileContent(filepath) {
|
|
187
|
+
if (this.dataCache) {
|
|
188
|
+
return await this.dataCache.getFileContent(filepath);
|
|
189
|
+
}
|
|
190
|
+
return await fs.readFile(filepath, 'utf8');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get file stats with caching support
|
|
195
|
+
* @param {string} filepath - Path to file
|
|
196
|
+
* @returns {Promise<Object>} File stats
|
|
197
|
+
*/
|
|
198
|
+
async getFileStats(filepath) {
|
|
199
|
+
if (this.dataCache) {
|
|
200
|
+
return await this.dataCache.getFileStats(filepath);
|
|
201
|
+
}
|
|
202
|
+
return await fs.stat(filepath);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get parsed conversation with caching support
|
|
207
|
+
* @param {string} filepath - Path to conversation file
|
|
208
|
+
* @returns {Promise<Array>} Parsed conversation messages
|
|
209
|
+
*/
|
|
210
|
+
async getParsedConversation(filepath) {
|
|
211
|
+
if (this.dataCache) {
|
|
212
|
+
return await this.dataCache.getParsedConversation(filepath);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Fallback to direct parsing
|
|
216
|
+
const content = await fs.readFile(filepath, 'utf8');
|
|
217
|
+
return content.trim().split('\n')
|
|
218
|
+
.filter(line => line.trim())
|
|
219
|
+
.map(line => {
|
|
220
|
+
try {
|
|
221
|
+
const item = JSON.parse(line);
|
|
222
|
+
if (item.message && item.message.role) {
|
|
223
|
+
return {
|
|
224
|
+
role: item.message.role,
|
|
225
|
+
timestamp: new Date(item.timestamp),
|
|
226
|
+
content: item.message.content,
|
|
227
|
+
model: item.message.model || null,
|
|
228
|
+
usage: item.message.usage || null,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
return null;
|
|
233
|
+
})
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get cached token usage calculation
|
|
239
|
+
* @param {string} filepath - File path
|
|
240
|
+
* @param {Array} parsedMessages - Parsed messages array
|
|
241
|
+
* @returns {Promise<Object>} Token usage statistics
|
|
242
|
+
*/
|
|
243
|
+
async getCachedTokenUsage(filepath, parsedMessages) {
|
|
244
|
+
if (this.dataCache) {
|
|
245
|
+
return await this.dataCache.getCachedTokenUsage(filepath, () => {
|
|
246
|
+
return this.calculateRealTokenUsage(parsedMessages);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return this.calculateRealTokenUsage(parsedMessages);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get cached model info extraction
|
|
254
|
+
* @param {string} filepath - File path
|
|
255
|
+
* @param {Array} parsedMessages - Parsed messages array
|
|
256
|
+
* @returns {Promise<Object>} Model info data
|
|
257
|
+
*/
|
|
258
|
+
async getCachedModelInfo(filepath, parsedMessages) {
|
|
259
|
+
if (this.dataCache) {
|
|
260
|
+
return await this.dataCache.getCachedModelInfo(filepath, () => {
|
|
261
|
+
return this.extractModelInfo(parsedMessages);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return this.extractModelInfo(parsedMessages);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get cached status squares generation
|
|
269
|
+
* @param {string} filepath - File path
|
|
270
|
+
* @param {Array} parsedMessages - Parsed messages array
|
|
271
|
+
* @returns {Promise<Array>} Status squares data
|
|
272
|
+
*/
|
|
273
|
+
async getCachedStatusSquares(filepath, parsedMessages) {
|
|
274
|
+
if (this.dataCache) {
|
|
275
|
+
return await this.dataCache.getCachedStatusSquares(filepath, () => {
|
|
276
|
+
return this.generateStatusSquares(parsedMessages);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return this.generateStatusSquares(parsedMessages);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Calculate real token usage from message usage data
|
|
284
|
+
* @param {Array} parsedMessages - Array of parsed message objects
|
|
285
|
+
* @returns {Object} Token usage statistics
|
|
286
|
+
*/
|
|
287
|
+
calculateRealTokenUsage(parsedMessages) {
|
|
288
|
+
let totalInputTokens = 0;
|
|
289
|
+
let totalOutputTokens = 0;
|
|
290
|
+
let totalCacheCreationTokens = 0;
|
|
291
|
+
let totalCacheReadTokens = 0;
|
|
292
|
+
let messagesWithUsage = 0;
|
|
293
|
+
|
|
294
|
+
parsedMessages.forEach(message => {
|
|
295
|
+
if (message.usage) {
|
|
296
|
+
totalInputTokens += message.usage.input_tokens || 0;
|
|
297
|
+
totalOutputTokens += message.usage.output_tokens || 0;
|
|
298
|
+
totalCacheCreationTokens += message.usage.cache_creation_input_tokens || 0;
|
|
299
|
+
totalCacheReadTokens += message.usage.cache_read_input_tokens || 0;
|
|
300
|
+
messagesWithUsage++;
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
total: totalInputTokens + totalOutputTokens,
|
|
306
|
+
inputTokens: totalInputTokens,
|
|
307
|
+
outputTokens: totalOutputTokens,
|
|
308
|
+
cacheCreationTokens: totalCacheCreationTokens,
|
|
309
|
+
cacheReadTokens: totalCacheReadTokens,
|
|
310
|
+
messagesWithUsage: messagesWithUsage,
|
|
311
|
+
totalMessages: parsedMessages.length,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Extract model and service tier information from messages
|
|
317
|
+
* @param {Array} parsedMessages - Array of parsed message objects
|
|
318
|
+
* @returns {Object} Model information
|
|
319
|
+
*/
|
|
320
|
+
extractModelInfo(parsedMessages) {
|
|
321
|
+
const models = new Set();
|
|
322
|
+
const serviceTiers = new Set();
|
|
323
|
+
let lastModel = null;
|
|
324
|
+
let lastServiceTier = null;
|
|
325
|
+
|
|
326
|
+
parsedMessages.forEach(message => {
|
|
327
|
+
if (message.model) {
|
|
328
|
+
models.add(message.model);
|
|
329
|
+
lastModel = message.model;
|
|
330
|
+
}
|
|
331
|
+
if (message.usage && message.usage.service_tier) {
|
|
332
|
+
serviceTiers.add(message.usage.service_tier);
|
|
333
|
+
lastServiceTier = message.usage.service_tier;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
models: Array.from(models),
|
|
339
|
+
primaryModel: lastModel || models.values().next().value || 'Unknown',
|
|
340
|
+
serviceTiers: Array.from(serviceTiers),
|
|
341
|
+
currentServiceTier: lastServiceTier || serviceTiers.values().next().value || 'Unknown',
|
|
342
|
+
hasMultipleModels: models.size > 1,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Extract project name from Claude directory file path
|
|
348
|
+
* @param {string} filePath - Full path to conversation file
|
|
349
|
+
* @returns {string|null} Project name or null
|
|
350
|
+
*/
|
|
351
|
+
extractProjectFromPath(filePath) {
|
|
352
|
+
// Extract project name from file path like:
|
|
353
|
+
// /Users/user/.claude/projects/-Users-user-Projects-MyProject/conversation.jsonl
|
|
354
|
+
const pathParts = filePath.split('/');
|
|
355
|
+
const projectIndex = pathParts.findIndex(part => part === 'projects');
|
|
356
|
+
|
|
357
|
+
if (projectIndex !== -1 && projectIndex + 1 < pathParts.length) {
|
|
358
|
+
const projectDir = pathParts[projectIndex + 1];
|
|
359
|
+
// Clean up the project directory name
|
|
360
|
+
const cleanName = projectDir
|
|
361
|
+
.replace(/^-/, '')
|
|
362
|
+
.replace(/-/g, '/')
|
|
363
|
+
.split('/')
|
|
364
|
+
.pop() || 'Unknown';
|
|
365
|
+
|
|
366
|
+
return cleanName;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Attempt to extract project information from conversation content
|
|
374
|
+
* @param {Array} messages - Array of message objects
|
|
375
|
+
* @returns {string} Project name or 'Unknown'
|
|
376
|
+
*/
|
|
377
|
+
extractProjectFromConversation(messages) {
|
|
378
|
+
// Try to extract project information from conversation
|
|
379
|
+
for (const message of messages.slice(0, 5)) {
|
|
380
|
+
if (message.content && typeof message.content === 'string') {
|
|
381
|
+
const pathMatch = message.content.match(/\/([^\/\s]+)$/);
|
|
382
|
+
if (pathMatch) {
|
|
383
|
+
return pathMatch[1];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return 'Unknown';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Generate status indicators for conversation messages
|
|
392
|
+
* @param {Array} messages - Array of message objects
|
|
393
|
+
* @returns {Array} Array of status square objects
|
|
394
|
+
*/
|
|
395
|
+
generateStatusSquares(messages) {
|
|
396
|
+
if (!messages || messages.length === 0) {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Sort messages by timestamp and take last 10 for status squares
|
|
401
|
+
const sortedMessages = messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
402
|
+
const recentMessages = sortedMessages.slice(-10);
|
|
403
|
+
|
|
404
|
+
return recentMessages.map((message, index) => {
|
|
405
|
+
const messageNum = sortedMessages.length - recentMessages.length + index + 1;
|
|
406
|
+
|
|
407
|
+
// Determine status based on message content and role
|
|
408
|
+
if (message.role === 'user') {
|
|
409
|
+
return {
|
|
410
|
+
type: 'pending',
|
|
411
|
+
tooltip: `Message #${messageNum}: User input`,
|
|
412
|
+
};
|
|
413
|
+
} else if (message.role === 'assistant') {
|
|
414
|
+
// Check if the message contains tool usage or errors
|
|
415
|
+
const content = message.content || '';
|
|
416
|
+
|
|
417
|
+
if (typeof content === 'string') {
|
|
418
|
+
if (content.includes('[Tool:') || content.includes('tool_use')) {
|
|
419
|
+
return {
|
|
420
|
+
type: 'tool',
|
|
421
|
+
tooltip: `Message #${messageNum}: Tool execution`,
|
|
422
|
+
};
|
|
423
|
+
} else if (content.includes('error') || content.includes('Error') || content.includes('failed')) {
|
|
424
|
+
return {
|
|
425
|
+
type: 'error',
|
|
426
|
+
tooltip: `Message #${messageNum}: Error in response`,
|
|
427
|
+
};
|
|
428
|
+
} else {
|
|
429
|
+
return {
|
|
430
|
+
type: 'success',
|
|
431
|
+
tooltip: `Message #${messageNum}: Successful response`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
} else if (Array.isArray(content)) {
|
|
435
|
+
// Check for tool_use blocks in array content
|
|
436
|
+
const hasToolUse = content.some(block => block.type === 'tool_use');
|
|
437
|
+
const hasError = content.some(block =>
|
|
438
|
+
block.type === 'text' && (block.text?.includes('error') || block.text?.includes('Error'))
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
if (hasError) {
|
|
442
|
+
return {
|
|
443
|
+
type: 'error',
|
|
444
|
+
tooltip: `Message #${messageNum}: Error in response`,
|
|
445
|
+
};
|
|
446
|
+
} else if (hasToolUse) {
|
|
447
|
+
return {
|
|
448
|
+
type: 'tool',
|
|
449
|
+
tooltip: `Message #${messageNum}: Tool execution`,
|
|
450
|
+
};
|
|
451
|
+
} else {
|
|
452
|
+
return {
|
|
453
|
+
type: 'success',
|
|
454
|
+
tooltip: `Message #${messageNum}: Successful response`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
type: 'pending',
|
|
462
|
+
tooltip: `Message #${messageNum}: Unknown status`,
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Calculate summary statistics from conversations and projects data with caching
|
|
469
|
+
* @param {Array} conversations - Array of conversation objects
|
|
470
|
+
* @param {Array} projects - Array of project objects
|
|
471
|
+
* @returns {Promise<Object>} Summary statistics
|
|
472
|
+
*/
|
|
473
|
+
async calculateSummary(conversations, projects) {
|
|
474
|
+
if (this.dataCache) {
|
|
475
|
+
const dependencies = conversations.map(conv => conv.filePath);
|
|
476
|
+
return await this.dataCache.getCachedComputation(
|
|
477
|
+
'summary',
|
|
478
|
+
() => this.computeSummary(conversations, projects),
|
|
479
|
+
dependencies
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return this.computeSummary(conversations, projects);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Compute summary statistics (internal method)
|
|
487
|
+
* @param {Array} conversations - Array of conversation objects
|
|
488
|
+
* @param {Array} projects - Array of project objects
|
|
489
|
+
* @returns {Promise<Object>} Summary statistics
|
|
490
|
+
*/
|
|
491
|
+
async computeSummary(conversations, projects) {
|
|
492
|
+
const totalTokens = conversations.reduce((sum, conv) => sum + conv.tokens, 0);
|
|
493
|
+
const totalConversations = conversations.length;
|
|
494
|
+
const activeConversations = conversations.filter(c => c.status === 'active').length;
|
|
495
|
+
const activeProjects = projects.filter(p => p.status === 'active').length;
|
|
496
|
+
|
|
497
|
+
const avgTokensPerConversation = totalConversations > 0 ? Math.round(totalTokens / totalConversations) : 0;
|
|
498
|
+
const totalFileSize = conversations.reduce((sum, conv) => sum + conv.fileSize, 0);
|
|
499
|
+
|
|
500
|
+
// Calculate real Claude sessions (5-hour periods)
|
|
501
|
+
const claudeSessions = await this.calculateClaudeSessions(conversations);
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
totalConversations,
|
|
505
|
+
totalTokens,
|
|
506
|
+
activeConversations,
|
|
507
|
+
activeProjects,
|
|
508
|
+
avgTokensPerConversation,
|
|
509
|
+
totalFileSize: this.formatBytes(totalFileSize),
|
|
510
|
+
lastActivity: conversations.length > 0 ? conversations[0].lastModified : null,
|
|
511
|
+
claudeSessions,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Calculate Claude usage sessions based on 5-hour periods with caching
|
|
517
|
+
* @param {Array} conversations - Array of conversation objects
|
|
518
|
+
* @returns {Promise<Object>} Session statistics
|
|
519
|
+
*/
|
|
520
|
+
async calculateClaudeSessions(conversations) {
|
|
521
|
+
if (this.dataCache) {
|
|
522
|
+
const dependencies = conversations.map(conv => conv.filePath);
|
|
523
|
+
return await this.dataCache.getCachedComputation(
|
|
524
|
+
'sessions',
|
|
525
|
+
() => this.computeClaudeSessions(conversations),
|
|
526
|
+
dependencies
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return this.computeClaudeSessions(conversations);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Compute Claude usage sessions (internal method)
|
|
534
|
+
* @param {Array} conversations - Array of conversation objects
|
|
535
|
+
* @returns {Promise<Object>} Session statistics
|
|
536
|
+
*/
|
|
537
|
+
async computeClaudeSessions(conversations) {
|
|
538
|
+
// Collect all message timestamps across all conversations
|
|
539
|
+
const allMessages = [];
|
|
540
|
+
|
|
541
|
+
for (const conv of conversations) {
|
|
542
|
+
// Use cached file content for better performance
|
|
543
|
+
try {
|
|
544
|
+
const content = await this.getFileContent(conv.filePath);
|
|
545
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
546
|
+
|
|
547
|
+
lines.forEach(line => {
|
|
548
|
+
try {
|
|
549
|
+
const item = JSON.parse(line);
|
|
550
|
+
if (item.timestamp && item.message && item.message.role === 'user') {
|
|
551
|
+
// Only count user messages as session starters
|
|
552
|
+
allMessages.push({
|
|
553
|
+
timestamp: new Date(item.timestamp),
|
|
554
|
+
conversationId: conv.id,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
} catch {}
|
|
558
|
+
});
|
|
559
|
+
} catch {}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
if (allMessages.length === 0) return {
|
|
563
|
+
total: 0,
|
|
564
|
+
currentMonth: 0,
|
|
565
|
+
thisWeek: 0
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// Sort messages by timestamp
|
|
569
|
+
allMessages.sort((a, b) => a.timestamp - b.timestamp);
|
|
570
|
+
|
|
571
|
+
// Calculate sessions (5-hour periods)
|
|
572
|
+
const sessions = [];
|
|
573
|
+
let currentSession = null;
|
|
574
|
+
|
|
575
|
+
allMessages.forEach(message => {
|
|
576
|
+
if (!currentSession) {
|
|
577
|
+
// Start first session
|
|
578
|
+
currentSession = {
|
|
579
|
+
start: message.timestamp,
|
|
580
|
+
end: new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000), // +5 hours
|
|
581
|
+
messageCount: 1,
|
|
582
|
+
conversations: new Set([message.conversationId]),
|
|
583
|
+
};
|
|
584
|
+
} else if (message.timestamp <= currentSession.end) {
|
|
585
|
+
// Message is within current session
|
|
586
|
+
currentSession.messageCount++;
|
|
587
|
+
currentSession.conversations.add(message.conversationId);
|
|
588
|
+
// Update session end if this message extends beyond current session
|
|
589
|
+
const potentialEnd = new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000);
|
|
590
|
+
if (potentialEnd > currentSession.end) {
|
|
591
|
+
currentSession.end = potentialEnd;
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
// Message is outside current session, start new session
|
|
595
|
+
sessions.push(currentSession);
|
|
596
|
+
currentSession = {
|
|
597
|
+
start: message.timestamp,
|
|
598
|
+
end: new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000),
|
|
599
|
+
messageCount: 1,
|
|
600
|
+
conversations: new Set([message.conversationId]),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Add the last session
|
|
606
|
+
if (currentSession) {
|
|
607
|
+
sessions.push(currentSession);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Calculate statistics
|
|
611
|
+
const now = new Date();
|
|
612
|
+
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
613
|
+
const thisWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
614
|
+
|
|
615
|
+
const currentMonthSessions = sessions.filter(s => s.start >= currentMonth).length;
|
|
616
|
+
const thisWeekSessions = sessions.filter(s => s.start >= thisWeek).length;
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
total: sessions.length,
|
|
620
|
+
currentMonth: currentMonthSessions,
|
|
621
|
+
thisWeek: thisWeekSessions,
|
|
622
|
+
sessions: sessions.map(s => ({
|
|
623
|
+
start: s.start,
|
|
624
|
+
end: s.end,
|
|
625
|
+
messageCount: s.messageCount,
|
|
626
|
+
conversationCount: s.conversations.size,
|
|
627
|
+
duration: Math.round((s.end - s.start) / (1000 * 60 * 60) * 10) / 10, // hours with 1 decimal
|
|
628
|
+
})),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Simple token estimation fallback
|
|
634
|
+
* @param {string} text - Text to estimate tokens for
|
|
635
|
+
* @returns {number} Estimated token count
|
|
636
|
+
*/
|
|
637
|
+
estimateTokens(text) {
|
|
638
|
+
// Simple token estimation (roughly 4 characters per token)
|
|
639
|
+
return Math.ceil(text.length / 4);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Find TODO files in project directories
|
|
644
|
+
* @param {string} projectPath - Path to project directory
|
|
645
|
+
* @returns {Promise<Array>} Array of TODO file names
|
|
646
|
+
*/
|
|
647
|
+
async findTodoFiles(projectPath) {
|
|
648
|
+
try {
|
|
649
|
+
const files = await fs.readdir(projectPath);
|
|
650
|
+
return files.filter(file => file.includes('todo') || file.includes('TODO'));
|
|
651
|
+
} catch {
|
|
652
|
+
return [];
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Determine project activity status based on last modification time
|
|
658
|
+
* @param {Date} lastActivity - Last activity timestamp
|
|
659
|
+
* @returns {string} Status: 'active', 'recent', or 'inactive'
|
|
660
|
+
*/
|
|
661
|
+
determineProjectStatus(lastActivity) {
|
|
662
|
+
const now = new Date();
|
|
663
|
+
const timeDiff = now - lastActivity;
|
|
664
|
+
const hoursAgo = timeDiff / (1000 * 60 * 60);
|
|
665
|
+
|
|
666
|
+
if (hoursAgo < 1) return 'active';
|
|
667
|
+
if (hoursAgo < 24) return 'recent';
|
|
668
|
+
return 'inactive';
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Update real-time statistics cache
|
|
673
|
+
*/
|
|
674
|
+
updateRealtimeStats() {
|
|
675
|
+
this.data.realtimeStats = {
|
|
676
|
+
totalConversations: this.data.conversations.length,
|
|
677
|
+
totalTokens: this.data.conversations.reduce((sum, conv) => sum + conv.tokens, 0),
|
|
678
|
+
activeProjects: this.data.activeProjects.filter(p => p.status === 'active').length,
|
|
679
|
+
lastActivity: this.data.summary.lastActivity,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Format byte sizes for display
|
|
685
|
+
* @param {number} bytes - Number of bytes
|
|
686
|
+
* @returns {string} Formatted byte string
|
|
687
|
+
*/
|
|
688
|
+
formatBytes(bytes) {
|
|
689
|
+
if (bytes === 0) return '0 Bytes';
|
|
690
|
+
const k = 1024;
|
|
691
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
692
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
693
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Get current conversation data
|
|
698
|
+
* @returns {Array} Current conversations
|
|
699
|
+
*/
|
|
700
|
+
getConversations() {
|
|
701
|
+
return this.data.conversations;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Get current project data
|
|
706
|
+
* @returns {Array} Current projects
|
|
707
|
+
*/
|
|
708
|
+
getActiveProjects() {
|
|
709
|
+
return this.data.activeProjects;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Get current summary data
|
|
714
|
+
* @returns {Object} Current summary
|
|
715
|
+
*/
|
|
716
|
+
getSummary() {
|
|
717
|
+
return this.data.summary;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Get current orphan processes
|
|
722
|
+
* @returns {Array} Current orphan processes
|
|
723
|
+
*/
|
|
724
|
+
getOrphanProcesses() {
|
|
725
|
+
return this.data.orphanProcesses;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Get current realtime stats
|
|
730
|
+
* @returns {Object} Current realtime stats
|
|
731
|
+
*/
|
|
732
|
+
getRealtimeStats() {
|
|
733
|
+
return this.data.realtimeStats;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Update conversations data (used for external updates)
|
|
738
|
+
* @param {Array} conversations - Updated conversations array
|
|
739
|
+
*/
|
|
740
|
+
setConversations(conversations) {
|
|
741
|
+
this.data.conversations = conversations;
|
|
742
|
+
this.updateRealtimeStats();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Update orphan processes data
|
|
747
|
+
* @param {Array} orphanProcesses - Updated orphan processes array
|
|
748
|
+
*/
|
|
749
|
+
setOrphanProcesses(orphanProcesses) {
|
|
750
|
+
this.data.orphanProcesses = orphanProcesses;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
module.exports = ConversationAnalyzer;
|