UNPKG

cost-claude

Version:

Claude Code cost monitoring, analytics, and optimization toolkit

283 lines • 12.3 kB
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { createHash } from 'crypto'; import chalk from 'chalk'; import { logger } from '../utils/logger.js'; import { JSONLParser } from '../core/jsonl-parser.js'; export class SyncManager { syncDir; machineId; machineName; parser; constructor() { this.syncDir = join(homedir(), '.cost-claude', 'sync'); this.machineId = this.generateMachineId(); this.machineName = this.getMachineName(); this.parser = new JSONLParser(); if (!existsSync(this.syncDir)) { mkdirSync(this.syncDir, { recursive: true }); } } generateMachineId() { const hostname = require('os').hostname(); const platform = process.platform; const hash = createHash('sha256').update(`${hostname}-${platform}`).digest('hex'); return hash.substring(0, 8); } getMachineName() { return require('os').hostname(); } async exportForSync(projectPath, outputPath) { try { const messages = await this.parser.parseDirectory(projectPath); const totalCost = messages.reduce((sum, msg) => sum + (msg.costUSD || 0), 0); const checksum = this.calculateChecksum(messages); const metadata = { machineId: this.machineId, machineName: this.machineName, lastSync: new Date().toISOString(), messagesCount: messages.length, totalCost, checksum }; const exportData = { metadata, messages: messages.map((msg) => ({ ...msg, _syncMachineId: this.machineId, _syncTimestamp: new Date().toISOString() })) }; const filename = `claude-sync-${this.machineId}-${Date.now()}.json`; const finalPath = outputPath || join(this.syncDir, filename); writeFileSync(finalPath, JSON.stringify(exportData, null, 2)); logger.info(`Exported ${messages.length} messages to ${finalPath}`); return finalPath; } catch (error) { logger.error('Error exporting for sync:', error); throw error; } } async importAndMerge(importPaths, targetPath, options = { strategy: 'newest', backup: true, dryRun: false }) { try { const existingMessages = await this.parser.parseDirectory(targetPath); const existingMap = new Map(); existingMessages.forEach((msg) => { if (msg.uuid) { existingMap.set(msg.uuid, msg); } }); const importedData = []; let totalNewMessages = 0; let totalDuplicates = 0; let totalConflicts = 0; for (const importPath of importPaths) { if (!existsSync(importPath)) { logger.warn(`Import file not found: ${importPath}`); continue; } const data = JSON.parse(readFileSync(importPath, 'utf-8')); importedData.push(data); for (const msg of data.messages) { if (!msg.uuid) continue; if (!existingMap.has(msg.uuid)) { existingMap.set(msg.uuid, msg); totalNewMessages++; } else { const existing = existingMap.get(msg.uuid); if (this.messagesEqual(existing, msg)) { totalDuplicates++; } else { totalConflicts++; const winner = this.resolveConflict(existing, msg, options.strategy); existingMap.set(msg.uuid, winner); } } } } const mergedMessages = Array.from(existingMap.values()); const totalCost = mergedMessages.reduce((sum, msg) => sum + (msg.costUSD || 0), 0); const report = { totalMessages: mergedMessages.length, newMessages: totalNewMessages, duplicates: totalDuplicates, conflicts: totalConflicts, totalCost, machines: importedData.map(d => d.metadata) }; if (!options.dryRun) { if (options.backup && existingMessages.length > 0) { const backupPath = join(targetPath, '..', `backup-${Date.now()}.jsonl`); await this.createBackup(targetPath, backupPath); } await this.writeMergedData(mergedMessages, targetPath); } return report; } catch (error) { logger.error('Error importing and merging:', error); throw error; } } calculateChecksum(messages) { const sortedMessages = [...messages].sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')); const data = sortedMessages.map(msg => `${msg.uuid}:${msg.timestamp}:${msg.costUSD || 0}`).join('|'); return createHash('sha256').update(data).digest('hex').substring(0, 16); } messagesEqual(msg1, msg2) { return (msg1.uuid === msg2.uuid && msg1.timestamp === msg2.timestamp && msg1.costUSD === msg2.costUSD && msg1.type === msg2.type); } resolveConflict(existing, incoming, strategy) { switch (strategy) { case 'newest': return new Date(existing.timestamp || 0) > new Date(incoming.timestamp || 0) ? existing : incoming; case 'oldest': return new Date(existing.timestamp || 0) < new Date(incoming.timestamp || 0) ? existing : incoming; case 'cost': if (existing.costUSD && !incoming.costUSD) return existing; if (!existing.costUSD && incoming.costUSD) return incoming; return (existing.message && typeof existing.message === 'object') ? existing : incoming; case 'manual': logger.warn(`Conflict for message ${existing.uuid}, using existing version`); return existing; default: return existing; } } async createBackup(sourcePath, backupPath) { try { const messages = await this.parser.parseDirectory(sourcePath); const jsonlContent = messages.map((msg) => JSON.stringify(msg)).join('\n'); writeFileSync(backupPath, jsonlContent); logger.info(`Backup created at ${backupPath}`); } catch (error) { logger.error('Error creating backup:', error); throw error; } } async writeMergedData(messages, targetPath) { try { const fileGroups = new Map(); messages.forEach(msg => { const date = msg.timestamp ? new Date(msg.timestamp).toISOString().split('T')[0] : 'unknown'; const key = `claude-${date}.jsonl`; if (!fileGroups.has(key)) { fileGroups.set(key, []); } fileGroups.get(key).push(msg); }); fileGroups.forEach((msgs, filename) => { const filePath = join(targetPath, filename); const content = msgs.map(msg => JSON.stringify(msg)).join('\n'); writeFileSync(filePath, content); }); logger.info(`Merged data written to ${targetPath}`); } catch (error) { logger.error('Error writing merged data:', error); throw error; } } async compareWithRemote(localPath, remotePath) { try { const localMessages = await this.parser.parseDirectory(localPath); const remoteMessages = await this.parser.parseDirectory(remotePath); const localMap = new Map(localMessages.map((msg) => [msg.uuid, msg])); const remoteMap = new Map(remoteMessages.map((msg) => [msg.uuid, msg])); const localOnly = localMessages.filter((msg) => !remoteMap.has(msg.uuid)); const remoteOnly = remoteMessages.filter((msg) => !localMap.has(msg.uuid)); const conflicts = []; localMessages.forEach((msg) => { if (remoteMap.has(msg.uuid) && !this.messagesEqual(msg, remoteMap.get(msg.uuid))) { conflicts.push(msg); } }); console.log(chalk.bold('\nšŸ“Š Sync Comparison Report')); console.log('='.repeat(50)); console.log(`Local messages: ${localMessages.length}`); console.log(`Remote messages: ${remoteMessages.length}`); console.log(`Local only: ${localOnly.length}`); console.log(`Remote only: ${remoteOnly.length}`); console.log(`Conflicts: ${conflicts.length}`); if (localOnly.length > 0) { console.log(chalk.yellow('\nšŸ“¤ Messages only in local:')); localOnly.slice(0, 5).forEach(msg => { console.log(` - ${msg.timestamp} (${msg.costUSD ? `$${msg.costUSD.toFixed(2)}` : 'no cost'})`); }); if (localOnly.length > 5) { console.log(` ... and ${localOnly.length - 5} more`); } } if (remoteOnly.length > 0) { console.log(chalk.blue('\nšŸ“„ Messages only in remote:')); remoteOnly.slice(0, 5).forEach(msg => { console.log(` - ${msg.timestamp} (${msg.costUSD ? `$${msg.costUSD.toFixed(2)}` : 'no cost'})`); }); if (remoteOnly.length > 5) { console.log(` ... and ${remoteOnly.length - 5} more`); } } if (conflicts.length > 0) { console.log(chalk.red('\nāš ļø Conflicting messages:')); conflicts.slice(0, 5).forEach(msg => { console.log(` - ${msg.uuid} at ${msg.timestamp}`); }); if (conflicts.length > 5) { console.log(` ... and ${conflicts.length - 5} more`); } } } catch (error) { logger.error('Error comparing with remote:', error); throw error; } } formatSyncReport(report) { const lines = [ chalk.bold('šŸ”„ Sync Report'), '='.repeat(50), '', chalk.green(`āœ… Total messages after sync: ${report.totalMessages}`), chalk.blue(`šŸ“„ New messages imported: ${report.newMessages}`), chalk.yellow(`šŸ” Duplicate messages skipped: ${report.duplicates}`), ]; if (report.conflicts > 0) { lines.push(chalk.red(`āš ļø Conflicts resolved: ${report.conflicts}`)); } lines.push(chalk.cyan(`šŸ’° Total cost: $${report.totalCost.toFixed(2)}`)); lines.push(''); lines.push(chalk.bold('šŸ“± Synced Machines:')); report.machines.forEach(machine => { lines.push(` ${machine.machineName} (${machine.machineId})`); lines.push(` Last sync: ${new Date(machine.lastSync).toLocaleString()}`); lines.push(` Messages: ${machine.messagesCount}`); lines.push(` Cost: $${machine.totalCost.toFixed(2)}`); lines.push(''); }); return lines.join('\n'); } listAvailableSyncs() { try { const files = readdirSync(this.syncDir); return files.filter(f => f.startsWith('claude-sync-') && f.endsWith('.json')); } catch (error) { logger.error('Error listing sync files:', error); return []; } } } //# sourceMappingURL=sync-manager.js.map