UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

342 lines (341 loc) 11.3 kB
#!/usr/bin/env node import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { existsSync, writeFileSync, unlinkSync, appendFileSync, readFileSync } from "fs"; import { isProcessAlive } from "../utils/process-cleanup.js"; import { loadDaemonConfig, getDaemonPaths, writeDaemonStatus } from "./daemon-config.js"; import { DaemonContextService } from "./services/context-service.js"; import { DaemonLinearService } from "./services/linear-service.js"; import { DaemonMaintenanceService } from "./services/maintenance-service.js"; import { DaemonMemoryService } from "./services/memory-service.js"; class UnifiedDaemon { config; paths; contextService; linearService; maintenanceService; memoryService; heartbeatInterval; isShuttingDown = false; startTime = 0; constructor(config) { this.paths = getDaemonPaths(); this.config = { ...loadDaemonConfig(), ...config }; this.contextService = new DaemonContextService( this.config.context, (level, msg, data) => this.log(level, "context", msg, data) ); this.linearService = new DaemonLinearService( this.config.linear, (level, msg, data) => this.log(level, "linear", msg, data) ); this.maintenanceService = new DaemonMaintenanceService( this.config.maintenance, (level, msg, data) => this.log(level, "maintenance", msg, data) ); this.memoryService = new DaemonMemoryService( this.config.memory, (level, msg, data) => this.log(level, "memory", msg, data) ); } log(level, service, message, data) { const logLevel = level.toUpperCase(); const levels = ["DEBUG", "INFO", "WARN", "ERROR"]; const configLevel = this.config.logLevel.toUpperCase(); if (levels.indexOf(logLevel) < levels.indexOf(configLevel)) { return; } const entry = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), level: logLevel, service, message, data }; const logLine = JSON.stringify(entry) + "\n"; try { appendFileSync(this.paths.logFile, logLine); } catch { console.error(`[${entry.timestamp}] ${level} [${service}]: ${message}`); } } checkIdempotency() { if (existsSync(this.paths.pidFile)) { try { const existingPid = readFileSync(this.paths.pidFile, "utf8").trim(); const pid = parseInt(existingPid, 10); if (isProcessAlive(pid)) { this.log("WARN", "daemon", "Daemon already running", { pid }); return false; } this.log("INFO", "daemon", "Cleaning stale PID file", { pid }); unlinkSync(this.paths.pidFile); } catch { try { unlinkSync(this.paths.pidFile); } catch { } } } return true; } writePidFile() { writeFileSync(this.paths.pidFile, process.pid.toString()); this.log("INFO", "daemon", "PID file created", { pid: process.pid, file: this.paths.pidFile }); } updateStatus() { const maintenanceState = this.maintenanceService.getState(); const memoryState = this.memoryService.getState(); const status = { running: true, pid: process.pid, startTime: this.startTime, uptime: Date.now() - this.startTime, services: { context: { enabled: this.config.context.enabled, lastRun: this.contextService.getState().lastSaveTime || void 0, saveCount: this.contextService.getState().saveCount }, linear: { enabled: this.config.linear.enabled, lastRun: this.linearService.getState().lastSyncTime || void 0, syncCount: this.linearService.getState().syncCount }, maintenance: { enabled: this.config.maintenance.enabled, lastRun: maintenanceState.lastRunTime || void 0, staleFramesCleaned: maintenanceState.staleFramesCleaned, ftsRebuilds: maintenanceState.ftsRebuilds, embeddingsGenerated: maintenanceState.embeddingsGenerated, embeddingsTotal: maintenanceState.embeddingsTotal, embeddingsRemaining: maintenanceState.embeddingsRemaining }, memory: { enabled: this.config.memory.enabled, lastTrigger: memoryState.lastTriggerTime || void 0, triggerCount: memoryState.triggerCount, currentRamPercent: memoryState.currentRamPercent }, fileWatch: { enabled: this.config.fileWatch.enabled } }, errors: [ ...this.contextService.getState().errors.slice(-5), ...this.linearService.getState().errors.slice(-5), ...maintenanceState.errors.slice(-5), ...memoryState.errors.slice(-5) ] }; writeDaemonStatus(status); } setupSignalHandlers() { const handleSignal = (signal) => { this.log("INFO", "daemon", `Received ${signal}, shutting down`); this.shutdown(signal.toLowerCase()); }; process.on("SIGTERM", () => handleSignal("SIGTERM")); process.on("SIGINT", () => handleSignal("SIGINT")); process.on("SIGHUP", () => handleSignal("SIGHUP")); process.on("uncaughtException", (err) => { this.log("ERROR", "daemon", "Uncaught exception", { error: err.message, stack: err.stack }); this.shutdown("uncaught_exception"); }); process.on("unhandledRejection", (reason) => { this.log("ERROR", "daemon", "Unhandled rejection", { reason: String(reason) }); }); } cleanup() { try { if (existsSync(this.paths.pidFile)) { unlinkSync(this.paths.pidFile); this.log("INFO", "daemon", "PID file removed"); } } catch (e) { this.log("WARN", "daemon", "Failed to remove PID file", { error: String(e) }); } const finalStatus = { running: false, startTime: this.startTime, uptime: Date.now() - this.startTime, services: { context: { enabled: false, saveCount: this.contextService.getState().saveCount }, linear: { enabled: false, syncCount: this.linearService.getState().syncCount }, maintenance: { enabled: false, staleFramesCleaned: this.maintenanceService.getState().staleFramesCleaned, ftsRebuilds: this.maintenanceService.getState().ftsRebuilds }, memory: { enabled: false, triggerCount: this.memoryService.getState().triggerCount }, fileWatch: { enabled: false } }, errors: [] }; writeDaemonStatus(finalStatus); } shutdown(reason) { if (this.isShuttingDown) return; this.isShuttingDown = true; this.log("INFO", "daemon", "Daemon shutting down", { reason, uptime: Date.now() - this.startTime, contextSaves: this.contextService.getState().saveCount, linearSyncs: this.linearService.getState().syncCount, maintenanceRuns: this.maintenanceService.getState().ftsRebuilds, memoryTriggers: this.memoryService.getState().triggerCount }); if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = void 0; } this.contextService.stop(); this.linearService.stop(); this.maintenanceService.stop(); this.memoryService.stop(); this.cleanup(); const exitCode = reason === "sigterm" || reason === "sigint" || reason === "sighup" ? 0 : 1; process.exit(exitCode); } async start() { if (!this.checkIdempotency()) { this.log("INFO", "daemon", "Exiting - daemon already running"); process.exit(0); } this.startTime = Date.now(); this.writePidFile(); this.setupSignalHandlers(); this.log("INFO", "daemon", "Unified daemon started", { pid: process.pid, config: { context: this.config.context.enabled, linear: this.config.linear.enabled, maintenance: this.config.maintenance.enabled, memory: this.config.memory.enabled, fileWatch: this.config.fileWatch.enabled } }); this.contextService.start(); await this.linearService.start(); this.maintenanceService.start(); this.memoryService.start(); this.heartbeatInterval = setInterval(() => { this.updateStatus(); }, this.config.heartbeatInterval * 1e3); this.updateStatus(); } getStatus() { const maintenanceState = this.maintenanceService.getState(); const memoryState = this.memoryService.getState(); return { running: !this.isShuttingDown, pid: process.pid, startTime: this.startTime, uptime: Date.now() - this.startTime, services: { context: { enabled: this.config.context.enabled, lastRun: this.contextService.getState().lastSaveTime || void 0, saveCount: this.contextService.getState().saveCount }, linear: { enabled: this.config.linear.enabled, lastRun: this.linearService.getState().lastSyncTime || void 0, syncCount: this.linearService.getState().syncCount }, maintenance: { enabled: this.config.maintenance.enabled, lastRun: maintenanceState.lastRunTime || void 0, staleFramesCleaned: maintenanceState.staleFramesCleaned, ftsRebuilds: maintenanceState.ftsRebuilds, embeddingsGenerated: maintenanceState.embeddingsGenerated, embeddingsTotal: maintenanceState.embeddingsTotal, embeddingsRemaining: maintenanceState.embeddingsRemaining }, memory: { enabled: this.config.memory.enabled, lastTrigger: memoryState.lastTriggerTime || void 0, triggerCount: memoryState.triggerCount, currentRamPercent: memoryState.currentRamPercent }, fileWatch: { enabled: this.config.fileWatch.enabled } }, errors: [ ...this.contextService.getState().errors, ...this.linearService.getState().errors, ...maintenanceState.errors, ...memoryState.errors ] }; } } if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("unified-daemon.js")) { const args = process.argv.slice(2); const config = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--save-interval" && args[i + 1]) { config.context = { enabled: true, interval: parseInt(args[i + 1], 10) }; i++; } else if (arg === "--linear-interval" && args[i + 1]) { config.linear = { enabled: true, interval: parseInt(args[i + 1], 10), retryAttempts: 3, retryDelay: 3e4 }; i++; } else if (arg === "--no-linear") { config.linear = { enabled: false, interval: 60, retryAttempts: 3, retryDelay: 3e4 }; } else if (arg === "--log-level" && args[i + 1]) { config.logLevel = args[i + 1]; i++; } } const daemon = new UnifiedDaemon(config); daemon.start().catch((err) => { console.error("Failed to start daemon:", err); process.exit(1); }); } export { UnifiedDaemon };