@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
JavaScript
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