UNPKG

cost-claude

Version:

Claude Code cost monitoring, analytics, and optimization toolkit

517 lines โ€ข 26.3 kB
import chalk from 'chalk'; import ora from 'ora'; import { homedir } from 'os'; import { glob } from 'glob'; import { readFile } from 'fs/promises'; import { ClaudeFileWatcher } from '../../services/file-watcher.js'; import { NotificationService } from '../../services/notification.js'; import { CostCalculator } from '../../core/cost-calculator.js'; import { JSONLParser } from '../../core/jsonl-parser.js'; import { logger } from '../../utils/logger.js'; import { formatCostColored, formatCost, formatDuration, formatNumber, shortenProjectName } from '../../utils/format.js'; import { SessionDetector } from '../../services/session-detector.js'; import { ProjectParser } from '../../core/project-parser.js'; function getMessageCost(message, parser, calculator) { if (message.costUSD !== null && message.costUSD !== undefined) { return message.costUSD; } const content = parser.parseMessageContent(message); if (content?.usage) { return calculator.calculate(content.usage); } return 0; } async function getRecentMessages(basePath, count, parser, calculator) { const pattern = `${basePath}/**/*.jsonl`; const files = await glob(pattern); const allMessages = []; for (const file of files) { try { const content = await readFile(file, 'utf-8'); const lines = content.split('\n').filter(line => line.trim()); for (const line of lines) { try { const message = JSON.parse(line); if (message.type === 'assistant') { const cost = getMessageCost(message, parser, calculator); if (cost > 0) { allMessages.push(message); } } } catch { } } } catch (error) { logger.warn(`Failed to read file ${file}:`, error); } } return allMessages .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) .slice(0, count); } export async function watchCommand(options) { if (options.verbose) { logger.level = 'debug'; logger.transports.forEach((transport) => { transport.level = 'debug'; }); } const model = options.parent?.opts?.()?.model || 'claude-opus-4-20250514'; const notifySession = options.notify && options.notifySession !== false; const notifyCost = options.notify && options.notifyCost === true; console.log(chalk.bold.blue('Claude Code Cost Watcher')); console.log(chalk.gray('Real-time monitoring for Claude usage')); console.log(chalk.dim(`Model: ${model}`)); if (options.test) { console.log(chalk.yellow('๐Ÿงช TEST MODE ENABLED')); } if (options.verbose) { console.log(chalk.gray('Verbose logging enabled')); } if (options.notify) { const notificationTypes = []; if (notifySession) notificationTypes.push('Session'); if (notifyCost) notificationTypes.push('Cost'); if (options.notifyTask) notificationTypes.push('Task (Delayed)'); if (options.notifyProgress !== false) notificationTypes.push('Progress'); console.log(chalk.gray(`Notifications: ${notificationTypes.join(', ') || 'None'}`)); if (options.delayedTimeout) { console.log(chalk.gray(`Delayed completion: ${parseInt(options.delayedTimeout) / 1000}s`)); } if (options.progressInterval) { console.log(chalk.gray(`Progress interval: ${parseInt(options.progressInterval) / 1000}s`)); } } const recentCount = parseInt(options.recent || '5'); if (options.includeExisting) { console.log(chalk.gray('Processing all existing messages')); } else if (recentCount > 0) { console.log(chalk.gray(`Showing last ${recentCount} messages before monitoring`)); } else { console.log(chalk.gray('Monitoring new messages only')); } console.log(); const spinner = ora('Initializing watcher...').start(); try { const basePath = options.path.replace('~', homedir()); if (options.test) { const testPath = `${homedir()}/.cost-claude/test`; const { mkdir } = await import('fs/promises'); await mkdir(testPath, { recursive: true }); console.log(chalk.gray(`Test directory created: ${testPath}`)); } const minCost = parseFloat(options.minCost); const recentCount = parseInt(options.recent || '5'); const watcher = new ClaudeFileWatcher({ paths: [`${basePath}/**/*.jsonl`], ignoreInitial: !options.includeExisting, pollInterval: 100, debounceDelay: 300, }); const notificationService = new NotificationService({ soundEnabled: options.sound || options.sessionSoundOnly, taskCompleteSound: options.taskSound, sessionCompleteSound: options.sessionSound, }); const calculator = new CostCalculator(undefined, model); await calculator.ensureRatesLoaded(); const parser = new JSONLParser(); const sessionDetector = new SessionDetector({ inactivityTimeout: 300000, summaryMessageTimeout: 5000, taskCompletionTimeout: 3000, delayedTaskCompletionTimeout: parseInt(options.delayedTimeout || '30000'), minTaskCost: parseFloat(options.minTaskCost || '0.01'), minTaskMessages: parseInt(options.minTaskMessages || '1'), enableProgressNotifications: options.notifyProgress !== false, progressCheckInterval: parseInt(options.progressInterval || '10000'), minProgressCost: 0.02, minProgressDuration: 15000 }); const sessionCosts = new Map(); const sessionMessages = new Map(); let dailyTotal = 0; let currentDay = new Date().toDateString(); let lastSummary = { total: 0, sessions: 0, messages: 0 }; spinner.succeed('Watcher initialized'); console.log(chalk.gray(`Watching: ${basePath}`)); console.log(chalk.gray(`Min cost for notification: $${minCost.toFixed(4)}`)); console.log(chalk.gray('Press Ctrl+C to stop')); if (options.test) { console.log(chalk.yellow('\n๐Ÿ“ Test Mode Instructions:')); console.log(chalk.gray(' 1. Create or modify .jsonl files in the watched directory')); console.log(chalk.gray(' 2. Add messages in JSONL format (one JSON object per line)')); console.log(chalk.gray(' 3. Watch for real-time cost updates')); console.log(chalk.gray(`\nExample message format:`)); console.log(chalk.dim(` {"uuid":"msg-123","type":"assistant","costUSD":0.05,"timestamp":"${new Date().toISOString()}"}}`)); } console.log(); sessionDetector.on('task-completed', async (data) => { const durationSec = Math.round(data.taskDuration / 1000); const completionIcon = data.completionType === 'delayed' ? '๐ŸŽฏ' : '๐Ÿ’ฌ'; const completionText = data.completionType === 'delayed' ? 'Task Completed (Confident)' : 'Task Completed'; console.log(chalk.bold.cyan(`\n${completionIcon} ${completionText}`)); console.log(chalk.gray(` Project: ${data.projectName}`)); console.log(chalk.gray(` Duration: ${durationSec} seconds`)); console.log(chalk.gray(` Cost: ${formatCostColored(data.taskCost)}`)); console.log(chalk.gray(` Messages: ${data.assistantMessageCount}`)); console.log(chalk.gray(` Type: ${data.completionType}\n`)); if (options.notifyTask) { const title = data.completionType === 'delayed' ? `๐ŸŽฏ ${shortenProjectName(data.projectName)} - Task Complete` : `๐Ÿ’ฌ ${shortenProjectName(data.projectName)} - Quick Task`; const message = [ `โฑ๏ธ ${durationSec}s โ€ข ๐Ÿ’ฌ ${data.assistantMessageCount} messages`, `๐Ÿ’ฐ ${formatCost(data.taskCost)}` ].join('\n'); await notificationService.sendCustom(title, message, { soundType: 'task', timeout: 20, sound: options.sound && !options.sessionSoundOnly }); } }); sessionDetector.on('task-progress', async (data) => { const durationMin = Math.round(data.currentDuration / 60000); const durationSec = Math.round((data.currentDuration % 60000) / 1000); const timeStr = durationMin > 0 ? `${durationMin}m ${durationSec}s` : `${durationSec}s`; console.log(chalk.bold.yellow(`\nโณ Task in Progress`)); console.log(chalk.gray(` Project: ${data.projectName}`)); console.log(chalk.gray(` Duration: ${timeStr}`)); console.log(chalk.gray(` Current Cost: ${formatCostColored(data.currentCost)}`)); console.log(chalk.gray(` Messages: ${data.assistantMessageCount}`)); if (data.estimatedCompletion) { const estSec = Math.round(data.estimatedCompletion / 1000); console.log(chalk.gray(` Est. completion: ~${estSec}s`)); } console.log(); if (options.notifyProgress !== false) { const message = [ `โฑ๏ธ ${timeStr} โ€ข ๐Ÿ’ฌ ${data.assistantMessageCount} messages`, `๐Ÿ’ฐ Current: ${formatCost(data.currentCost)}`, data.estimatedCompletion ? `โฐ Est: ~${Math.round(data.estimatedCompletion / 1000)}s` : '' ].filter(Boolean).join('\n'); await notificationService.sendCustom(`โณ ${shortenProjectName(data.projectName)} - In Progress`, message, { sound: false, timeout: 10 }); } }); sessionDetector.on('session-completed', async (data) => { const durationMin = Math.round(data.duration / 60000); const avgCostPerMessage = data.messageCount > 0 ? data.totalCost / data.messageCount : 0; console.log(chalk.bold.green(`\nโœ… Session Completed: ${data.projectName}`)); console.log(chalk.gray(` Summary: ${data.summary}`)); console.log(chalk.gray(` Duration: ${durationMin} minutes`)); console.log(chalk.gray(` Total Cost: ${formatCostColored(data.totalCost)}`)); console.log(chalk.gray(` Messages: ${data.messageCount}`)); console.log(chalk.gray(` Avg Cost/Message: ${formatCostColored(avgCostPerMessage)}\n`)); if (notifySession && data.totalCost > 0) { const message = [ `๐Ÿ“ ${data.summary}`, `โฑ๏ธ ${durationMin} min โ€ข ๐Ÿ’ฌ ${data.messageCount} messages`, `๐Ÿ’ฐ Total: ${formatCost(data.totalCost)}` ].join('\n'); await notificationService.sendCustom(`โœ… ${shortenProjectName(data.projectName)} - Session Complete`, message, { soundType: 'session', sound: options.sound || options.sessionSoundOnly }); } }); watcher.on('new-message', async (message) => { const messageAge = Date.now() - new Date(message.timestamp).getTime(); const maxAgeMinutes = parseInt(options.maxAge || '5', 10); const maxAge = maxAgeMinutes * 60 * 1000; if (messageAge > maxAge && !options.includeExisting) { logger.debug(`Skipping old message (${Math.round(messageAge / 1000)}s old):`, { uuid: message.uuid, timestamp: message.timestamp, type: message.type }); sessionDetector.processMessage(message); return; } sessionDetector.processMessage(message); const todayDate = new Date().toDateString(); if (currentDay !== todayDate) { console.log(chalk.bold.blue(`\n๐Ÿ“… New day: ${todayDate}\n`)); dailyTotal = 0; currentDay = todayDate; } if (message.type === 'assistant') { const messageCost = getMessageCost(message, parser, calculator); if (messageCost === 0) return; const sessionId = message.sessionId || 'unknown'; const currentSessionCost = sessionCosts.get(sessionId) || 0; const currentSessionMessages = sessionMessages.get(sessionId) || 0; const newSessionCost = currentSessionCost + messageCost; const newSessionMessages = currentSessionMessages + 1; sessionCosts.set(sessionId, newSessionCost); sessionMessages.set(sessionId, newSessionMessages); dailyTotal += messageCost; const content = parser.parseMessageContent(message); const tokens = content?.usage || { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }; const cacheEfficiency = calculator.calculateCacheEfficiency(tokens); const projectName = ProjectParser.getProjectFromMessage(message) || 'Unknown Project'; const msgDateTime = new Date(message.timestamp); const dateStr = msgDateTime.toLocaleDateString(); const timeStr = msgDateTime.toLocaleTimeString(); const isToday = dateStr === new Date().toLocaleDateString(); const timestamp = isToday ? timeStr : `${dateStr} ${timeStr}`; if (msgDateTime.toDateString() === currentDay) { dailyTotal += messageCost; } let duration = message.durationMs || 0; let durationEstimated = false; if (!duration && content?.ttftMs) { const outputTime = (tokens.output_tokens || 0) * 10; duration = content.ttftMs + outputTime; durationEstimated = true; } else if (!duration && tokens.output_tokens > 0) { duration = Math.max(1000, tokens.output_tokens * 20); durationEstimated = true; } const durationDisplay = durationEstimated ? `${formatDuration(duration)}~` : formatDuration(duration); console.log(`[${chalk.gray(timestamp)}] ` + `Cost: ${formatCostColored(messageCost)} | ` + `Duration: ${chalk.cyan(durationDisplay)} | ` + `Tokens: ${chalk.gray(formatNumber(tokens.input_tokens + tokens.output_tokens))} | ` + `Cache: ${chalk.green(cacheEfficiency.toFixed(0) + '%')} | ` + chalk.bold(projectName)); if (notifyCost && messageCost >= minCost) { await notificationService.notifyCostUpdate({ sessionId, messageId: message.uuid, cost: messageCost, duration: duration, tokens: { input: tokens.input_tokens || 0, output: tokens.output_tokens || 0, cacheHit: tokens.cache_read_input_tokens || 0, }, sessionTotal: newSessionCost, dailyTotal, projectName, }); } if (newSessionMessages % 10 === 0) { console.log(chalk.dim(' โ””โ”€ Session summary: ') + `${newSessionMessages} messages | ` + `Total: ${formatCostColored(newSessionCost)} | ` + `Avg: ${formatCostColored(newSessionCost / newSessionMessages)}`); } } }); watcher.on('error', (error) => { logger.error('Watcher error:', error); console.error(chalk.red('Error:'), error.message); }); watcher.on('file-added', (filePath) => { if (options.verbose) { console.log(chalk.dim(`๐Ÿ“ New file detected: ${filePath}`)); } }); if (options.verbose) { watcher.on('parse-error', ({ filePath, line, error }) => { console.error(chalk.yellow('โš ๏ธ Parse error:'), { file: filePath, line: line.substring(0, 50) + '...', error: error instanceof Error ? error.message : error }); }); } if (recentCount > 0 && !options.includeExisting) { const recentSpinner = ora('Loading recent messages...').start(); try { const recentMessages = await getRecentMessages(basePath, recentCount, parser, calculator); recentSpinner.succeed(`Found ${recentMessages.length} recent messages`); if (recentMessages.length > 0) { console.log(chalk.bold.cyan('\n๐Ÿ“œ Recent Messages:')); let lastDate = ''; for (const message of recentMessages.reverse()) { const content = parser.parseMessageContent(message); const tokens = content?.usage || { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }; const cacheEfficiency = calculator.calculateCacheEfficiency(tokens); const messageDate = new Date(message.timestamp); const dateStr = messageDate.toLocaleDateString(); const timeStr = messageDate.toLocaleTimeString(); if (dateStr !== lastDate) { console.log(chalk.bold.gray(`\n๐Ÿ“… ${dateStr}`)); lastDate = dateStr; } const projectName = ProjectParser.getProjectFromMessage(message) || 'Unknown Project'; const messageCost = getMessageCost(message, parser, calculator); let duration = message.durationMs || 0; let durationEstimated = false; if (!duration && content?.ttftMs) { const outputTime = (tokens.output_tokens || 0) * 10; duration = content.ttftMs + outputTime; durationEstimated = true; } else if (!duration && tokens.output_tokens > 0) { duration = Math.max(1000, tokens.output_tokens * 20); durationEstimated = true; } const durationDisplay = durationEstimated ? `${formatDuration(duration)}~` : formatDuration(duration); console.log(`[${chalk.gray(timeStr)}] ` + `Cost: ${formatCostColored(messageCost)} | ` + `Duration: ${chalk.cyan(durationDisplay)} | ` + `Tokens: ${chalk.gray(formatNumber(tokens.input_tokens + tokens.output_tokens))} | ` + `Cache: ${chalk.green(cacheEfficiency.toFixed(0) + '%')} | ` + chalk.bold(projectName)); const sessionId = message.sessionId || 'unknown'; const currentSessionCost = sessionCosts.get(sessionId) || 0; const currentSessionMessages = sessionMessages.get(sessionId) || 0; sessionCosts.set(sessionId, currentSessionCost + messageCost); sessionMessages.set(sessionId, currentSessionMessages + 1); const messageDateStr = new Date(message.timestamp).toDateString(); if (messageDateStr === currentDay) { dailyTotal += messageCost; } } console.log(chalk.dim('โ”€'.repeat(60)) + '\n'); } } catch (error) { recentSpinner.fail('Failed to load recent messages'); logger.error('Error loading recent messages:', error); } } await watcher.start(); if (options.test) { const generateTestData = async () => { const testFile = `${homedir()}/.cost-claude/test/test-session-${Date.now()}.jsonl`; const { writeFile } = await import('fs/promises'); console.log(chalk.blue('\n๐ŸŽฒ Generating test data...')); const sessionId = `test-${Date.now()}`; const messages = []; messages.push({ uuid: `${sessionId}-1`, type: 'user', timestamp: new Date().toISOString(), sessionId, message: JSON.stringify({ role: 'user', content: 'Test question about coding' }) }); messages.push({ uuid: `${sessionId}-2`, type: 'assistant', timestamp: new Date().toISOString(), sessionId, costUSD: 0.0234, durationMs: 2345, message: JSON.stringify({ role: 'assistant', content: 'Test response content', model: model, usage: { input_tokens: 523, output_tokens: 234, cache_read_input_tokens: 100, cache_creation_input_tokens: 50 } }) }); await writeFile(testFile, messages.map(m => JSON.stringify(m)).join('\n') + '\n'); console.log(chalk.green(`โœ“ Created test file: ${testFile}`)); console.log(chalk.gray(` Added ${messages.length} messages`)); }; setTimeout(generateTestData, 2000); setInterval(generateTestData, 30000); } const summaryInterval = setInterval(() => { const currentMessages = Array.from(sessionMessages.values()).reduce((a, b) => a + b, 0); const currentSessions = sessionCosts.size; const hasChanges = dailyTotal !== lastSummary.total || currentSessions !== lastSummary.sessions || currentMessages !== lastSummary.messages; if (dailyTotal > 0 && hasChanges) { console.log(chalk.bold.yellow('\n๐Ÿ“Š Hourly Summary:') + `\n Today's total: ${formatCostColored(dailyTotal)}` + `\n Active sessions: ${currentSessions}` + `\n Total messages: ${currentMessages}\n`); lastSummary = { total: dailyTotal, sessions: currentSessions, messages: currentMessages }; } }, 3600000); let isShuttingDown = false; process.on('SIGINT', async () => { if (isShuttingDown) return; isShuttingDown = true; console.log(chalk.yellow('\n\nShutting down...')); clearInterval(summaryInterval); const activeSessions = sessionDetector.getActiveSessions(); if (activeSessions.length > 0) { console.log(chalk.gray(`Completing ${activeSessions.length} active sessions...`)); sessionDetector.completeAllSessions(); await new Promise(resolve => setTimeout(resolve, 1000)); } try { await watcher.stop(); } catch (error) { logger.debug('Error stopping watcher:', error); } if (sessionCosts.size > 0) { console.log(chalk.bold.blue('\n๐Ÿ“ˆ Final Summary:')); console.log(` Total sessions: ${sessionCosts.size}`); console.log(` Total cost: ${formatCostColored(dailyTotal)}`); const topSessions = Array.from(sessionCosts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3); if (topSessions.length > 0) { console.log(chalk.bold('\n Top Sessions:')); topSessions.forEach(([sessionId, cost], index) => { const messages = sessionMessages.get(sessionId) || 0; console.log(` ${index + 1}. ${sessionId.substring(0, 8)}... - ` + `${formatCostColored(cost)} (${messages} messages)`); }); } } console.log(chalk.green('\nGoodbye! ๐Ÿ‘‹')); process.exit(0); }); await new Promise(() => { }); } catch (error) { spinner.fail('Failed to start watcher'); logger.error('Watch command error:', error); console.error(chalk.red('Error:'), error instanceof Error ? error.message : error); process.exit(1); } } //# sourceMappingURL=watch.js.map