claude-code-templates
Version:
CLI tool to setup Claude Code configurations with framework-specific commands, automation hooks and MCP Servers for your projects
1,409 lines (1,224 loc) • 83.6 kB
JavaScript
const chalk = require('chalk');
const fs = require('fs-extra');
const path = require('path');
const express = require('express');
const open = require('open');
const os = require('os');
const inquirer = require('inquirer');
const boxen = require('boxen');
const { spawn } = require('child_process');
const packageJson = require('../package.json');
const StateCalculator = require('./analytics/core/StateCalculator');
const ProcessDetector = require('./analytics/core/ProcessDetector');
const ConversationAnalyzer = require('./analytics/core/ConversationAnalyzer');
const FileWatcher = require('./analytics/core/FileWatcher');
const SessionAnalyzer = require('./analytics/core/SessionAnalyzer');
const AgentAnalyzer = require('./analytics/core/AgentAnalyzer');
const DataCache = require('./analytics/data/DataCache');
const WebSocketServer = require('./analytics/notifications/WebSocketServer');
const NotificationManager = require('./analytics/notifications/NotificationManager');
const PerformanceMonitor = require('./analytics/utils/PerformanceMonitor');
const ConsoleBridge = require('./console-bridge');
const ClaudeAPIProxy = require('./claude-api-proxy');
class ClaudeAnalytics {
constructor(options = {}) {
this.options = options;
this.verbose = options.verbose || false;
this.app = express();
this.port = 3333;
this.stateCalculator = new StateCalculator();
this.processDetector = new ProcessDetector();
this.fileWatcher = new FileWatcher();
this.sessionAnalyzer = new SessionAnalyzer();
this.agentAnalyzer = new AgentAnalyzer();
this.dataCache = new DataCache();
this.performanceMonitor = new PerformanceMonitor({
enabled: true,
logInterval: 60000,
memoryThreshold: 300 * 1024 * 1024 // 300MB - more realistic for analytics dashboard
});
this.webSocketServer = null;
this.notificationManager = null;
this.httpServer = null;
this.consoleBridge = null;
this.cloudflareProcess = null;
this.publicUrl = null;
this.claudeApiProxy = null;
this.data = {
conversations: [],
summary: {},
activeProjects: [],
realtimeStats: {
totalConversations: 0,
totalTokens: 0,
activeProjects: 0,
lastActivity: null,
},
};
}
/**
* Log messages only if verbose mode is enabled
* @param {string} level - Log level ('info', 'warn', 'error')
* @param {string} message - Message to log
* @param {...any} args - Additional arguments
*/
log(level, message, ...args) {
if (!this.verbose) return;
switch (level) {
case 'error':
console.error(message, ...args);
break;
case 'warn':
console.warn(message, ...args);
break;
case 'info':
default:
console.log(message, ...args);
break;
}
}
async initialize() {
const homeDir = os.homedir();
this.claudeDir = path.join(homeDir, '.claude');
this.claudeDesktopDir = path.join(homeDir, 'Library', 'Application Support', 'Claude');
this.claudeStatsigDir = path.join(this.claudeDir, 'statsig');
// Check if Claude directories exist
if (!(await fs.pathExists(this.claudeDir))) {
throw new Error(`Claude Code directory not found at ${this.claudeDir}`);
}
// Initialize conversation analyzer with Claude directory and cache
this.conversationAnalyzer = new ConversationAnalyzer(this.claudeDir, this.dataCache);
await this.loadInitialData();
this.setupFileWatchers();
this.setupWebServer();
}
async loadInitialData() {
try {
// Store previous data for comparison
const previousData = this.data;
// Use ConversationAnalyzer to load and analyze all data
const analyzedData = await this.conversationAnalyzer.loadInitialData(
this.stateCalculator,
this.processDetector
);
// Update our data structure with analyzed data
this.data = analyzedData;
// Get Claude session information
const claudeSessionInfo = await this.getClaudeSessionInfo();
// Analyze session data for Max plan usage tracking with real Claude session info
this.data.sessionData = this.sessionAnalyzer.analyzeSessionData(this.data.conversations, claudeSessionInfo);
// Send real-time notifications if WebSocket is available
if (this.notificationManager) {
this.notificationManager.notifyDataRefresh(this.data, 'data_refresh');
// Check for conversation state changes
this.detectAndNotifyStateChanges(previousData, this.data);
}
} catch (error) {
console.error(chalk.red('Error loading Claude data:'), error.message);
throw error;
}
}
async loadActiveProjects() {
const projects = [];
try {
const files = await fs.readdir(this.claudeDir);
for (const file of files) {
const filePath = path.join(this.claudeDir, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory() && !file.startsWith('.')) {
const projectPath = filePath;
const todoFiles = await this.findTodoFiles(projectPath);
const project = {
name: file,
path: projectPath,
lastActivity: stats.mtime,
todoFiles: todoFiles.length,
status: this.determineProjectStatus(stats.mtime),
};
projects.push(project);
}
}
return projects.sort((a, b) => b.lastActivity - a.lastActivity);
} catch (error) {
console.error(chalk.red('Error loading projects:'), error.message);
return [];
}
}
async findTodoFiles(projectPath) {
try {
const files = await fs.readdir(projectPath);
return files.filter(file => file.includes('todo') || file.includes('TODO'));
} catch {
return [];
}
}
estimateTokens(text) {
// Simple token estimation (roughly 4 characters per token)
return Math.ceil(text.length / 4);
}
calculateRealTokenUsage(parsedMessages) {
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCacheCreationTokens = 0;
let totalCacheReadTokens = 0;
let messagesWithUsage = 0;
parsedMessages.forEach(message => {
if (message.usage) {
totalInputTokens += message.usage.input_tokens || 0;
totalOutputTokens += message.usage.output_tokens || 0;
totalCacheCreationTokens += message.usage.cache_creation_input_tokens || 0;
totalCacheReadTokens += message.usage.cache_read_input_tokens || 0;
messagesWithUsage++;
}
});
return {
total: totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
cacheCreationTokens: totalCacheCreationTokens,
cacheReadTokens: totalCacheReadTokens,
messagesWithUsage: messagesWithUsage,
totalMessages: parsedMessages.length,
};
}
calculateDetailedTokenUsage() {
if (!this.data || !this.data.conversations) {
return null;
}
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCacheCreationTokens = 0;
let totalCacheReadTokens = 0;
let totalMessages = 0;
let messagesWithUsage = 0;
this.data.conversations.forEach(conversation => {
if (conversation.tokenUsage) {
totalInputTokens += conversation.tokenUsage.inputTokens || 0;
totalOutputTokens += conversation.tokenUsage.outputTokens || 0;
totalCacheCreationTokens += conversation.tokenUsage.cacheCreationTokens || 0;
totalCacheReadTokens += conversation.tokenUsage.cacheReadTokens || 0;
messagesWithUsage += conversation.tokenUsage.messagesWithUsage || 0;
totalMessages += conversation.tokenUsage.totalMessages || 0;
}
});
const total = totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens;
return {
total,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
cacheCreationTokens: totalCacheCreationTokens,
cacheReadTokens: totalCacheReadTokens,
messagesWithUsage,
totalMessages
};
}
extractModelInfo(parsedMessages) {
const models = new Set();
const serviceTiers = new Set();
let lastModel = null;
let lastServiceTier = null;
parsedMessages.forEach(message => {
if (message.model) {
models.add(message.model);
lastModel = message.model;
}
if (message.usage && message.usage.service_tier) {
serviceTiers.add(message.usage.service_tier);
lastServiceTier = message.usage.service_tier;
}
});
return {
models: Array.from(models),
primaryModel: lastModel || models.values().next().value || 'Unknown',
serviceTiers: Array.from(serviceTiers),
currentServiceTier: lastServiceTier || serviceTiers.values().next().value || 'Unknown',
hasMultipleModels: models.size > 1,
};
}
async extractProjectFromPath(filePath) {
// First try to read cwd from the conversation file itself
try {
const content = await fs.readFile(filePath, 'utf8');
const lines = content.trim().split('\n').filter(line => line.trim());
for (const line of lines.slice(0, 10)) { // Check first 10 lines
try {
const item = JSON.parse(line);
// Look for cwd field in the message
if (item.cwd) {
return path.basename(item.cwd);
}
// Also check if it's in nested objects
if (item.message && item.message.cwd) {
return path.basename(item.message.cwd);
}
} catch (parseError) {
// Skip invalid JSON lines
continue;
}
}
} catch (error) {
console.warn(chalk.yellow(`Warning: Could not extract project from conversation ${filePath}:`, error.message));
}
// Fallback: Extract project name from file path like:
// /Users/user/.claude/projects/-Users-user-Projects-MyProject/conversation.jsonl
const pathParts = filePath.split('/');
const projectIndex = pathParts.findIndex(part => part === 'projects');
if (projectIndex !== -1 && projectIndex + 1 < pathParts.length) {
const projectDir = pathParts[projectIndex + 1];
// Clean up the project directory name
const cleanName = projectDir
.replace(/^-/, '')
.replace(/-/g, '/')
.split('/')
.pop() || 'Unknown';
return cleanName;
}
return 'Unknown';
}
extractProjectFromConversation(messages) {
// Try to extract project information from conversation
for (const message of messages.slice(0, 5)) {
if (message.content && typeof message.content === 'string') {
const pathMatch = message.content.match(/\/([^\/\s]+)$/);
if (pathMatch) {
return pathMatch[1];
}
}
}
return 'Unknown';
}
generateStatusSquares(messages) {
if (!messages || messages.length === 0) {
return [];
}
// Sort messages by timestamp and take last 10 for status squares
const sortedMessages = messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const recentMessages = sortedMessages.slice(-10);
return recentMessages.map((message, index) => {
const messageNum = sortedMessages.length - recentMessages.length + index + 1;
// Determine status based on message content and role
if (message.role === 'user') {
return {
type: 'pending',
tooltip: `Message #${messageNum}: User input`,
};
} else if (message.role === 'assistant') {
// Check if the message contains tool usage or errors
const content = message.content || '';
if (typeof content === 'string') {
if (content.includes('[Tool:') || content.includes('tool_use')) {
return {
type: 'tool',
tooltip: `Message #${messageNum}: Tool execution`,
};
} else if (content.includes('error') || content.includes('Error') || content.includes('failed')) {
return {
type: 'error',
tooltip: `Message #${messageNum}: Error in response`,
};
} else {
return {
type: 'success',
tooltip: `Message #${messageNum}: Successful response`,
};
}
} else if (Array.isArray(content)) {
// Check for tool_use blocks in array content
const hasToolUse = content.some(block => block.type === 'tool_use');
const hasError = content.some(block =>
block.type === 'text' && (block.text ?.includes('error') || block.text ?.includes('Error'))
);
if (hasError) {
return {
type: 'error',
tooltip: `Message #${messageNum}: Error in response`,
};
} else if (hasToolUse) {
return {
type: 'tool',
tooltip: `Message #${messageNum}: Tool execution`,
};
} else {
return {
type: 'success',
tooltip: `Message #${messageNum}: Successful response`,
};
}
}
}
return {
type: 'pending',
tooltip: `Message #${messageNum}: Unknown status`,
};
});
}
determineProjectStatus(lastActivity) {
const now = new Date();
const timeDiff = now - lastActivity;
const hoursAgo = timeDiff / (1000 * 60 * 60);
if (hoursAgo < 1) return 'active';
if (hoursAgo < 24) return 'recent';
return 'inactive';
}
calculateSummary(conversations, projects) {
const totalTokens = conversations.reduce((sum, conv) => sum + conv.tokens, 0);
const totalConversations = conversations.length;
const activeConversations = conversations.filter(c => c.status === 'active').length;
const activeProjects = projects.filter(p => p.status === 'active').length;
const avgTokensPerConversation = totalConversations > 0 ? Math.round(totalTokens / totalConversations) : 0;
const totalFileSize = conversations.reduce((sum, conv) => sum + conv.fileSize, 0);
// Calculate real Claude sessions (5-hour periods)
const claudeSessions = this.calculateClaudeSessions(conversations);
return {
totalConversations,
totalTokens,
activeConversations,
activeProjects,
avgTokensPerConversation,
totalFileSize: this.formatBytes(totalFileSize),
lastActivity: conversations.length > 0 ? conversations[0].lastModified : null,
claudeSessions,
};
}
calculateClaudeSessions(conversations) {
// Collect all message timestamps across all conversations
const allMessages = [];
conversations.forEach(conv => {
// Parse the conversation file to get message timestamps
try {
const fs = require('fs-extra');
const content = fs.readFileSync(conv.filePath, 'utf8');
const lines = content.trim().split('\n').filter(line => line.trim());
lines.forEach(line => {
try {
const item = JSON.parse(line);
if (item.timestamp && item.message && item.message.role === 'user') {
// Only count user messages as session starters
allMessages.push({
timestamp: new Date(item.timestamp),
conversationId: conv.id,
});
}
} catch {}
});
} catch {}
});
if (allMessages.length === 0) return {
total: 0,
currentMonth: 0,
thisWeek: 0
};
// Sort messages by timestamp
allMessages.sort((a, b) => a.timestamp - b.timestamp);
// Calculate sessions (5-hour periods)
const sessions = [];
let currentSession = null;
allMessages.forEach(message => {
if (!currentSession) {
// Start first session
currentSession = {
start: message.timestamp,
end: new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000), // +5 hours
messageCount: 1,
conversations: new Set([message.conversationId]),
};
} else if (message.timestamp <= currentSession.end) {
// Message is within current session
currentSession.messageCount++;
currentSession.conversations.add(message.conversationId);
// Update session end if this message extends beyond current session
const potentialEnd = new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000);
if (potentialEnd > currentSession.end) {
currentSession.end = potentialEnd;
}
} else {
// Message is outside current session, start new session
sessions.push(currentSession);
currentSession = {
start: message.timestamp,
end: new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000),
messageCount: 1,
conversations: new Set([message.conversationId]),
};
}
});
// Add the last session
if (currentSession) {
sessions.push(currentSession);
}
// Calculate statistics
const now = new Date();
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const thisWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const currentMonthSessions = sessions.filter(s => s.start >= currentMonth).length;
const thisWeekSessions = sessions.filter(s => s.start >= thisWeek).length;
return {
total: sessions.length,
currentMonth: currentMonthSessions,
thisWeek: thisWeekSessions,
sessions: sessions.map(s => ({
start: s.start,
end: s.end,
messageCount: s.messageCount,
conversationCount: s.conversations.size,
duration: Math.round((s.end - s.start) / (1000 * 60 * 60) * 10) / 10, // hours with 1 decimal
})),
};
}
updateRealtimeStats() {
this.data.realtimeStats = {
totalConversations: this.data.conversations.length,
totalTokens: this.data.conversations.reduce((sum, conv) => sum + conv.tokens, 0),
activeProjects: this.data.activeProjects.filter(p => p.status === 'active').length,
lastActivity: this.data.summary.lastActivity,
};
}
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Handle conversation file changes and detect new messages
* @param {string} conversationId - Conversation ID that changed
* @param {string} filePath - Path to the conversation file
*/
async handleConversationChange(conversationId, filePath) {
try {
// Get the latest messages from the file
const messages = await this.conversationAnalyzer.getParsedConversation(filePath);
if (messages && messages.length > 0) {
// Get the most recent message
const latestMessage = messages[messages.length - 1];
// Send WebSocket notification for new message
if (this.notificationManager) {
this.notificationManager.notifyNewMessage(conversationId, latestMessage, {
totalMessages: messages.length,
timestamp: new Date().toISOString()
});
}
}
} catch (error) {
console.error(chalk.red(`Error handling conversation change for ${conversationId}:`), error);
}
}
setupFileWatchers() {
// Setup file watchers using the FileWatcher module
this.fileWatcher.setupFileWatchers(
this.claudeDir,
// Data refresh callback
async () => {
await this.loadInitialData();
},
// Process refresh callback
async () => {
const enrichmentResult = await this.processDetector.enrichWithRunningProcesses(
this.data.conversations,
this.claudeDir,
this.stateCalculator
);
this.data.conversations = enrichmentResult.conversations;
this.data.orphanProcesses = enrichmentResult.orphanProcesses;
},
// DataCache for cache invalidation
this.dataCache,
// Conversation change callback for real-time message updates
async (conversationId, filePath) => {
await this.handleConversationChange(conversationId, filePath);
}
);
}
setupWebServer() {
// Add CORS middleware
this.app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.sendStatus(200);
return;
}
next();
});
// Add performance monitoring middleware
this.app.use(this.performanceMonitor.createExpressMiddleware());
// Serve static files (we'll create the dashboard HTML)
this.app.use(express.static(path.join(__dirname, 'analytics-web')));
// API endpoints
this.app.get('/api/data', async (req, res) => {
try {
// Calculate detailed token usage
const detailedTokenUsage = this.calculateDetailedTokenUsage();
// Memory cleanup: limit conversation history to prevent memory buildup
if (this.data.conversations && this.data.conversations.length > 150) {
this.data.conversations = this.data.conversations
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified))
.slice(0, 150);
}
// Add timestamp to verify data freshness
const dataWithTimestamp = {
...this.data,
detailedTokenUsage,
timestamp: new Date().toISOString(),
lastUpdate: new Date().toLocaleString(),
};
res.json(dataWithTimestamp);
} catch (error) {
console.error('Error calculating detailed token usage:', error);
res.json({
...this.data,
detailedTokenUsage: null,
timestamp: new Date().toISOString(),
lastUpdate: new Date().toLocaleString(),
});
}
});
// Paginated conversations endpoint
this.app.get('/api/conversations', async (req, res) => {
try {
const page = parseInt(req.query.page) || 0;
const limit = parseInt(req.query.limit) || 10;
const offset = page * limit;
// Sort conversations by lastModified (most recent first)
const sortedConversations = [...this.data.conversations]
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
const paginatedConversations = sortedConversations.slice(offset, offset + limit);
const totalCount = this.data.conversations.length;
const hasMore = offset + limit < totalCount;
res.json({
conversations: paginatedConversations,
pagination: {
page,
limit,
offset,
totalCount,
hasMore,
currentCount: paginatedConversations.length
},
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error getting paginated conversations:', error);
res.status(500).json({ error: 'Failed to get conversations' });
}
});
// Agent usage analytics endpoint
this.app.get('/api/agents', async (req, res) => {
try {
const startDate = req.query.startDate;
const endDate = req.query.endDate;
const dateRange = (startDate || endDate) ? { startDate, endDate } : null;
const agentAnalysis = await this.agentAnalyzer.analyzeAgentUsage(this.data.conversations, dateRange);
const agentSummary = this.agentAnalyzer.generateSummary(agentAnalysis);
res.json({
...agentAnalysis,
summary: agentSummary,
dateRange,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error getting agent analytics:', error);
res.status(500).json({ error: 'Failed to get agent analytics' });
}
});
this.app.get('/api/realtime', async (req, res) => {
const realtimeWithTimestamp = {
...this.data.realtimeStats,
timestamp: new Date().toISOString(),
lastUpdate: new Date().toLocaleString(),
};
res.json(realtimeWithTimestamp);
});
// Force refresh endpoint
this.app.get('/api/refresh', async (req, res) => {
await this.loadInitialData();
res.json({
success: true,
message: 'Data refreshed',
timestamp: new Date().toISOString(),
});
});
// NEW: Ultra-fast endpoint for ALL conversation states
this.app.get('/api/conversation-state', async (req, res) => {
try {
// Detect running processes for accurate state calculation
const runningProcesses = await this.processDetector.detectRunningClaudeProcesses();
const activeStates = {};
// Calculate states for ALL conversations, not just those with runningProcess
for (const conversation of this.data.conversations) {
try {
let state;
// First try quick calculation if there's a running process
if (conversation.runningProcess) {
state = this.stateCalculator.quickStateCalculation(conversation, runningProcesses);
}
// If no quick state found, use full state calculation
if (!state) {
// For conversations without running processes, use basic heuristics
const now = new Date();
const timeDiff = (now - new Date(conversation.lastModified)) / (1000 * 60); // minutes
if (timeDiff < 5) {
state = 'Recently active';
} else if (timeDiff < 60) {
state = 'Idle';
} else if (timeDiff < 1440) { // 24 hours
state = 'Inactive';
} else {
state = 'Old';
}
}
// Store state with conversation ID as key
activeStates[conversation.id] = state;
} catch (error) {
activeStates[conversation.id] = 'unknown';
}
}
res.json({ activeStates, timestamp: Date.now() });
} catch (error) {
console.error('Error getting conversation states:', error);
res.status(500).json({ error: 'Failed to get conversation states' });
}
});
// Conversation messages endpoint with optional pagination
this.app.get('/api/conversations/:id/messages', async (req, res) => {
try {
const conversationId = req.params.id;
const page = parseInt(req.query.page);
const limit = parseInt(req.query.limit);
const conversation = this.data.conversations.find(conv => conv.id === conversationId);
if (!conversation) {
return res.status(404).json({ error: 'Conversation not found' });
}
// Read all messages from the JSONL file
const allMessages = await this.conversationAnalyzer.getParsedConversation(conversation.filePath);
// If pagination parameters are provided, use pagination
if (!isNaN(page) && !isNaN(limit)) {
// Sort messages by timestamp (newest first for reverse pagination)
const sortedMessages = allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
// Calculate pagination
const totalCount = sortedMessages.length;
const offset = page * limit;
const hasMore = offset + limit < totalCount;
// Get page of messages (reverse order - newest first)
const paginatedMessages = sortedMessages.slice(offset, offset + limit);
// For display, we want messages in chronological order (oldest first)
const messagesInDisplayOrder = [...paginatedMessages].reverse();
res.json({
conversationId,
messages: messagesInDisplayOrder,
pagination: {
page,
limit,
offset,
totalCount,
hasMore,
currentCount: paginatedMessages.length
},
timestamp: new Date().toISOString()
});
} else {
// Non-paginated response (backward compatibility)
res.json({
conversationId,
messages: allMessages,
messageCount: allMessages.length,
timestamp: new Date().toISOString()
});
}
} catch (error) {
console.error('Error loading conversation messages:', error);
res.status(500).json({ error: 'Failed to load conversation messages' });
}
});
// Session data endpoint for Max plan usage tracking
this.app.get('/api/session/data', async (req, res) => {
try {
// Get real-time Claude session information
const claudeSessionInfo = await this.getClaudeSessionInfo();
if (!this.data.sessionData) {
// Generate session data if not available
this.data.sessionData = this.sessionAnalyzer.analyzeSessionData(this.data.conversations, claudeSessionInfo);
}
const timerData = this.sessionAnalyzer.getSessionTimerData(this.data.sessionData);
res.json({
...this.data.sessionData,
timer: timerData,
claudeSessionInfo: claudeSessionInfo,
timestamp: Date.now()
});
} catch (error) {
console.error('Session data error:', error);
res.status(500).json({
error: 'Failed to get session data',
timestamp: Date.now()
});
}
});
// Get specific conversation history
this.app.get('/api/session/:id', async (req, res) => {
try {
const conversationId = req.params.id;
// Find the conversation
const conversation = this.data.conversations.find(conv => conv.id === conversationId);
if (!conversation) {
return res.status(404).json({ error: 'Conversation not found' });
}
// Read the conversation file to get full message history
const conversationFile = conversation.filePath;
if (!conversationFile) {
return res.status(404).json({
error: 'Conversation file path not found',
conversationId: conversationId,
conversationKeys: Object.keys(conversation),
hasFilePath: !!conversation.filePath,
hasFileName: !!conversation.filename
});
}
if (!await fs.pathExists(conversationFile)) {
return res.status(404).json({ error: 'Conversation file not found', path: conversationFile });
}
const content = await fs.readFile(conversationFile, 'utf8');
const lines = content.trim().split('\n').filter(line => line.trim());
const rawMessages = lines.map(line => {
try {
return JSON.parse(line);
} catch (error) {
console.warn('Error parsing message line:', error);
return null;
}
}).filter(Boolean);
// Extract actual messages from Claude Code format
const messages = rawMessages.map(item => {
if (item.message && item.message.role) {
let content = '';
if (typeof item.message.content === 'string') {
content = item.message.content;
} else if (Array.isArray(item.message.content)) {
content = item.message.content
.map(block => {
if (block.type === 'text') return block.text;
if (block.type === 'tool_use') return `[Tool: ${block.name}]`;
if (block.type === 'tool_result') return '[Tool Result]';
return block.content || '';
})
.join('\n');
} else if (item.message.content && typeof item.message.content === 'object' && item.message.content.length) {
content = item.message.content[0].text || '';
}
return {
role: item.message.role,
content: content || 'No content',
timestamp: item.timestamp,
type: item.type,
stop_reason: item.message.stop_reason || null,
message_id: item.message.id || null,
model: item.message.model || null,
usage: item.message.usage || null,
hasToolUse: item.message.content && Array.isArray(item.message.content) &&
item.message.content.some(block => block.type === 'tool_use'),
hasToolResult: item.message.content && Array.isArray(item.message.content) &&
item.message.content.some(block => block.type === 'tool_result'),
contentBlocks: item.message.content && Array.isArray(item.message.content) ?
item.message.content.map(block => ({ type: block.type, name: block.name || null })) : [],
rawContent: item.message.content || null,
parentUuid: item.parentUuid || null,
uuid: item.uuid || null,
sessionId: item.sessionId || null,
userType: item.userType || null,
cwd: item.cwd || null,
version: item.version || null,
isCompactSummary: item.isCompactSummary || false,
isSidechain: item.isSidechain || false
};
}
return null;
}).filter(Boolean);
res.json({
conversation: {
id: conversation.id,
project: conversation.project,
messageCount: conversation.messageCount,
tokens: conversation.tokens,
created: conversation.created,
lastModified: conversation.lastModified,
status: conversation.status
},
messages: messages,
timestamp: Date.now()
});
} catch (error) {
console.error('Error getting conversation history:', error);
console.error('Error stack:', error.stack);
res.status(500).json({
error: 'Failed to load conversation history',
details: error.message,
stack: error.stack
});
}
});
// Fast state update endpoint - only updates conversation states without full reload
this.app.get('/api/fast-update', async (req, res) => {
try {
// Update process information and conversation states
const enrichmentResult = await this.processDetector.enrichWithRunningProcesses(
this.data.conversations,
this.claudeDir,
this.stateCalculator
);
this.data.conversations = enrichmentResult.conversations;
this.data.orphanProcesses = enrichmentResult.orphanProcesses;
// For active conversations, re-read the files to get latest messages
const activeConversations = this.data.conversations.filter(c => c.runningProcess);
for (const conv of activeConversations) {
try {
const conversationFile = path.join(this.claudeDir, conv.fileName);
const content = await fs.readFile(conversationFile, 'utf8');
const parsedMessages = content.split('\n')
.filter(line => line.trim())
.map(line => JSON.parse(line));
const stats = await fs.stat(conversationFile);
conv.conversationState = this.stateCalculator.determineConversationState(
parsedMessages,
stats.mtime,
conv.runningProcess
);
} catch (error) {
// If we can't read the file, keep the existing state
}
}
// Only log when there are actually active conversations (reduce noise)
const activeConvs = this.data.conversations.filter(c => c.runningProcess);
if (activeConvs.length > 0) {
// Only log every 10th update to reduce spam, or when states change
if (!this.lastLoggedStates) this.lastLoggedStates = new Map();
let hasChanges = false;
activeConvs.forEach(conv => {
const lastState = this.lastLoggedStates.get(conv.id);
if (lastState !== conv.conversationState) {
hasChanges = true;
this.lastLoggedStates.set(conv.id, conv.conversationState);
}
});
}
// Memory cleanup: limit conversation history to prevent memory buildup
if (this.data.conversations.length > 100) {
this.data.conversations = this.data.conversations
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified))
.slice(0, 100);
}
// Force garbage collection hint
if (global.gc) {
global.gc();
}
const dataWithTimestamp = {
conversations: this.data.conversations,
summary: this.data.summary,
timestamp: new Date().toISOString(),
lastUpdate: new Date().toLocaleString(),
};
res.json(dataWithTimestamp);
} catch (error) {
console.error('Fast update error:', error);
res.status(500).json({ error: 'Failed to update states' });
}
});
// Remove duplicate endpoint - this conflicts with the correct one above
// System health endpoint
this.app.get('/api/system/health', (req, res) => {
try {
const stats = this.performanceMonitor.getStats();
const systemHealth = {
status: 'healthy',
uptime: stats.uptime,
memory: stats.memory,
requests: stats.requests,
cache: {
...stats.cache,
dataCache: this.dataCache.getStats()
},
errors: stats.errors,
counters: stats.counters,
timestamp: Date.now()
};
// Determine overall health status
if (stats.errors.total > 10) {
systemHealth.status = 'degraded';
}
if (stats.memory.current && stats.memory.current.heapUsed > this.performanceMonitor.options.memoryThreshold) {
systemHealth.status = 'warning';
}
res.json(systemHealth);
} catch (error) {
res.status(500).json({
status: 'error',
message: 'Failed to get system health',
timestamp: Date.now()
});
}
});
// Version endpoint
this.app.get('/api/version', (req, res) => {
res.json({
version: packageJson.version,
name: packageJson.name,
description: packageJson.description,
timestamp: Date.now()
});
});
// Claude session information endpoint
this.app.get('/api/claude/session', async (req, res) => {
try {
const sessionInfo = await this.getClaudeSessionInfo();
res.json({
...sessionInfo,
timestamp: Date.now()
});
} catch (error) {
console.error('Error getting Claude session info:', error);
res.status(500).json({
error: 'Failed to get Claude session info',
timestamp: Date.now()
});
}
});
// Performance metrics endpoint
this.app.get('/api/system/metrics', (req, res) => {
try {
const timeframe = parseInt(req.query.timeframe) || 300000; // 5 minutes default
const stats = this.performanceMonitor.getStats(timeframe);
res.json({
...stats,
dataCache: this.dataCache.getStats(),
timestamp: Date.now()
});
} catch (error) {
res.status(500).json({
error: 'Failed to get performance metrics',
timestamp: Date.now()
});
}
});
// Cache management endpoint
this.app.post('/api/cache/clear', (req, res) => {
try {
// Clear specific cache types or all
const { type } = req.body;
if (!type || type === 'all') {
// Clear all caches
this.dataCache.invalidateComputations();
this.dataCache.caches.parsedConversations.clear();
this.dataCache.caches.fileContent.clear();
this.dataCache.caches.fileStats.clear();
res.json({ success: true, message: 'All caches cleared' });
} else if (type === 'conversations') {
// Clear only conversation-related caches
this.dataCache.caches.parsedConversations.clear();
this.dataCache.caches.fileContent.clear();
res.json({ success: true, message: 'Conversation caches cleared' });
} else {
res.status(400).json({ error: 'Invalid cache type. Use "all" or "conversations"' });
}
} catch (error) {
console.error('Error clearing cache:', error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
// Agents API endpoint
this.app.get('/api/agents', async (req, res) => {
try {
const agents = await this.loadAgents();
res.json({ agents });
} catch (error) {
console.error('Error loading agents:', error);
res.status(500).json({ error: 'Failed to load agents data' });
}
});
// Clear cache endpoint
this.app.post('/api/clear-cache', async (req, res) => {
try {
console.log('🔥 Clear cache request received');
// Clear DataCache
if (this.dataCache && typeof this.dataCache.clear === 'function') {
this.dataCache.clear();
console.log('🔥 Server DataCache cleared');
} else {
console.log('⚠️ DataCache not available or no clear method');
}
// Also clear ConversationAnalyzer cache if available
if (this.conversationAnalyzer && typeof this.conversationAnalyzer.clearCache === 'function') {
this.conversationAnalyzer.clearCache();
console.log('🔥 ConversationAnalyzer cache cleared');
}
res.json({
success: true,
message: 'Cache cleared successfully',
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ Error clearing cache:', error);
res.status(500).json({
error: 'Failed to clear cache',
details: error.message
});
}
});
// Activity heatmap data endpoint - needs full conversation history
this.app.get('/api/activity', async (req, res) => {
try {
// TEMPORARY: Use test data for demo/screenshots
console.log(`🔥 /api/activity called - using test data for demo...`);
const fs = require('fs');
const path = require('path');
const testDataPath = path.join(__dirname, 'test-activity-data.json');
if (fs.existsSync(testDataPath)) {
const testData = JSON.parse(fs.readFileSync(testDataPath, 'utf8'));
// Calculate totals
const totalContributions = testData.reduce((sum, day) => sum + day.conversations, 0);
const totalTools = testData.reduce((sum, day) => sum + day.tools, 0);
const totalMessages = testData.reduce((sum, day) => sum + day.messages, 0);
const totalTokens = testData.reduce((sum, day) => sum + day.tokens, 0);
const activityData = {
dailyActivity: testData,
totalContributions,
activeDays: testData.length,
longestStreak: 7, // Sample streak
currentStreak: 3, // Sample current streak
totalTools,
totalMessages,
totalTokens,
timestamp: new Date().toISOString()
};
return res.json(activityData);
}
// Fallback to real data if test file doesn't exist
console.log(`🔥 /api/activity called - loading all conversations...`);
const allConversations = await this.conversationAnalyzer.loadConversations(this.stateCalculator);
console.log(`🔥 Loaded ${allConversations.length} conversations from server`);
// Generate activity data using complete dataset
const activityData = this.generateActivityDataFromConversations(allConversations);
res.json({
conversations: allConversations, // Also include conversations for the heatmap component
...activityData,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error generating activity data:', error);
res.status(500).json({ error: 'Failed to generate activity data' });
}
});
// Main dashboard route
this.app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'analytics-web', 'index.html'));
});
}
async startServer() {
return new Promise(async (resolve) => {
this.httpServer = this.app.listen(this.port, async () => {
console.log(chalk.green(`🚀 Analytics dashboard started at http://localhost:${this.port}`));
// Initialize WebSocket server
await this.initializeWebSocket();
resolve();
});
// Keep reference for compatibility
this.server = this.httpServer;
});
}
async openBrowser(openTo = null) {
const baseUrl = this.publicUrl || `http://localhost:${this.port}`;
let fullUrl = baseUrl;
// Add fragment/hash for specific page
if (openTo === 'agents') {
fullUrl = `${baseUrl}/#agents`;
console.log(chalk.blue('🌐 Opening browser to Claude Code Chats...'));
} else {
console.log(chalk.blue('🌐 Opening browser to Claude Code Analytics...'));
}
try {
await open(fullUrl);
} catch (error) {
console.log(chalk.yellow('Could not open browser automatically. Please visit:'));
console.log(chalk.cyan(fullUrl));
}
}
/**
* Prompt user if they want to use Cloudflare Tunnel
*/
async promptCloudflareSetup() {
console.log('');
console.log(chalk.yellow('🌐 Analytics Dashboard Access Options'));
console.log('');
console.log(chalk.cyan('🔒 About Cloudflare Tunnel:'));
console.log(chalk.gray('• Creates a secure connection between your localhost and the web'));
console.log(chalk.gray('• Only you will have access to the generated URL (not public)'));
console.log(chalk.gray('• The connection is end-to-end encrypted'));
console.log(chalk.gray('• Automatically closes when you end the session'));
console.log(chalk.gray('• No firewall or port configuration required'));
console.log('');
console.log(chalk.green('✅ It is completely secure - only you can access the dashboard'));
console.log('');
const { useCloudflare } = await inquirer.prompt([{
type: 'confirm',
name: 'useCloudflare',
message: 'Enable Cloudflare Tunnel for secure remote access?',
default: true
}]);
return useCloudflare;
}
/**
* Start Cloudflare Tunnel
*/
async startCloudflareTunnel() {
try {
console.log(chalk.blue('🔧 Starting Cloudflare Tunnel...'));
// Check if cloudflared is installed
const checkProcess = spawn('cloudflared', ['version'], { stdio: 'pipe' });
return new Promise((resolve, reject) => {
checkProcess.on('error', (error) => {
console.log(chalk.red('❌ Cloudflared is not installed.'));
console.log('');
console.log(chalk.yellow('📥 To install Cloudflare Tunnel:'));
console.log(chalk.gray('• macOS: brew install cloudflared'));
console.log(chalk.gray('• Windows: winget install --id Cloudflare.cloudflared'));
console.log(chalk.gray('• Linux: apt-get install cloudflared'));
console.log('');
console.log(chalk.blue('💡 More info: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'));
resolve(false);
});
checkProcess.on('close', (code) => {
if (code === 0) {
this.createCloudflareTunnel();
resolve(true);
} else {
resolve(false);
}
});
});
} catch (error) {
console.log(chalk.red(`❌ Error checking Cloudflare Tunnel: ${error.message}`));
return false;
}
}
/**
* Create the actual Cloudflare Tunnel
*/
async createCloudflareTunnel() {
try {
console.log(chalk.blue('🚀 Creating secure tunnel...'));
// Start cloudflared tunnel normally, but filter the output to capture URL
this.cloudflareProcess = spawn('cloudflared', [
'tunnel',
'--url', `http://localhost:${this.port}`
], {
stdio: ['pipe', 'pipe', 'pipe']
});
let tunnelEstablished = false;
return new Promise((resolve) => {
// Monitor stderr for the tunnel URL (cloudflared outputs most info to stderr)
this.cloudflareProcess.stderr.on('data', (data) => {
const output = data.toString();
// Use the cleaner regex to extract URL from the logs
const urlMatch = output.match(/https:\/\/[a-zA-Z0-9.-]+\.trycloudflare\.com/);
if (urlMatch && !tunnelEstabl