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

222 lines (221 loc) 5.62 kB
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 * as os from "os"; const CANONICAL_HOOKS = [ { scriptName: "session-rescue.sh", eventType: "Stop", timeout: 12, required: true }, { scriptName: "stop-checkpoint.js", eventType: "Stop", timeout: 5, commandPrefix: "node", required: true }, { scriptName: "chime-on-stop.sh", eventType: "Stop", timeout: 2, required: true }, { scriptName: "auto-checkpoint.js", eventType: "PostToolUse", timeout: 2, commandPrefix: "node", required: true }, { scriptName: "cord-trace.js", eventType: "PostToolUse", matcher: "mcp__.*__cord_(spawn|fork|complete|ask|tree)", timeout: 2, commandPrefix: "node", required: true }, { scriptName: "theory-capture.js", eventType: "PostToolUse", matcher: "Edit|Write|MultiEdit", timeout: 2, commandPrefix: "node", required: false }, { scriptName: "team-subagent-stop.js", eventType: "SubagentStop", timeout: 5, commandPrefix: "node", required: false }, { scriptName: "team-task-complete.js", eventType: "TaskCompleted", timeout: 5, commandPrefix: "node", required: false }, { scriptName: "team-teammate-idle.js", eventType: "TeammateIdle", timeout: 3, commandPrefix: "node", required: false }, { scriptName: "desire-path-trace.js", eventType: "PostToolUse", timeout: 2, commandPrefix: "node", required: false }, { scriptName: "daemon-auto-start.js", eventType: "PostToolUse", timeout: 2, commandPrefix: "node", required: false }, { scriptName: "wiki-update.js", eventType: "Stop", timeout: 10, commandPrefix: "node", required: false }, { scriptName: "doc-ingest.js", eventType: "PostToolUse", matcher: "WebFetch", timeout: 15, commandPrefix: "node", required: false } ]; const DEAD_HOOKS = ["sms-response-handler.js"]; function buildCommand(entry, hooksDir) { const scriptPath = path.join(hooksDir, entry.scriptName); if (entry.commandPrefix) { return `${entry.commandPrefix} ${scriptPath}`; } return scriptPath; } function hookExists(settings, entry) { const groups = settings.hooks?.[entry.eventType]; if (!groups) return false; for (const group of groups) { for (const hook of group.hooks) { if (hook.command.includes(entry.scriptName)) { return true; } } } return false; } function hasDeadHooks(settings) { if (!settings.hooks) return false; for (const groups of Object.values(settings.hooks)) { for (const group of groups) { for (const hook of group.hooks) { for (const dead of DEAD_HOOKS) { if (hook.command.includes(dead)) return true; } } } } return false; } function removeDeadHooks(settings) { if (!settings.hooks) return false; let removed = false; for (const eventType of Object.keys(settings.hooks)) { const groups = settings.hooks[eventType]; for (const group of groups) { const before = group.hooks.length; group.hooks = group.hooks.filter((hook) => { for (const dead of DEAD_HOOKS) { if (hook.command.includes(dead)) return false; } return true; }); if (group.hooks.length < before) removed = true; } settings.hooks[eventType] = groups.filter((g) => g.hooks.length > 0); if (settings.hooks[eventType].length === 0) { delete settings.hooks[eventType]; } } return removed; } function addHook(settings, entry, hooksDir) { if (!settings.hooks) settings.hooks = {}; const eventGroups = settings.hooks[entry.eventType] || []; const command = buildCommand(entry, hooksDir); const hookCmd = { type: "command", command }; if (entry.timeout) hookCmd.timeout = entry.timeout; const matcherValue = entry.matcher ?? void 0; const targetGroup = eventGroups.find((g) => { if (matcherValue) return g.matcher === matcherValue; return !g.matcher; }); if (targetGroup) { targetGroup.hooks.push(hookCmd); } else { const newGroup = { hooks: [hookCmd] }; if (matcherValue) newGroup.matcher = matcherValue; eventGroups.push(newGroup); } settings.hooks[entry.eventType] = eventGroups; } function mergeSettings(existing, hooksDir) { const merged = JSON.parse(JSON.stringify(existing)); removeDeadHooks(merged); for (const entry of CANONICAL_HOOKS) { if (!hookExists(merged, entry)) { addHook(merged, entry, hooksDir); } } return merged; } function getSettingsPath() { return path.join(os.homedir(), ".claude", "settings.json"); } function readSettings(settingsPath) { const p = settingsPath ?? getSettingsPath(); try { if (fs.existsSync(p)) { return JSON.parse(fs.readFileSync(p, "utf8")); } } catch { } return {}; } function writeSettingsAtomic(settings, settingsPath) { const p = settingsPath ?? getSettingsPath(); const dir = path.dirname(p); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const tmp = p + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); fs.renameSync(tmp, p); } export { CANONICAL_HOOKS, DEAD_HOOKS, buildCommand, getSettingsPath, hasDeadHooks, hookExists, mergeSettings, readSettings, removeDeadHooks, writeSettingsAtomic };