@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.
365 lines (364 loc) • 10.5 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 {
existsSync,
readFileSync,
writeFileSync,
unlinkSync,
watch,
appendFileSync
} from "fs";
import { join, extname, relative } from "path";
import { spawn } from "child_process";
import { loadConfig } from "./config.js";
import {
hookEmitter
} from "./events.js";
const state = {
running: false,
startTime: 0,
eventsProcessed: 0,
watchers: /* @__PURE__ */ new Map(),
pendingPrediction: false
};
let config;
let logStream = null;
function log(level, message, data) {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
const line = `[${timestamp}] [${level.toUpperCase()}] ${message}${data ? " " + JSON.stringify(data) : ""}`;
if (logStream) {
logStream(line);
}
const logLevels = ["debug", "info", "warn", "error"];
const configLevel = logLevels.indexOf(config?.daemon?.log_level || "info");
const msgLevel = logLevels.indexOf(level);
if (msgLevel >= configLevel) {
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
}
}
async function startDaemon(options = {}) {
config = loadConfig();
if (!config.daemon.enabled) {
log("warn", "Daemon is disabled in config");
return;
}
const pidFile = config.daemon.pid_file;
if (existsSync(pidFile)) {
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
try {
process.kill(pid, 0);
log("warn", "Daemon already running", { pid });
return;
} catch {
unlinkSync(pidFile);
}
}
if (!options.foreground) {
const child = spawn(
process.argv[0],
[...process.argv.slice(1), "--foreground"],
{
detached: true,
stdio: "ignore"
}
);
child.unref();
log("info", "Daemon started in background", { pid: child.pid });
return;
}
writeFileSync(pidFile, process.pid.toString());
state.running = true;
state.startTime = Date.now();
log("info", "Hook daemon starting", { pid: process.pid });
setupLogStream();
registerBuiltinHandlers();
startFileWatchers();
setupSignalHandlers();
hookEmitter.emitHook({
type: "session_start",
timestamp: Date.now(),
data: { pid: process.pid }
});
log("info", "Hook daemon ready", {
events: hookEmitter.getRegisteredEvents(),
watching: Array.from(state.watchers.keys())
});
await new Promise(() => {
});
}
function stopDaemon() {
const pidFile = config?.daemon?.pid_file || join(process.env.HOME || "/tmp", ".stackmemory", "hooks.pid");
if (!existsSync(pidFile)) {
log("info", "Daemon not running");
return;
}
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
try {
process.kill(pid, "SIGTERM");
log("info", "Daemon stopped", { pid });
} catch {
log("warn", "Could not stop daemon", { pid });
}
try {
unlinkSync(pidFile);
} catch {
}
}
function getDaemonStatus() {
config = loadConfig();
const pidFile = config.daemon.pid_file;
if (!existsSync(pidFile)) {
return { running: false };
}
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
try {
process.kill(pid, 0);
return {
running: true,
pid,
uptime: state.running ? Date.now() - state.startTime : void 0,
eventsProcessed: state.eventsProcessed
};
} catch {
return { running: false };
}
}
function setupLogStream() {
const logFile = config.daemon.log_file;
logStream = (msg) => {
try {
appendFileSync(logFile, msg + "\n");
} catch {
}
};
}
function registerBuiltinHandlers() {
hookEmitter.registerHandler("file_change", handleFileChange);
hookEmitter.registerHandler("suggestion_ready", handleSuggestionReady);
hookEmitter.registerHandler("error", handleError);
hookEmitter.on("*", () => {
state.eventsProcessed++;
});
}
async function handleFileChange(event) {
const fileEvent = event;
const hookConfig = config.hooks.file_change;
if (!hookConfig?.enabled) return;
log("debug", "File change detected", { path: fileEvent.data.path });
if (hookConfig.handler === "sweep-predict") {
await runSweepPrediction(fileEvent);
}
}
async function runSweepPrediction(event) {
const hookConfig = config.hooks.file_change;
if (!hookConfig) return;
if (state.pendingPrediction) {
log("debug", "Prediction already pending, skipping");
return;
}
if (state.lastPrediction) {
const cooldown = hookConfig.cooldown_ms || 1e4;
if (Date.now() - state.lastPrediction < cooldown) {
log("debug", "In cooldown period, skipping");
return;
}
}
state.pendingPrediction = true;
const debounce = hookConfig.debounce_ms || 2e3;
await new Promise((r) => setTimeout(r, debounce));
try {
const sweepScript = findSweepScript();
if (!sweepScript) {
log("warn", "Sweep script not found");
state.pendingPrediction = false;
return;
}
const filePath = event.data.path;
const content = event.data.content || (existsSync(filePath) ? readFileSync(filePath, "utf-8") : "");
const input = {
file_path: filePath,
current_content: content
};
const result = await runPythonScript(sweepScript, input);
if (result && result.success && result.predicted_content) {
state.lastPrediction = Date.now();
const suggestionEvent = {
type: "suggestion_ready",
timestamp: Date.now(),
data: {
suggestion: result.predicted_content,
source: "sweep",
confidence: result.confidence,
preview: result.predicted_content.split("\n").slice(0, 3).join("\n")
}
};
await hookEmitter.emitHook(suggestionEvent);
}
} catch (error) {
log("error", "Sweep prediction failed", {
error: error.message
});
} finally {
state.pendingPrediction = false;
}
}
function findSweepScript() {
const locations = [
join(process.env.HOME || "", ".stackmemory", "sweep", "sweep_predict.py"),
join(
process.cwd(),
"packages",
"sweep-addon",
"python",
"sweep_predict.py"
)
];
for (const loc of locations) {
if (existsSync(loc)) {
return loc;
}
}
return null;
}
async function runPythonScript(scriptPath, input) {
return new Promise((resolve) => {
const proc = spawn("python3", [scriptPath], {
stdio: ["pipe", "pipe", "pipe"]
});
let stdout = "";
proc.stdout.on("data", (data) => stdout += data);
proc.stderr.on("data", () => {
});
proc.on("close", () => {
try {
resolve(JSON.parse(stdout.trim()));
} catch {
resolve({ success: false });
}
});
proc.on("error", () => resolve({ success: false }));
proc.stdin.write(JSON.stringify(input));
proc.stdin.end();
});
}
function handleSuggestionReady(event) {
const suggestionEvent = event;
const hookConfig = config.hooks.suggestion_ready;
if (!hookConfig?.enabled) return;
const output = hookConfig.output || "overlay";
switch (output) {
case "overlay":
displayOverlay(suggestionEvent.data);
break;
case "notification":
displayNotification(suggestionEvent.data);
break;
case "log":
log("info", "Suggestion ready", suggestionEvent.data);
break;
}
}
function displayOverlay(data) {
const preview = data.preview || data.suggestion.slice(0, 200);
console.log("\n" + "\u2500".repeat(50));
console.log(`[${data.source}] Suggestion:`);
console.log(preview);
if (data.suggestion.length > 200) console.log("...");
console.log("\u2500".repeat(50) + "\n");
}
function displayNotification(data) {
const title = `StackMemory - ${data.source}`;
const message = data.preview || data.suggestion.slice(0, 100);
if (process.platform === "darwin") {
spawn("osascript", [
"-e",
`display notification "${message}" with title "${title}"`
]);
} else if (process.platform === "linux") {
spawn("notify-send", [title, message]);
}
}
function handleError(event) {
log("error", "Hook error", event.data);
}
function startFileWatchers() {
if (!config.file_watch.enabled) return;
const paths = config.file_watch.paths;
const ignore = new Set(config.file_watch.ignore);
const extensions = new Set(config.file_watch.extensions);
for (const watchPath of paths) {
const absPath = join(process.cwd(), watchPath);
if (!existsSync(absPath)) continue;
try {
const watcher = watch(
absPath,
{ recursive: true },
(eventType, filename) => {
if (!filename) return;
const relPath = relative(absPath, join(absPath, filename));
const parts = relPath.split("/");
if (parts.some((p) => ignore.has(p))) return;
const ext = extname(filename);
if (!extensions.has(ext)) return;
const fullPath = join(absPath, filename);
const changeType = eventType === "rename" ? existsSync(fullPath) ? "create" : "delete" : "modify";
const fileEvent = {
type: "file_change",
timestamp: Date.now(),
data: {
path: fullPath,
changeType,
content: changeType !== "delete" && existsSync(fullPath) ? readFileSync(fullPath, "utf-8") : void 0
}
};
hookEmitter.emitHook(fileEvent);
}
);
state.watchers.set(absPath, watcher);
log("debug", "Watching directory", { path: absPath });
} catch (error) {
log("warn", "Failed to watch directory", {
path: absPath,
error: error.message
});
}
}
}
function setupSignalHandlers() {
const cleanup = () => {
log("info", "Daemon shutting down");
state.running = false;
for (const [path, watcher] of state.watchers) {
watcher.close();
log("debug", "Stopped watching", { path });
}
hookEmitter.emitHook({
type: "session_end",
timestamp: Date.now(),
data: { uptime: Date.now() - state.startTime }
});
try {
unlinkSync(config.daemon.pid_file);
} catch {
}
process.exit(0);
};
process.on("SIGTERM", cleanup);
process.on("SIGINT", cleanup);
process.on("SIGHUP", cleanup);
}
export {
config,
getDaemonStatus,
log,
startDaemon,
state,
stopDaemon
};
//# sourceMappingURL=daemon.js.map