UNPKG

mcp-product-manager

Version:

MCP Orchestrator for task and project management with web interface

531 lines 25 kB
// UsageTrackingService - Handles ccusage integration with proper block boundary tracking // Combines ccusage blocks (for 5-hour boundaries) with ccusage daily (for token details) import { exec, execSync } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); export class UsageTrackingService { constructor(dbPath) { this.dbPath = dbPath; this.db = null; this.cache = new Map(); this.lastBlockId = null; this.blockCheckInterval = null; this.isInitialized = false; // Cache TTLs this.CACHE_TTL = { active: 60 * 1000, // 1 minute for active block recent: 5 * 60 * 1000, // 5 minutes for recent blocks daily: 30 * 60 * 1000, // 30 minutes for daily stats snapshot: 2 * 60 * 1000 // 2 minutes for snapshots }; // Configuration this.config = { dailyBudget: 500, warningThresholds: { burnRate: 100, // $/hr warning threshold timeRemaining: 4, // Hours warning threshold opusUsage: 0.8 // 80% Opus usage warning } }; } async initialize() { // Initialize database connection with compatibility check try { const { default: Database } = await import('better-sqlite3'); this.db = new Database(this.dbPath); } catch (error) { if (error.code === 'MODULE_NOT_FOUND' || error.message.includes('better-sqlite3')) { console.log('🔧 Installing better-sqlite3 for usage tracking...'); try { execSync('npm install better-sqlite3', { stdio: 'inherit', cwd: process.cwd() }); console.log('✅ better-sqlite3 installed successfully'); // Try importing again after install const { default: Database } = await import('better-sqlite3'); this.db = new Database(this.dbPath); } catch (installError) { console.error('❌ Failed to install better-sqlite3 for usage tracking:', installError.message); throw installError; } } else if (error.message.includes('NODE_MODULE_VERSION')) { console.log('🔧 Rebuilding better-sqlite3 for usage tracking...'); try { execSync('npm rebuild better-sqlite3', { stdio: 'inherit', cwd: process.cwd() }); console.log('✅ better-sqlite3 rebuilt successfully'); // Try importing again after rebuild const { default: Database } = await import('better-sqlite3'); this.db = new Database(this.dbPath); } catch (rebuildError) { console.error('❌ Failed to rebuild better-sqlite3 for usage tracking:', rebuildError.message); throw rebuildError; } } else { console.error('❌ Database error in usage tracking:', error.message); throw error; } } // Ensure snapshot tables exist await this.createSnapshotTables(); // Capture initial baseline for current block await this.captureInitialBaseline(); // Start monitoring for block transitions this.startBlockMonitoring(); this.isInitialized = true; console.log('✅ UsageTrackingService initialized'); } async createSnapshotTables() { try { this.db.exec(` CREATE TABLE IF NOT EXISTS block_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, block_start_time TEXT NOT NULL, block_id TEXT NOT NULL, snapshot_time TEXT NOT NULL, reason TEXT NOT NULL, cumulative_total_tokens INTEGER, cumulative_total_cost REAL, cumulative_opus_tokens INTEGER, cumulative_opus_cost REAL, cumulative_opus_input_tokens INTEGER DEFAULT 0, cumulative_opus_output_tokens INTEGER DEFAULT 0, cumulative_opus_cache_creation_tokens INTEGER DEFAULT 0, cumulative_opus_cache_read_tokens INTEGER DEFAULT 0, cumulative_sonnet_tokens INTEGER, cumulative_sonnet_cost REAL, cumulative_sonnet_input_tokens INTEGER DEFAULT 0, cumulative_sonnet_output_tokens INTEGER DEFAULT 0, cumulative_sonnet_cache_creation_tokens INTEGER DEFAULT 0, cumulative_sonnet_cache_read_tokens INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(block_start_time, reason) ) `); } catch (err) { throw err; } } async captureInitialBaseline() { try { // Get current active block with its backdated start time const { stdout: blockData } = await execAsync('npx ccusage blocks --active --json'); const blocks = JSON.parse(blockData); const activeBlock = blocks.blocks?.[0]; if (!activeBlock) { console.log('⚠️ No active block found'); return; } // CRITICAL: Use the backdated block start time from ccusage const blockStartTime = activeBlock.startTime; this.lastBlockId = activeBlock.id; console.log(`📊 Current block: ${activeBlock.id} (started: ${blockStartTime})`); // Check if we already have a baseline for this block const existingBaseline = await this.getSnapshot(blockStartTime, 'block_start'); if (!existingBaseline) { console.log(`📸 Capturing initial baseline for block starting at ${blockStartTime}`); // Get detailed token data from daily const todayStr = new Date().toISOString().split('T')[0].replace(/-/g, ''); const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${todayStr} --breakdown --json`); const daily = JSON.parse(dailyData); // Extract model breakdowns const modelData = this.extractModelData(daily); // Save baseline snapshot with backdated block start time await this.saveSnapshot({ block_start_time: blockStartTime, block_id: activeBlock.id, snapshot_time: new Date().toISOString(), reason: 'block_start', ...modelData }); console.log('✅ Initial baseline captured'); } else { console.log('✓ Baseline already exists for current block'); } // Calculate next block transition const blockStart = new Date(blockStartTime); const nextBlockTime = new Date(blockStart.getTime() + (5 * 60 * 60 * 1000)); console.log(`⏰ Next block transition expected at: ${nextBlockTime.toISOString()}`); } catch (error) { console.error('❌ Error capturing initial baseline:', error); } } extractModelData(dailyData) { const daily = dailyData.daily?.[0] || {}; const modelBreakdowns = daily.modelBreakdowns || []; // Find Opus and Sonnet data const opusData = modelBreakdowns.find(m => m.modelName?.toLowerCase().includes('opus')) || {}; const sonnetData = modelBreakdowns.find(m => m.modelName?.toLowerCase().includes('sonnet')) || {}; return { cumulative_total_tokens: daily.totalTokens || 0, cumulative_total_cost: daily.totalCost || 0, cumulative_opus_tokens: opusData.totalTokens || 0, cumulative_opus_cost: opusData.cost || 0, cumulative_opus_input_tokens: opusData.inputTokens || 0, cumulative_opus_output_tokens: opusData.outputTokens || 0, cumulative_opus_cache_creation_tokens: opusData.cacheCreationInputTokens || 0, cumulative_opus_cache_read_tokens: opusData.cacheReadInputTokens || 0, cumulative_sonnet_tokens: sonnetData.totalTokens || 0, cumulative_sonnet_cost: sonnetData.cost || 0, cumulative_sonnet_input_tokens: sonnetData.inputTokens || 0, cumulative_sonnet_output_tokens: sonnetData.outputTokens || 0, cumulative_sonnet_cache_creation_tokens: sonnetData.cacheCreationInputTokens || 0, cumulative_sonnet_cache_read_tokens: sonnetData.cacheReadInputTokens || 0 }; } startBlockMonitoring() { // Check for block transitions every minute this.blockCheckInterval = setInterval(async () => { await this.checkBlockTransition(); }, 60 * 1000); // 1 minute console.log('🔄 Block transition monitoring started'); } async checkBlockTransition() { try { const { stdout } = await execAsync('npx ccusage blocks --active --json'); const data = JSON.parse(stdout); const currentBlock = data.blocks?.[0]; if (currentBlock && currentBlock.id !== this.lastBlockId) { console.log(`🔄 BLOCK TRANSITION DETECTED: ${this.lastBlockId} → ${currentBlock.id}`); // Capture end-of-block snapshot for the old block if (this.lastBlockId) { await this.captureSnapshot('block_end'); } // Update current block this.lastBlockId = currentBlock.id; // Capture start-of-block baseline for new block await this.captureSnapshot('block_start'); // Clear relevant caches this.cache.delete('active-block'); this.cache.delete('current-usage'); } } catch (error) { console.error('Error checking block transition:', error); } } async captureSnapshot(reason) { try { const { stdout: blockData } = await execAsync('npx ccusage blocks --active --json'); const blocks = JSON.parse(blockData); const activeBlock = blocks.blocks?.[0]; if (!activeBlock) return; // Get detailed token data const todayStr = new Date().toISOString().split('T')[0].replace(/-/g, ''); const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${todayStr} --breakdown --json`); const daily = JSON.parse(dailyData); const modelData = this.extractModelData(daily); await this.saveSnapshot({ block_start_time: activeBlock.startTime, block_id: activeBlock.id, snapshot_time: new Date().toISOString(), reason: reason, ...modelData }); console.log(`📸 Snapshot captured: ${reason} for block ${activeBlock.id}`); } catch (error) { console.error('Error capturing snapshot:', error); } } async saveSnapshot(data) { try { const sql = ` INSERT OR REPLACE INTO block_snapshots ( block_start_time, block_id, snapshot_time, reason, cumulative_total_tokens, cumulative_total_cost, cumulative_opus_tokens, cumulative_opus_cost, cumulative_opus_input_tokens, cumulative_opus_output_tokens, cumulative_opus_cache_creation_tokens, cumulative_opus_cache_read_tokens, cumulative_sonnet_tokens, cumulative_sonnet_cost, cumulative_sonnet_input_tokens, cumulative_sonnet_output_tokens, cumulative_sonnet_cache_creation_tokens, cumulative_sonnet_cache_read_tokens ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const stmt = this.db.prepare(sql); stmt.run([ data.block_start_time, data.block_id, data.snapshot_time, data.reason, data.cumulative_total_tokens, data.cumulative_total_cost, data.cumulative_opus_tokens, data.cumulative_opus_cost, data.cumulative_opus_input_tokens, data.cumulative_opus_output_tokens, data.cumulative_opus_cache_creation_tokens, data.cumulative_opus_cache_read_tokens, data.cumulative_sonnet_tokens, data.cumulative_sonnet_cost, data.cumulative_sonnet_input_tokens, data.cumulative_sonnet_output_tokens, data.cumulative_sonnet_cache_creation_tokens, data.cumulative_sonnet_cache_read_tokens ]); } catch (err) { throw err; } } async getSnapshot(blockStartTime, reason) { try { const stmt = this.db.prepare('SELECT * FROM block_snapshots WHERE block_start_time = ? AND reason = ?'); return stmt.get([blockStartTime, reason]); } catch (err) { throw err; } } // Public API methods async getCurrentUsage() { const cacheKey = 'current-usage'; const cached = this.cache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL.active) { return cached.data; } try { // Get active block info const { stdout: blockData } = await execAsync('npx ccusage blocks --active --breakdown --json'); const blocks = JSON.parse(blockData); const activeBlock = blocks.blocks?.[0]; if (!activeBlock) { return { error: 'No active block found' }; } // Get detailed daily data for model breakdown const todayStr = new Date().toISOString().split('T')[0].replace(/-/g, ''); const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${todayStr} --breakdown --json`); const daily = JSON.parse(dailyData); // Get baseline snapshot for this block const baseline = await this.getSnapshot(activeBlock.startTime, 'block_start'); // Calculate current usage within block const currentModelData = this.extractModelData(daily); const blockUsage = this.calculateBlockUsage(currentModelData, baseline); // Calculate burn rate and projections const analysis = this.analyzeUsage(activeBlock, blockUsage); const result = { block: activeBlock, usage: blockUsage, analysis: analysis, timestamp: new Date().toISOString() }; this.cache.set(cacheKey, { data: result, timestamp: Date.now() }); return result; } catch (error) { console.error('Error getting current usage:', error); return { error: error.message }; } } calculateBlockUsage(current, baseline) { if (!baseline) { // No baseline means this is all usage for current block return { opus: { tokens: current.cumulative_opus_tokens, cost: current.cumulative_opus_cost, inputTokens: current.cumulative_opus_input_tokens, outputTokens: current.cumulative_opus_output_tokens, cacheTokens: current.cumulative_opus_cache_creation_tokens + current.cumulative_opus_cache_read_tokens }, sonnet: { tokens: current.cumulative_sonnet_tokens, cost: current.cumulative_sonnet_cost, inputTokens: current.cumulative_sonnet_input_tokens, outputTokens: current.cumulative_sonnet_output_tokens, cacheTokens: current.cumulative_sonnet_cache_creation_tokens + current.cumulative_sonnet_cache_read_tokens }, total: { tokens: current.cumulative_total_tokens, cost: current.cumulative_total_cost } }; } // Calculate delta from baseline return { opus: { tokens: current.cumulative_opus_tokens - baseline.cumulative_opus_tokens, cost: current.cumulative_opus_cost - baseline.cumulative_opus_cost, inputTokens: current.cumulative_opus_input_tokens - baseline.cumulative_opus_input_tokens, outputTokens: current.cumulative_opus_output_tokens - baseline.cumulative_opus_output_tokens, cacheTokens: (current.cumulative_opus_cache_creation_tokens + current.cumulative_opus_cache_read_tokens) - (baseline.cumulative_opus_cache_creation_tokens + baseline.cumulative_opus_cache_read_tokens) }, sonnet: { tokens: current.cumulative_sonnet_tokens - baseline.cumulative_sonnet_tokens, cost: current.cumulative_sonnet_cost - baseline.cumulative_sonnet_cost, inputTokens: current.cumulative_sonnet_input_tokens - baseline.cumulative_sonnet_input_tokens, outputTokens: current.cumulative_sonnet_output_tokens - baseline.cumulative_sonnet_output_tokens, cacheTokens: (current.cumulative_sonnet_cache_creation_tokens + current.cumulative_sonnet_cache_read_tokens) - (baseline.cumulative_sonnet_cache_creation_tokens + baseline.cumulative_sonnet_cache_read_tokens) }, total: { tokens: current.cumulative_total_tokens - baseline.cumulative_total_tokens, cost: current.cumulative_total_cost - baseline.cumulative_total_cost } }; } analyzeUsage(block, usage) { const elapsedMinutes = block.elapsedMinutes || 1; const burnRate = (usage.total.cost / elapsedMinutes) * 60; // $/hour const budgetRemaining = this.config.dailyBudget - usage.total.cost; const hoursUntilBudgetExhausted = burnRate > 0 ? budgetRemaining / burnRate : Infinity; const opusRatio = usage.total.cost > 0 ? usage.opus.cost / usage.total.cost : 0; const warnings = []; // Budget warnings if (hoursUntilBudgetExhausted < 2) { warnings.push({ severity: 'critical', type: 'budget', message: 'Less than 2 hours until budget exhausted', action: 'Pause non-critical agents immediately' }); } else if (hoursUntilBudgetExhausted < 4) { warnings.push({ severity: 'warning', type: 'budget', message: `Budget will be exhausted in ${hoursUntilBudgetExhausted.toFixed(1)} hours`, action: 'Monitor closely and prioritize critical tasks' }); } // Burn rate warnings if (burnRate > this.config.warningThresholds.burnRate) { warnings.push({ severity: 'warning', type: 'burn_rate', message: `High burn rate: $${burnRate.toFixed(2)}/hour`, action: 'Consider using Sonnet instead of Opus for analysis tasks' }); } // Model usage warnings if (opusRatio > this.config.warningThresholds.opusUsage) { warnings.push({ severity: 'warning', type: 'model_usage', message: `Heavy Opus usage: ${(opusRatio * 100).toFixed(0)}% of costs`, action: 'Opus is 5x more expensive - use strategically for complex tasks only' }); } return { burnRate: burnRate, budgetRemaining: budgetRemaining, hoursUntilBudgetExhausted: hoursUntilBudgetExhausted, opusRatio: opusRatio, sonnetRatio: 1 - opusRatio, warnings: warnings, recommendations: this.generateRecommendations(usage, burnRate, opusRatio) }; } generateRecommendations(usage, burnRate, opusRatio) { const recommendations = []; if (opusRatio > 0.5 && burnRate > 50) { recommendations.push({ type: 'model_optimization', priority: 'high', suggestion: 'Switch routine tasks to Sonnet to reduce costs by up to 80%', estimatedSavings: usage.opus.cost * 0.8 }); } if (burnRate > 100) { const optimalAgentCount = Math.floor(this.config.dailyBudget / (8 * 25)); // 8 hours, $25/hr per agent recommendations.push({ type: 'agent_optimization', priority: 'medium', suggestion: `Consider limiting to ${optimalAgentCount} concurrent agents`, estimatedSavings: (burnRate - (optimalAgentCount * 25)) * 8 }); } return recommendations; } async getHistoricalTrends(days = 7) { const cacheKey = `trends-${days}`; const cached = this.cache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL.daily) { return cached.data; } try { const since = new Date(); since.setDate(since.getDate() - days); const sinceStr = since.toISOString().split('T')[0].replace(/-/g, ''); // Get daily breakdown const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${sinceStr} --breakdown --json`); const daily = JSON.parse(dailyData); // Get recent blocks const { stdout: blockData } = await execAsync('npx ccusage blocks --recent --breakdown --json'); const blocks = JSON.parse(blockData); const result = { daily: daily.daily || [], blocks: blocks.blocks || [], patterns: this.analyzePatterns(daily.daily || []), timestamp: new Date().toISOString() }; this.cache.set(cacheKey, { data: result, timestamp: Date.now() }); return result; } catch (error) { console.error('Error getting historical trends:', error); return { error: error.message }; } } analyzePatterns(dailyData) { if (!dailyData.length) return {}; const totalCost = dailyData.reduce((sum, day) => sum + (day.totalCost || 0), 0); const averageDailyCost = totalCost / dailyData.length; let totalOpusCost = 0; let totalSonnetCost = 0; dailyData.forEach(day => { if (day.modelBreakdowns) { day.modelBreakdowns.forEach(model => { if (model.modelName?.toLowerCase().includes('opus')) { totalOpusCost += model.cost || 0; } else if (model.modelName?.toLowerCase().includes('sonnet')) { totalSonnetCost += model.cost || 0; } }); } }); return { averageDailyCost: averageDailyCost, totalCost: totalCost, opusCost: totalOpusCost, sonnetCost: totalSonnetCost, opusPercentage: totalCost > 0 ? (totalOpusCost / totalCost) * 100 : 0, trend: this.calculateTrend(dailyData) }; } calculateTrend(dailyData) { if (dailyData.length < 3) return 'insufficient_data'; const recent = dailyData.slice(-3); const older = dailyData.slice(0, 3); const recentAvg = recent.reduce((sum, d) => sum + d.totalCost, 0) / 3; const olderAvg = older.reduce((sum, d) => sum + d.totalCost, 0) / 3; if (recentAvg > olderAvg * 1.2) return 'increasing'; if (recentAvg < olderAvg * 0.8) return 'decreasing'; return 'stable'; } // Cleanup method stop() { if (this.blockCheckInterval) { clearInterval(this.blockCheckInterval); } if (this.db) { this.db.close(); } console.log('🛑 UsageTrackingService stopped'); } } //# sourceMappingURL=usageTracking.js.map