UNPKG

claude-code-templates

Version:

CLI tool to setup Claude Code configurations with framework-specific commands, automation hooks and MCP Servers for your projects

686 lines (583 loc) 25.9 kB
/** * SessionAnalyzer - Extracts session timing, token usage, and plan information * Tracks Claude Max plan session limits and usage patterns */ const chalk = require('chalk'); class SessionAnalyzer { constructor() { // CORRECTED: Sessions don't have fixed duration - they reset at scheduled times // Reset hours: 1am, 7am, 1pm, 7pm local time this.RESET_HOURS = [1, 7, 13, 19]; this.MONTHLY_SESSION_LIMIT = 50; // Plan-specific usage information (Claude uses complexity-based limits, not fixed message counts) this.PLAN_LIMITS = { 'free': { name: 'Free Plan', estimatedMessagesPerSession: null, monthlyPrice: 0, hasSessionLimits: false, description: 'Daily usage limits apply' }, 'standard': { name: 'Pro Plan', estimatedMessagesPerSession: 45, // Rough estimate for ~200 sentence messages monthlyPrice: 20, hasSessionLimits: true, description: 'Usage based on message complexity, conversation length, and current capacity. Limits reset every 5 hours.' }, 'max': { name: 'Max Plan (5x)', estimatedMessagesPerSession: null, // 5x more than Pro, but still complexity-based monthlyPrice: 100, hasSessionLimits: true, description: '5x the usage of Pro plan. Complexity-based limits.' }, 'premium': { name: 'Max Plan (20x)', estimatedMessagesPerSession: null, // 20x more than Pro monthlyPrice: 200, hasSessionLimits: true, description: '20x the usage of Pro plan. Complexity-based limits.' } }; } /** * Analyze all conversations to extract session information * @param {Array} conversations - Array of conversation objects with parsed messages * @param {Object} claudeSessionInfo - Real Claude session information from statsig files * @returns {Object} Session analysis data */ analyzeSessionData(conversations, claudeSessionInfo = null) { let sessions, currentSession; if (claudeSessionInfo && claudeSessionInfo.hasSession) { // Use real Claude session information sessions = this.extractSessionsFromClaudeInfo(conversations, claudeSessionInfo); currentSession = this.getCurrentActiveSessionFromClaudeInfo(sessions, claudeSessionInfo); } else { // Fallback to old logic sessions = this.extractSessions(conversations); currentSession = this.getCurrentActiveSession(sessions); } const monthlyUsage = this.calculateMonthlyUsage(sessions); const userPlan = this.detectUserPlan(conversations); const limits = this.PLAN_LIMITS[userPlan.planType] || this.PLAN_LIMITS['standard']; return { sessions, currentSession, monthlyUsage, userPlan, limits: limits, warnings: this.generateWarnings(currentSession, monthlyUsage, userPlan), claudeSessionInfo }; } /** * Calculate message complexity weight based on token usage * Pro plan limits are based on message complexity, not just count * @param {Object} message - Message object * @returns {number} Message weight (1.0 = average message) */ calculateMessageWeight(message) { // If we have token usage data, use it for more accurate weighting if (message.usage && message.usage.input_tokens) { // Average user message is ~200 English sentences = ~3000-4000 tokens // But Claude Code messages tend to be shorter, so we use ~500 tokens as average const AVERAGE_MESSAGE_TOKENS = 500; const inputTokens = message.usage.input_tokens || 0; const cacheTokens = message.usage.cache_creation_input_tokens || 0; const totalTokens = inputTokens + cacheTokens; // Calculate weight based on token count relative to average const weight = Math.max(0.1, totalTokens / AVERAGE_MESSAGE_TOKENS); // Cap maximum weight to prevent single very long messages from dominating return Math.min(weight, 5.0); } // Fallback: assume average message if no usage data return 1.0; } /** * Calculate session usage based on weighted messages * @param {Array} userMessages - Array of user messages * @returns {Object} Usage statistics */ calculateSessionUsage(userMessages) { let totalWeight = 0; let shortMessages = 0; let longMessages = 0; userMessages.forEach(msg => { const weight = this.calculateMessageWeight(msg); totalWeight += weight; if (weight < 0.5) shortMessages++; else if (weight > 2.0) longMessages++; }); return { messageCount: userMessages.length, totalWeight: totalWeight, shortMessages, longMessages, averageWeight: userMessages.length > 0 ? totalWeight / userMessages.length : 0 }; } /** * Generate estimated messages for session analysis when parsedMessages is not available * @param {Object} conversation - Conversation object * @returns {Array} Array of estimated message objects */ generateEstimatedMessages(conversation) { const messages = []; const messageCount = conversation.messageCount || 0; const created = new Date(conversation.created); const lastModified = new Date(conversation.lastModified); if (messageCount === 0) return messages; // Estimate message distribution over time const timeDiff = lastModified - created; const timePerMessage = timeDiff / messageCount; // Generate alternating user/assistant messages for (let i = 0; i < messageCount; i++) { const timestamp = new Date(created.getTime() + (i * timePerMessage)); const role = i % 2 === 0 ? 'user' : 'assistant'; messages.push({ timestamp: timestamp, role: role, usage: conversation.tokenUsage || null }); } return messages; } /** * Extract 5-hour sliding window sessions from conversations * @param {Array} conversations - Array of conversation objects * @returns {Array} Array of 5-hour session windows */ extractSessions(conversations) { // Collect all messages from all conversations with timestamps const allMessages = []; conversations.forEach(conversation => { // Skip conversations without message count or with zero messages if (!conversation.messageCount || conversation.messageCount === 0) { return; } // Generate estimated messages based on token usage and timestamps // This is a fallback when parsedMessages is not available const estimatedMessages = this.generateEstimatedMessages(conversation); estimatedMessages.forEach(message => { allMessages.push({ timestamp: message.timestamp, role: message.role, conversationId: conversation.id, usage: message.usage }); }); }); // Sort all messages by timestamp allMessages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); // Group messages into 5-hour sliding windows const sessions = []; const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; // Find first user message to start session tracking const firstUserMessage = allMessages.find(msg => msg.role === 'user'); if (!firstUserMessage) return []; let currentWindowStart = new Date(firstUserMessage.timestamp); let sessionCounter = 1; // Create sessions based on 5-hour windows while (currentWindowStart <= new Date()) { const windowEnd = new Date(currentWindowStart.getTime() + FIVE_HOURS_MS); // Find messages within this 5-hour window const windowMessages = allMessages.filter(msg => { const msgTime = new Date(msg.timestamp); return msgTime >= currentWindowStart && msgTime < windowEnd; }); if (windowMessages.length > 0) { const session = { id: `session_${sessionCounter}`, startTime: currentWindowStart, endTime: windowEnd, messages: windowMessages, tokenUsage: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, total: 0 }, conversations: [...new Set(windowMessages.map(msg => msg.conversationId))], serviceTier: null, isActive: false }; // Calculate token usage for this window windowMessages.forEach(message => { if (message.usage) { session.tokenUsage.input += message.usage.input_tokens || 0; session.tokenUsage.output += message.usage.output_tokens || 0; session.tokenUsage.cacheCreation += message.usage.cache_creation_input_tokens || 0; session.tokenUsage.cacheRead += message.usage.cache_read_input_tokens || 0; session.serviceTier = message.usage.service_tier || session.serviceTier; } }); session.tokenUsage.total = session.tokenUsage.input + session.tokenUsage.output + session.tokenUsage.cacheCreation + session.tokenUsage.cacheRead; // Calculate additional properties const now = new Date(); session.duration = windowEnd - currentWindowStart; // Only count USER messages for session limits (Claude Code only counts prompts, not responses) const userMessages = windowMessages.filter(msg => msg.role === 'user'); // Calculate session usage with message complexity weighting const sessionUsage = this.calculateSessionUsage(userMessages); session.messageCount = sessionUsage.messageCount; session.messageWeight = sessionUsage.totalWeight; session.usageDetails = sessionUsage; session.conversationCount = session.conversations.length; // Session is active if current time is within this window session.isActive = now >= currentWindowStart && now < windowEnd; // Calculate time remaining in this window if (session.isActive) { session.timeRemaining = Math.max(0, windowEnd - now); } else { session.timeRemaining = 0; } session.actualDuration = session.duration; sessions.push(session); sessionCounter++; } // Move to next potential session start (look for next user message after current window) const nextUserMessage = allMessages.find(msg => msg.role === 'user' && new Date(msg.timestamp) >= windowEnd ); if (nextUserMessage) { currentWindowStart = new Date(nextUserMessage.timestamp); } else { break; } } // Sort by start time (most recent first) return sessions.sort((a, b) => b.startTime - a.startTime); } /** * Extract sessions based on real Claude session information * @param {Array} conversations - Array of conversation objects * @param {Object} claudeSessionInfo - Real Claude session information * @returns {Array} Array of session objects */ extractSessionsFromClaudeInfo(conversations, claudeSessionInfo) { // Get all messages from all conversations const allMessages = []; conversations.forEach(conversation => { // Skip conversations without message count or with zero messages if (!conversation.messageCount || conversation.messageCount === 0) { return; } // Generate estimated messages based on token usage and timestamps // This is a fallback when parsedMessages is not available const estimatedMessages = this.generateEstimatedMessages(conversation); estimatedMessages.forEach(message => { allMessages.push({ timestamp: message.timestamp, role: message.role, conversationId: conversation.id, usage: message.usage }); }); }); // Sort all messages by timestamp allMessages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); // Create current session based on Claude's actual session window const sessionStartTime = new Date(claudeSessionInfo.startTime); const sessionEndTime = new Date(claudeSessionInfo.sessionLimit.nextResetTime); const now = new Date(); // Find the first user message that occurred AT OR AFTER the Claude session started // This handles cases where a conversation was ongoing when Claude session reset const firstMessageAfterSessionStart = allMessages.find(msg => { const msgTime = new Date(msg.timestamp); return msg.role === 'user' && msgTime >= sessionStartTime; }); let effectiveSessionStart = sessionStartTime; if (firstMessageAfterSessionStart) { effectiveSessionStart = new Date(firstMessageAfterSessionStart.timestamp); } // Filter messages that are within the current Claude session window AND after the effective session start const currentSessionMessages = allMessages.filter(msg => { const msgTime = new Date(msg.timestamp); return msgTime >= effectiveSessionStart && msgTime < sessionEndTime; }); // If no estimated messages found in session window, check for active conversations by lastModified if (currentSessionMessages.length === 0) { const RECENT_ACTIVITY_THRESHOLD = 10 * 60 * 1000; // 10 minutes const now = new Date(); // Find conversations with recent activity (lastModified within session timeframe) const activeConversations = conversations.filter(conversation => { if (!conversation.lastModified) return false; const lastModified = new Date(conversation.lastModified); const timeSinceModified = now - lastModified; // Consider conversation active if: // 1. Modified after session start, AND // 2. Recently modified (within threshold) return lastModified >= sessionStartTime && timeSinceModified < RECENT_ACTIVITY_THRESHOLD; }); if (activeConversations.length === 0) { return []; } // Create messages for active conversations based on their real message count activeConversations.forEach(conversation => { const lastModified = new Date(conversation.lastModified); const messageCount = conversation.messageCount || 0; // Create messages based on real message count, distributing them over the session const sessionDuration = now - sessionStartTime; const timePerMessage = sessionDuration / messageCount; for (let i = 0; i < messageCount; i++) { // Distribute messages over the session timeline, alternating user/assistant const messageTime = new Date(sessionStartTime.getTime() + (i * timePerMessage)); const role = i % 2 === 0 ? 'user' : 'assistant'; currentSessionMessages.push({ timestamp: messageTime, role: role, conversationId: conversation.id, usage: conversation.tokenUsage || null }); } }); } // Create the current session object const session = { id: `claude_session_${claudeSessionInfo.sessionId.substring(0, 8)}`, startTime: effectiveSessionStart, endTime: sessionEndTime, messages: currentSessionMessages, tokenUsage: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, total: 0 }, conversations: [...new Set(currentSessionMessages.map(msg => msg.conversationId))], serviceTier: null, isActive: now >= sessionStartTime && now < sessionEndTime && !claudeSessionInfo.estimatedTimeRemaining.isExpired }; // Calculate token usage for this session currentSessionMessages.forEach(message => { if (message.usage) { session.tokenUsage.input += message.usage.input_tokens || 0; session.tokenUsage.output += message.usage.output_tokens || 0; session.tokenUsage.cacheCreation += message.usage.cache_creation_input_tokens || 0; session.tokenUsage.cacheRead += message.usage.cache_read_input_tokens || 0; session.serviceTier = message.usage.service_tier || session.serviceTier; } }); session.tokenUsage.total = session.tokenUsage.input + session.tokenUsage.output + session.tokenUsage.cacheCreation + session.tokenUsage.cacheRead; // Only count USER messages for session limits const userMessages = currentSessionMessages.filter(msg => msg.role === 'user'); // Calculate session usage with message complexity weighting const sessionUsage = this.calculateSessionUsage(userMessages); session.messageCount = sessionUsage.messageCount; session.messageWeight = sessionUsage.totalWeight; session.usageDetails = sessionUsage; session.conversationCount = session.conversations.length; // Use Claude's actual time remaining session.timeRemaining = Math.max(0, claudeSessionInfo.estimatedTimeRemaining.ms); session.actualDuration = claudeSessionInfo.sessionDuration.ms; session.duration = claudeSessionInfo.sessionLimit.ms; return [session]; } /** * Get current active session based on Claude session info * @param {Array} sessions - Array of session objects * @param {Object} claudeSessionInfo - Real Claude session information * @returns {Object|null} Current active session or null */ getCurrentActiveSessionFromClaudeInfo(sessions, claudeSessionInfo) { if (sessions.length === 0) return null; const now = Date.now(); const RECENT_ACTIVITY_THRESHOLD = 5 * 60 * 1000; // 5 minutes // Check if there's recent activity - sessions can be renewed at reset time const timeSinceLastUpdate = now - claudeSessionInfo.lastUpdate; const hasRecentActivity = timeSinceLastUpdate < RECENT_ACTIVITY_THRESHOLD; // Session is active if not expired OR has recent activity (session was renewed) if (!claudeSessionInfo.estimatedTimeRemaining.isExpired || hasRecentActivity) { return sessions[0]; } return null; } /** * Get current active session * @param {Array} sessions - Array of session objects * @returns {Object|null} Current active session or null */ getCurrentActiveSession(sessions) { return sessions.find(session => session.isActive) || null; } /** * Calculate monthly usage statistics * @param {Array} sessions - Array of session objects * @returns {Object} Monthly usage data */ calculateMonthlyUsage(sessions) { const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthlySessions = sessions.filter(session => session.startTime >= monthStart ); const totalTokens = monthlySessions.reduce((sum, session) => sum + session.tokenUsage.total, 0 ); const totalMessages = monthlySessions.reduce((sum, session) => sum + session.messageCount, 0 ); return { sessionCount: monthlySessions.length, totalTokens, totalMessages, remainingSessions: Math.max(0, this.MONTHLY_SESSION_LIMIT - monthlySessions.length), averageTokensPerSession: monthlySessions.length > 0 ? Math.round(totalTokens / monthlySessions.length) : 0, averageMessagesPerSession: monthlySessions.length > 0 ? Math.round(totalMessages / monthlySessions.length) : 0 }; } /** * Detect user plan based on service tier information * @param {Array} conversations - Array of conversation objects * @returns {Object} User plan information */ detectUserPlan(conversations) { const serviceTiers = new Set(); let latestTier = null; let latestTimestamp = null; conversations.forEach(conversation => { if (!conversation.parsedMessages) return; conversation.parsedMessages.forEach(message => { if (message.usage && message.usage.service_tier) { serviceTiers.add(message.usage.service_tier); if (!latestTimestamp || message.timestamp > latestTimestamp) { latestTimestamp = message.timestamp; latestTier = message.usage.service_tier; } } }); }); // Map service tier to plan type - Pro plan users typically have 'standard' service tier // Default to Pro plan since most users have Pro plan const planMapping = { 'free': 'free', // Free Plan - daily limits 'standard': 'standard', // Pro Plan - 45 messages per 5-hour session 'premium': 'premium', // Max Plan 20x - 900 messages per 5-hour session 'max': 'max' // Max Plan 5x - 225 messages per 5-hour session }; const detectedTier = latestTier || 'standard'; const planType = planMapping[detectedTier] || 'standard'; return { tier: detectedTier, planType: planType, allTiers: Array.from(serviceTiers), confidence: latestTier ? 'high' : 'low', lastDetected: latestTimestamp }; } /** * Generate warnings based on current usage * @param {Object|null} currentSession - Current active session * @param {Object} monthlyUsage - Monthly usage data * @param {Object} userPlan - User plan information * @returns {Array} Array of warning objects */ generateWarnings(currentSession, monthlyUsage, userPlan) { const warnings = []; const planLimits = this.PLAN_LIMITS[userPlan.planType] || this.PLAN_LIMITS['standard']; // Session-level warnings - only for time remaining and token usage if (currentSession) { // Time remaining warning (30 minutes before reset) if (currentSession.timeRemaining < 30 * 60 * 1000) { // 30 minutes warnings.push({ type: 'session_time_warning', level: 'info', message: `Session resets in ${Math.round(currentSession.timeRemaining / 60000)} minutes`, timeRemaining: currentSession.timeRemaining }); } // High token usage warning (if we have token data and it's exceptionally high) if (currentSession.tokenUsage && currentSession.tokenUsage.total > 1000000) { // 1M tokens warnings.push({ type: 'high_token_usage', level: 'info', message: `High token usage in this session (${Math.round(currentSession.tokenUsage.total / 1000)}K tokens)`, tokenUsage: currentSession.tokenUsage.total }); } // Note: We don't warn about message counts since Claude uses complexity-based limits // that can't be accurately predicted from simple message counts } // Monthly warnings (these limits are more predictable) const monthlyProgress = monthlyUsage.sessionCount / this.MONTHLY_SESSION_LIMIT; if (monthlyProgress >= 0.9) { warnings.push({ type: 'monthly_limit_critical', level: 'error', message: `You're near your monthly session limit (${monthlyUsage.sessionCount}/${this.MONTHLY_SESSION_LIMIT})`, remainingSessions: monthlyUsage.remainingSessions }); } else if (monthlyProgress >= 0.75) { warnings.push({ type: 'monthly_limit_warning', level: 'warning', message: `75% of monthly sessions used (${monthlyUsage.sessionCount}/${this.MONTHLY_SESSION_LIMIT})`, remainingSessions: monthlyUsage.remainingSessions }); } return warnings; } /** * Format time remaining for display * @param {number} milliseconds - Time in milliseconds * @returns {string} Formatted time string */ formatTimeRemaining(milliseconds) { if (milliseconds <= 0) return '0m'; const hours = Math.floor(milliseconds / (60 * 60 * 1000)); const minutes = Math.floor((milliseconds % (60 * 60 * 1000)) / (60 * 1000)); if (hours > 0) { return `${hours}h ${minutes}m`; } return `${minutes}m`; } /** * Get session timer data for dashboard display * @param {Object} sessionData - Session analysis data * @returns {Object} Timer display data */ getSessionTimerData(sessionData) { const { currentSession, monthlyUsage, limits, warnings } = sessionData; if (!currentSession) { return { hasActiveSession: false, message: 'No active session', nextSessionAvailable: true }; } // Ensure limits exist, fallback to standard plan const planLimits = limits || this.PLAN_LIMITS['standard']; // Calculate only user messages (Claude only counts prompts toward limits) const userMessages = currentSession.messages ? currentSession.messages.filter(msg => msg.role === 'user') : []; const userMessageCount = userMessages.length; return { hasActiveSession: true, timeRemaining: currentSession.timeRemaining, timeRemainingFormatted: this.formatTimeRemaining(currentSession.timeRemaining), messagesUsed: userMessageCount, messagesEstimate: planLimits.estimatedMessagesPerSession, // Show as estimate, not limit tokensUsed: currentSession.tokenUsage.total, planName: planLimits.name, planDescription: planLimits.description, monthlySessionsUsed: monthlyUsage.sessionCount, monthlySessionsLimit: this.MONTHLY_SESSION_LIMIT, warnings: warnings.filter(w => w.type.includes('session')), willResetAt: currentSession.endTime, // Usage insights usageInsights: { tokensPerMessage: userMessageCount > 0 ? Math.round(currentSession.tokenUsage.total / userMessageCount) : 0, averageMessageComplexity: userMessageCount > 0 ? currentSession.messageWeight / userMessageCount : 0, conversationLength: currentSession.messages ? currentSession.messages.length : 0, sessionDuration: Date.now() - currentSession.startTime } }; } } module.exports = SessionAnalyzer;