UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

313 lines (312 loc) 9.79 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 * as fs from "fs"; import * as path from "path"; import { execSync } from "child_process"; class SessionDaemon { config; state; stackmemoryDir; sessionsDir; logsDir; pidFile; heartbeatFile; logFile; saveInterval = null; heartbeatInterval = null; activityCheckInterval = null; isShuttingDown = false; constructor(sessionId2, options2) { const homeDir = process.env["HOME"] || process.env["USERPROFILE"] || ""; this.stackmemoryDir = path.join(homeDir, ".stackmemory"); this.sessionsDir = path.join(this.stackmemoryDir, "sessions"); this.logsDir = path.join(this.stackmemoryDir, "logs"); this.config = { sessionId: sessionId2, saveIntervalMs: options2?.saveIntervalMs ?? 15 * 60 * 1e3, inactivityTimeoutMs: options2?.inactivityTimeoutMs ?? 30 * 60 * 1e3, heartbeatIntervalMs: options2?.heartbeatIntervalMs ?? 60 * 1e3 }; this.pidFile = path.join(this.sessionsDir, `${sessionId2}.pid`); this.heartbeatFile = path.join(this.sessionsDir, `${sessionId2}.heartbeat`); this.logFile = path.join(this.logsDir, "daemon.log"); this.state = { startTime: Date.now(), lastSaveTime: Date.now(), lastActivityTime: Date.now(), saveCount: 0, errors: [] }; this.ensureDirectories(); } ensureDirectories() { [this.sessionsDir, this.logsDir].forEach((dir) => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); } log(level, message, data) { const entry = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), level, sessionId: this.config.sessionId, message, data }; const logLine = JSON.stringify(entry) + "\n"; try { fs.appendFileSync(this.logFile, logLine); } catch { console.error(`[${entry.timestamp}] ${level}: ${message}`, data); } } checkIdempotency() { if (fs.existsSync(this.pidFile)) { try { const existingPid = fs.readFileSync(this.pidFile, "utf8").trim(); const pid = parseInt(existingPid, 10); try { process.kill(pid, 0); this.log("WARN", "Daemon already running for this session", { existingPid: pid }); return false; } catch { this.log("INFO", "Cleaning up stale PID file", { stalePid: pid }); fs.unlinkSync(this.pidFile); } } catch { try { fs.unlinkSync(this.pidFile); } catch { } } } return true; } writePidFile() { fs.writeFileSync(this.pidFile, process.pid.toString()); this.log("INFO", "PID file created", { pid: process.pid, file: this.pidFile }); } updateHeartbeat() { const heartbeatData = { pid: process.pid, sessionId: this.config.sessionId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), uptime: Date.now() - this.state.startTime, saveCount: this.state.saveCount, lastSaveTime: new Date(this.state.lastSaveTime).toISOString() }; try { fs.writeFileSync( this.heartbeatFile, JSON.stringify(heartbeatData, null, 2) ); } catch (err) { this.log("ERROR", "Failed to update heartbeat file", { error: String(err) }); } } saveContext() { if (this.isShuttingDown) return; try { const stackmemoryBin = path.join( this.stackmemoryDir, "bin", "stackmemory" ); if (!fs.existsSync(stackmemoryBin)) { this.log("WARN", "StackMemory binary not found", { path: stackmemoryBin }); return; } const message = `Auto-checkpoint #${this.state.saveCount + 1} at ${(/* @__PURE__ */ new Date()).toISOString()}`; execSync(`"${stackmemoryBin}" context add observation "${message}"`, { timeout: 3e4, encoding: "utf8", stdio: "pipe" }); this.state.saveCount++; this.state.lastSaveTime = Date.now(); this.log("INFO", "Context saved successfully", { saveCount: this.state.saveCount, intervalMs: this.config.saveIntervalMs }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); if (!errorMsg.includes("EBUSY") && !errorMsg.includes("EAGAIN")) { this.state.errors.push(errorMsg); this.log("WARN", "Failed to save context", { error: errorMsg }); } if (this.state.errors.length > 50) { this.log("ERROR", "Too many errors, initiating shutdown"); this.shutdown("too_many_errors"); } } } checkActivity() { if (this.isShuttingDown) return; const sessionFile = path.join( this.stackmemoryDir, "traces", "current-session.json" ); try { if (fs.existsSync(sessionFile)) { const stats = fs.statSync(sessionFile); const lastModified = stats.mtimeMs; if (lastModified > this.state.lastActivityTime) { this.state.lastActivityTime = lastModified; this.log("DEBUG", "Activity detected", { lastModified: new Date(lastModified).toISOString() }); } } } catch { } const inactiveTime = Date.now() - this.state.lastActivityTime; if (inactiveTime > this.config.inactivityTimeoutMs) { this.log("INFO", "Inactivity timeout reached", { inactiveTimeMs: inactiveTime, timeoutMs: this.config.inactivityTimeoutMs }); this.shutdown("inactivity_timeout"); } } setupSignalHandlers() { const handleSignal = (signal) => { this.log("INFO", `Received ${signal}, shutting down gracefully`); 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", "Uncaught exception", { error: err.message, stack: err.stack }); this.shutdown("uncaught_exception"); }); process.on("unhandledRejection", (reason) => { this.log("ERROR", "Unhandled rejection", { reason: String(reason) }); }); } cleanup() { try { if (fs.existsSync(this.pidFile)) { fs.unlinkSync(this.pidFile); this.log("INFO", "PID file removed"); } } catch (e) { this.log("WARN", "Failed to remove PID file", { error: String(e) }); } try { const finalHeartbeat = { pid: process.pid, sessionId: this.config.sessionId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), status: "shutdown", uptime: Date.now() - this.state.startTime, totalSaves: this.state.saveCount }; fs.writeFileSync( this.heartbeatFile, JSON.stringify(finalHeartbeat, null, 2) ); } catch { } } shutdown(reason) { if (this.isShuttingDown) return; this.isShuttingDown = true; this.log("INFO", "Daemon shutting down", { reason, uptime: Date.now() - this.state.startTime, totalSaves: this.state.saveCount, errors: this.state.errors.length }); if (this.saveInterval) { clearInterval(this.saveInterval); this.saveInterval = null; } if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } if (this.activityCheckInterval) { clearInterval(this.activityCheckInterval); this.activityCheckInterval = null; } try { this.saveContext(); } catch { } this.cleanup(); process.exit( reason === "inactivity_timeout" || reason === "sigterm" ? 0 : 1 ); } start() { if (!this.checkIdempotency()) { this.log("INFO", "Exiting - daemon already running"); process.exit(0); } this.writePidFile(); this.setupSignalHandlers(); this.log("INFO", "Session daemon started", { sessionId: this.config.sessionId, pid: process.pid, saveIntervalMs: this.config.saveIntervalMs, inactivityTimeoutMs: this.config.inactivityTimeoutMs }); this.updateHeartbeat(); this.heartbeatInterval = setInterval(() => { this.updateHeartbeat(); }, this.config.heartbeatIntervalMs); this.saveInterval = setInterval(() => { this.saveContext(); }, this.config.saveIntervalMs); this.activityCheckInterval = setInterval(() => { this.checkActivity(); }, 60 * 1e3); this.saveContext(); } } function parseArgs() { const args = process.argv.slice(2); let sessionId2 = `session-${Date.now()}`; const options2 = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--session-id" && args[i + 1]) { sessionId2 = args[i + 1]; i++; } else if (arg === "--save-interval" && args[i + 1]) { options2.saveIntervalMs = parseInt(args[i + 1], 10) * 1e3; i++; } else if (arg === "--inactivity-timeout" && args[i + 1]) { options2.inactivityTimeoutMs = parseInt(args[i + 1], 10) * 1e3; i++; } else if (arg === "--heartbeat-interval" && args[i + 1]) { options2.heartbeatIntervalMs = parseInt(args[i + 1], 10) * 1e3; i++; } else if (!arg.startsWith("--")) { sessionId2 = arg; } } return { sessionId: sessionId2, options: options2 }; } const { sessionId, options } = parseArgs(); const daemon = new SessionDaemon(sessionId, options); daemon.start(); //# sourceMappingURL=session-daemon.js.map