UNPKG

squabble-mcp

Version:

Engineer-driven development with critical-thinking PM collaboration - MCP server for Claude

336 lines 12.2 kB
import fs from 'fs'; import path from 'path'; import { createReadStream, createWriteStream } from 'fs'; import * as readline from 'readline'; /** * Non-blocking activity logger for persistent storage * Writes events to JSONL file with streaming writes */ export class ActivityLogger { workspaceRoot; writeStream; humanWriteStream; activityFile; humanReadableFile; isInitialized = false; // Log rotation configuration MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB MAX_SESSIONS_TO_KEEP = 5; constructor(workspaceRoot) { this.workspaceRoot = workspaceRoot; this.activityFile = path.join(workspaceRoot, 'pm-activity.jsonl'); this.humanReadableFile = path.join(workspaceRoot, 'pm-activity.log'); } /** * Initialize write streams */ async initialize() { if (this.isInitialized) return; // Check if log rotation is needed before opening streams await this.rotateLogsIfNeeded(); // Create write stream for JSONL file this.writeStream = fs.createWriteStream(this.activityFile, { flags: 'a', // Append mode encoding: 'utf8', highWaterMark: 16384 // 16KB buffer }); // Handle stream errors this.writeStream.on('error', (error) => { console.error('Activity logger write error:', error); }); // Create write stream for human-readable file this.humanWriteStream = fs.createWriteStream(this.humanReadableFile, { flags: 'a', encoding: 'utf8', highWaterMark: 16384 }); this.humanWriteStream.on('error', (error) => { console.error('Activity logger human write error:', error); }); this.isInitialized = true; } /** * Log an event asynchronously */ async logEvent(event) { if (!this.isInitialized) { await this.initialize(); } // Write to JSONL file const line = JSON.stringify(event) + '\n'; // Also log in human-readable format this.logEventHumanReadable(event).catch(err => { // Don't fail if human-readable logging fails console.error('Human-readable logging failed:', err); }); return new Promise((resolve, reject) => { if (!this.writeStream) { reject(new Error('Write stream not initialized')); return; } const canWrite = this.writeStream.write(line, (error) => { if (error) { reject(error); } else { resolve(); } }); // If buffer is full, wait for drain if (!canWrite) { this.writeStream.once('drain', () => resolve()); } }); } /** * Log human-readable format (async, best-effort) */ async logHumanReadable(message) { const timestamp = new Date().toISOString(); const line = `[${timestamp}] ${message}\n`; try { await fs.promises.appendFile(this.humanReadableFile, line); } catch (error) { // Best effort - don't fail on human-readable log errors console.error('Failed to write human-readable log:', error); } } /** * Format and log event in human-readable form */ async logEventHumanReadable(event) { let message = ''; switch (event.type) { case 'session_start': message = `🚀 PM Session Started (ID: ${event.sessionId})`; await this.logHumanReadable('='.repeat(80)); await this.logHumanReadable(message); await this.logHumanReadable('='.repeat(80)); return; case 'session_end': message = `✅ PM Session Completed`; await this.logHumanReadable(message); await this.logHumanReadable('='.repeat(80) + '\n'); return; case 'tool_use': message = `🔧 ${event.message || event.tool}`; break; case 'tool_result': message = ` └─ ${event.message || event.result}`; break; case 'pm_message': message = `💬 PM: ${event.message || ''}`; break; case 'error': message = `❌ Error: ${event.message}`; break; default: message = event.message || JSON.stringify(event); } await this.logHumanReadable(message); } /** * Flush any pending writes */ async flush() { if (!this.writeStream) return; return new Promise((resolve) => { this.writeStream.end(() => resolve()); }); } /** * Close the logger */ async close() { if (this.writeStream) { await this.flush(); this.writeStream = undefined; } this.isInitialized = false; } /** * Read recent events from file (for initial load) */ async readRecentEvents(limit = 100) { try { const content = await fs.promises.readFile(this.activityFile, 'utf-8'); const lines = content.split('\n').filter(line => line.trim()); const events = []; // Read from end for most recent for (let i = lines.length - 1; i >= 0 && events.length < limit; i--) { try { const event = JSON.parse(lines[i]); events.unshift(event); // Add to beginning to maintain order } catch (e) { // Skip invalid JSON lines } } return events; } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist yet return []; } throw error; } } /** * Check if log rotation is needed based on file size */ async rotateLogsIfNeeded() { try { const stats = await fs.promises.stat(this.activityFile); if (stats.size > this.MAX_FILE_SIZE) { console.log(`[ActivityLogger] Log file exceeds ${this.MAX_FILE_SIZE / 1024 / 1024}MB, rotating...`); await this.rotateLogs(); } } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist yet, no rotation needed return; } console.error('[ActivityLogger] Error checking log file size:', error); } } /** * Rotate logs by keeping only the most recent sessions */ async rotateLogs() { const tempJsonlFile = this.activityFile + '.tmp'; const tempLogFile = this.humanReadableFile + '.tmp'; try { // Parse sessions from JSONL file const sessions = await this.parseSessionsFromFile(); // Keep only the most recent sessions const sessionsToKeep = sessions.slice(-this.MAX_SESSIONS_TO_KEEP); if (sessionsToKeep.length === 0) { // No sessions to keep, just truncate files await fs.promises.writeFile(this.activityFile, ''); await fs.promises.writeFile(this.humanReadableFile, ''); console.log('[ActivityLogger] Log files truncated - no sessions found'); return; } // Write retained sessions to temp files await this.writeSessionsToFile(sessionsToKeep, tempJsonlFile, tempLogFile); // Atomically replace original files await fs.promises.rename(tempJsonlFile, this.activityFile); await fs.promises.rename(tempLogFile, this.humanReadableFile); console.log(`[ActivityLogger] Log rotation complete. Kept ${sessionsToKeep.length} sessions`); } catch (error) { // Clean up temp files on error try { await fs.promises.unlink(tempJsonlFile).catch(() => { }); await fs.promises.unlink(tempLogFile).catch(() => { }); } catch { } console.error('[ActivityLogger] Log rotation failed:', error); throw error; } } /** * Parse sessions from the JSONL file */ async parseSessionsFromFile() { const sessions = new Map(); const sessionOrder = []; const fileStream = createReadStream(this.activityFile); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (!line.trim()) continue; try { const event = JSON.parse(line); const sessionId = event.sessionId || 'unknown'; if (!sessions.has(sessionId)) { sessions.set(sessionId, []); sessionOrder.push(sessionId); } sessions.get(sessionId).push(event); } catch (e) { // Skip invalid JSON lines } } // Return sessions in order they were first seen return sessionOrder.map(sessionId => ({ sessionId, events: sessions.get(sessionId) })); } /** * Write sessions to new files */ async writeSessionsToFile(sessions, jsonlPath, logPath) { const jsonlStream = createWriteStream(jsonlPath); const logStream = createWriteStream(logPath); try { for (const session of sessions) { for (const event of session.events) { // Write to JSONL jsonlStream.write(JSON.stringify(event) + '\n'); // Write to human-readable log const humanReadable = await this.formatEventForHumanLog(event); if (humanReadable) { logStream.write(humanReadable); } } } // Wait for streams to finish await new Promise((resolve, reject) => { jsonlStream.end((err) => err ? reject(err) : resolve()); }); await new Promise((resolve, reject) => { logStream.end((err) => err ? reject(err) : resolve()); }); } catch (error) { jsonlStream.destroy(); logStream.destroy(); throw error; } } /** * Format an event for the human-readable log */ async formatEventForHumanLog(event) { const timestamp = event.timestamp || new Date().toISOString(); let message = ''; switch (event.type) { case 'session_start': message = `[${timestamp}] ${'='.repeat(80)}\n`; message += `[${timestamp}] 🚀 PM Session Started (ID: ${event.sessionId})\n`; message += `[${timestamp}] ${'='.repeat(80)}\n`; break; case 'session_end': message = `[${timestamp}] ✅ PM Session Completed\n`; message += `[${timestamp}] ${'='.repeat(80)}\n\n`; break; case 'tool_use': message = `[${timestamp}] 🔧 ${event.message || event.tool}\n`; break; case 'tool_result': message = `[${timestamp}] └─ ${event.message || event.result}\n`; break; case 'pm_message': message = `[${timestamp}] 💬 PM: ${event.message || ''}\n`; break; case 'error': message = `[${timestamp}] ❌ Error: ${event.message}\n`; break; default: message = `[${timestamp}] ${event.message || JSON.stringify(event)}\n`; } return message; } } //# sourceMappingURL=activity-logger.js.map