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.

625 lines (618 loc) 21.6 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { Command } from "commander"; import { execSync, execFileSync, spawn } from "child_process"; import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import Database from "better-sqlite3"; import { z } from "zod"; import { FrameManager } from "../../core/context/index.js"; import { LinearTaskManager } from "../../features/tasks/linear-task-manager.js"; import { logger } from "../../core/monitoring/logger.js"; import { EnhancedHandoffGenerator } from "../../core/session/enhanced-handoff.js"; const countTokens = (text) => Math.ceil(text.length / 3.5); const MAX_HANDOFF_VERSIONS = 10; function saveVersionedHandoff(projectRoot, branch, content) { const handoffsDir = join(projectRoot, ".stackmemory", "handoffs"); if (!existsSync(handoffsDir)) { mkdirSync(handoffsDir, { recursive: true }); } const now = /* @__PURE__ */ new Date(); const timestamp = now.toISOString().slice(0, 16).replace(/[T:]/g, "-"); const safeBranch = branch.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 30); const filename = `${timestamp}-${safeBranch}.md`; const versionedPath = join(handoffsDir, filename); writeFileSync(versionedPath, content); try { const files = readdirSync(handoffsDir).filter((f) => f.endsWith(".md")).sort().reverse(); for (const oldFile of files.slice(MAX_HANDOFF_VERSIONS)) { unlinkSync(join(handoffsDir, oldFile)); } } catch { } return versionedPath; } const CommitMessageSchema = z.string().min(1, "Commit message cannot be empty").max(200, "Commit message too long").regex( /^[a-zA-Z0-9\s\-_.,:()\/\[\]]+$/, "Commit message contains invalid characters" ).refine( (msg) => !msg.includes("\n"), "Commit message cannot contain newlines" ).refine( (msg) => !msg.includes('"'), "Commit message cannot contain double quotes" ).refine( (msg) => !msg.includes("`"), "Commit message cannot contain backticks" ); function createCaptureCommand() { const cmd = new Command("capture"); cmd.description("Commit current work and generate a handoff prompt").option("-m, --message <message>", "Custom commit message").option("--no-commit", "Skip git commit").option("--copy", "Copy the handoff prompt to clipboard").option("--basic", "Use basic handoff format instead of enhanced").option("--verbose", "Use verbose handoff format (full markdown)").option("--ultra", "Use ultra-compact pipe-delimited format").option( "--format <format>", "Output format: auto, ultra, compact, verbose (default: auto)" ).action(async (options) => { try { const projectRoot = process.cwd(); const dbPath = join(projectRoot, ".stackmemory", "context.db"); let gitStatus = ""; let hasChanges = false; try { gitStatus = execSync("git status --short", { encoding: "utf-8", cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"] }); hasChanges = gitStatus.trim().length > 0; } catch { } if (hasChanges && options.commit !== false) { try { const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8", cwd: projectRoot }).trim(); execSync("git add -A", { cwd: projectRoot }); let commitMessage = options.message || `chore: handoff checkpoint on ${currentBranch}`; try { commitMessage = CommitMessageSchema.parse(commitMessage); } catch (validationError) { console.error( "\u274C Invalid commit message:", validationError.message ); return; } execFileSync("git", ["commit", "-m", commitMessage], { cwd: projectRoot, stdio: "inherit" }); console.log(`\u2705 Committed changes: "${commitMessage}"`); console.log(` Branch: ${currentBranch}`); } catch (err) { console.error( "\u274C Failed to commit changes:", err.message ); } } else if (!hasChanges) { console.log("\u2139\uFE0F No changes to commit"); } let contextSummary = ""; let tasksSummary = ""; let recentWork = ""; if (existsSync(dbPath)) { const db = new Database(dbPath); const frameManager = new FrameManager(db, "cli-project"); const activeFrames = frameManager.getActiveFramePath(); if (activeFrames.length > 0) { contextSummary = "Active context frames:\n"; activeFrames.forEach((frame) => { contextSummary += ` - ${frame.name} [${frame.type}] `; }); } const taskStore = new LinearTaskManager(projectRoot, db); const activeTasks = taskStore.getActiveTasks(); const inProgress = activeTasks.filter( (t) => t.status === "in_progress" ); const todo = activeTasks.filter((t) => t.status === "pending"); const recentlyCompleted = activeTasks.filter((t) => t.status === "completed" && t.completed_at).sort( (a, b) => (b.completed_at || 0) - (a.completed_at || 0) ).slice(0, 3); if (inProgress.length > 0 || todo.length > 0) { tasksSummary = "\nTasks:\n"; if (inProgress.length > 0) { tasksSummary += "In Progress:\n"; inProgress.forEach((t) => { const externalId = t.external_refs?.linear?.id; tasksSummary += ` - ${t.title}${externalId ? ` [${externalId}]` : ""} `; }); } if (todo.length > 0) { tasksSummary += "TODO:\n"; todo.slice(0, 5).forEach((t) => { const externalId = t.external_refs?.linear?.id; tasksSummary += ` - ${t.title}${externalId ? ` [${externalId}]` : ""} `; }); if (todo.length > 5) { tasksSummary += ` ... and ${todo.length - 5} more `; } } } if (recentlyCompleted.length > 0) { recentWork = "\nRecently Completed:\n"; recentlyCompleted.forEach((t) => { recentWork += ` \u2713 ${t.title} `; }); } const recentEvents = db.prepare( ` SELECT event_type as type, payload as data, datetime(ts, 'unixepoch') as time FROM events ORDER BY ts DESC LIMIT 5 ` ).all(); if (recentEvents.length > 0) { recentWork += "\nRecent Activity:\n"; recentEvents.forEach((event) => { const data = JSON.parse(event.data); recentWork += ` - ${event.type}: ${data.message || data.name || "activity"} `; }); } db.close(); } let gitInfo = ""; try { const branch2 = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8", cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"] }).trim(); const lastCommit = execSync("git log -1 --oneline", { encoding: "utf-8", cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"] }).trim(); gitInfo = ` Git Status: Branch: ${branch2} Last commit: ${lastCommit} `; } catch { } let notes = ""; const notesPath = join(projectRoot, ".stackmemory", "handoff.md"); if (existsSync(notesPath)) { const handoffNotes = readFileSync(notesPath, "utf-8"); if (handoffNotes.trim()) { notes = ` Notes from previous handoff: ${handoffNotes} `; } } let handoffPrompt; if (options.basic) { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); handoffPrompt = `# Session Handoff - ${timestamp} ## Project: ${projectRoot.split("/").pop()} ${gitInfo} ${contextSummary} ${tasksSummary} ${recentWork} ${notes} ## Continue from here: 1. Run \`stackmemory status\` to check the current state 2. Review any in-progress tasks above 3. Check for any uncommitted changes with \`git status\` 4. Resume work on the active context ## Quick Commands: - \`stackmemory context load --recent\` - Load recent context - \`stackmemory task list --state in_progress\` - Show in-progress tasks - \`stackmemory linear sync\` - Sync with Linear if configured - \`stackmemory log recent\` - View recent activity --- Generated by stackmemory capture at ${timestamp} `; } else { const enhancedGenerator = new EnhancedHandoffGenerator(projectRoot); const enhancedHandoff = await enhancedGenerator.generate(); let format = "auto"; if (options.verbose) { format = "verbose"; } else if (options.ultra) { format = "ultra"; } else if (options.format) { format = options.format; } if (format === "auto") { const selectedFormat = enhancedGenerator.selectFormat(enhancedHandoff); handoffPrompt = enhancedGenerator.toAutoFormat(enhancedHandoff); const actualTokens = countTokens(handoffPrompt); console.log( `Estimated tokens: ~${actualTokens} (${selectedFormat}, auto-selected)` ); } else if (format === "ultra") { handoffPrompt = enhancedGenerator.toUltraCompact(enhancedHandoff); const actualTokens = countTokens(handoffPrompt); console.log(`Estimated tokens: ~${actualTokens} (ultra-compact)`); } else if (format === "verbose") { handoffPrompt = enhancedGenerator.toMarkdown(enhancedHandoff); const actualTokens = countTokens(handoffPrompt); console.log(`Estimated tokens: ~${actualTokens} (verbose)`); } else { handoffPrompt = enhancedGenerator.toCompact(enhancedHandoff); const actualTokens = countTokens(handoffPrompt); console.log(`Estimated tokens: ~${actualTokens} (compact)`); } } const stackmemoryDir = join(projectRoot, ".stackmemory"); if (!existsSync(stackmemoryDir)) { mkdirSync(stackmemoryDir, { recursive: true }); } const handoffPath = join(stackmemoryDir, "last-handoff.md"); writeFileSync(handoffPath, handoffPrompt); let branch = "unknown"; try { branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8", cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"] }).trim(); } catch { } const versionedPath = saveVersionedHandoff( projectRoot, branch, handoffPrompt ); console.log( `Versioned: ${versionedPath.split("/").slice(-2).join("/")}` ); console.log("\n" + "=".repeat(60)); console.log(handoffPrompt); console.log("=".repeat(60)); if (options.copy) { try { if (process.platform === "darwin") { execFileSync("pbcopy", [], { input: handoffPrompt, cwd: projectRoot }); } else if (process.platform === "win32") { execFileSync("clip", [], { input: handoffPrompt, cwd: projectRoot }); } else { execFileSync("xclip", ["-selection", "clipboard"], { input: handoffPrompt, cwd: projectRoot }); } console.log("\n\u2705 Handoff prompt copied to clipboard!"); } catch { console.log("\n\u26A0\uFE0F Could not copy to clipboard"); } } console.log(` \u{1F4BE} Handoff saved to: ${handoffPath}`); console.log("\u{1F4CB} Use this prompt when starting your next session"); } catch (error) { logger.error("Capture command failed", error); console.error("\u274C Capture failed:", error.message); process.exit(1); } }); return cmd; } function createRestoreCommand() { const cmd = new Command("restore"); cmd.description("Restore context from last handoff").option("--no-copy", "Do not copy prompt to clipboard").action(async (options) => { try { const projectRoot = process.cwd(); const handoffPath = join( projectRoot, ".stackmemory", "last-handoff.md" ); const metaPath = join( process.env["HOME"] || "~", ".stackmemory", "handoffs", "last-handoff-meta.json" ); if (!existsSync(handoffPath)) { console.log("\u274C No handoff found in this project"); console.log('\u{1F4A1} Run "stackmemory capture" to create one'); return; } const handoffPrompt = readFileSync(handoffPath, "utf-8"); console.log("\n" + "=".repeat(60)); console.log("\u{1F4CB} RESTORED HANDOFF"); console.log("=".repeat(60)); console.log(handoffPrompt); console.log("=".repeat(60)); if (existsSync(metaPath)) { const metadata = JSON.parse(readFileSync(metaPath, "utf-8")); console.log("\n\u{1F4CA} Session Metadata:"); console.log(` Timestamp: ${metadata.timestamp}`); console.log(` Reason: ${metadata.reason}`); console.log(` Duration: ${metadata.session_duration}s`); console.log(` Command: ${metadata.command}`); } try { const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); if (gitStatus) { console.log("\n Current uncommitted changes:"); console.log(gitStatus); } } catch { } if (options.copy !== false) { try { if (process.platform === "darwin") { execFileSync("pbcopy", [], { input: handoffPrompt, cwd: projectRoot }); } else if (process.platform === "win32") { execFileSync("clip", [], { input: handoffPrompt, cwd: projectRoot }); } else { execFileSync("xclip", ["-selection", "clipboard"], { input: handoffPrompt, cwd: projectRoot }); } console.log("\n\u2705 Handoff prompt copied to clipboard!"); } catch { console.log("\n\u26A0\uFE0F Could not copy to clipboard"); } } console.log("\n\u{1F680} Ready to continue where you left off!"); } catch (error) { logger.error("Restore failed", error); console.error("\u274C Restore failed:", error.message); process.exit(1); } }); return cmd; } async function captureHandoff(reason, exitCode, wrappedCommand, sessionStart, quiet) { const projectRoot = process.cwd(); const handoffDir = join(homedir(), ".stackmemory", "handoffs"); const logFile = join(handoffDir, "auto-handoff.log"); if (!existsSync(handoffDir)) { mkdirSync(handoffDir, { recursive: true }); } const logMessage = (msg) => { const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", " "); const logLine = `[${timestamp}] ${msg} `; try { writeFileSync(logFile, logLine, { flag: "a" }); } catch { } }; if (!quiet) { console.log("\nCapturing handoff context..."); } logMessage(`Capturing handoff: reason=${reason}, exit_code=${exitCode}`); try { execFileSync( process.execPath, [process.argv[1], "capture", "--no-commit"], { cwd: projectRoot, stdio: quiet ? "pipe" : "inherit" } ); const metadata = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), reason, exit_code: exitCode, command: wrappedCommand, pid: process.pid, cwd: projectRoot, user: process.env["USER"] || "unknown", session_duration: Math.floor((Date.now() - sessionStart) / 1e3) }; const metadataPath = join(handoffDir, "last-handoff-meta.json"); writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); if (!quiet) { console.log("Handoff captured successfully"); logMessage(`Handoff captured: ${metadataPath}`); console.log("\nSession Summary:"); console.log(` Duration: ${metadata.session_duration} seconds`); console.log(` Exit reason: ${reason}`); try { const gitStatus = execSync("git status --short", { encoding: "utf-8", cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"] }).trim(); if (gitStatus) { console.log("\nYou have uncommitted changes"); console.log(' Run "git status" to review'); } } catch { } console.log('\nRun "stackmemory restore" in your next session'); } } catch (err) { if (!quiet) { console.error("Failed to capture handoff:", err.message); } logMessage(`ERROR: ${err.message}`); } } function createAutoCaptureCommand() { const cmd = new Command("auto-capture"); cmd.description("Wrap a command with automatic handoff capture on termination").option("-a, --auto", "Auto-capture on normal exit (no prompt)").option("-q, --quiet", "Suppress output").option("-t, --tag <tag>", "Tag this session").argument("[command...]", "Command to wrap with auto-handoff").action(async (commandArgs, options) => { const autoCapture = options.auto || false; const quiet = options.quiet || false; const tag = options.tag || ""; if (!commandArgs || commandArgs.length === 0) { console.log("StackMemory Auto-Handoff"); console.log("-".repeat(50)); console.log(""); console.log( "Wraps a command with automatic handoff capture on termination." ); console.log(""); console.log("Usage:"); console.log(" stackmemory auto-capture [options] <command> [args...]"); console.log(""); console.log("Examples:"); console.log(" stackmemory auto-capture claude"); console.log(" stackmemory auto-capture -a npm run dev"); console.log(' stackmemory auto-capture -t "feature-work" vim'); console.log(""); console.log("Options:"); console.log( " -a, --auto Auto-capture on normal exit (no prompt)" ); console.log(" -q, --quiet Suppress output"); console.log(" -t, --tag <tag> Tag this session"); return; } const wrappedCommand = commandArgs.join(" "); const sessionStart = Date.now(); let capturedAlready = false; if (!quiet) { console.log("StackMemory Auto-Handoff Wrapper"); console.log(`Wrapping: ${wrappedCommand}`); if (tag) { console.log(`Tag: ${tag}`); } console.log("Handoff will be captured on termination"); console.log(""); } const [cmd2, ...args] = commandArgs; let childProcess; try { childProcess = spawn(cmd2, args, { stdio: "inherit", shell: false, cwd: process.cwd(), env: process.env }); } catch (err) { console.error(`Failed to start command: ${err.message}`); process.exit(1); return; } const handleSignal = async (signal, exitCode) => { if (capturedAlready) return; capturedAlready = true; if (!quiet) { console.log(` Received ${signal}`); } if (childProcess.pid && !childProcess.killed) { childProcess.kill(signal); } await captureHandoff( signal, exitCode, wrappedCommand, sessionStart, quiet ); process.exit(exitCode); }; process.on("SIGINT", () => handleSignal("SIGINT", 130)); process.on("SIGTERM", () => handleSignal("SIGTERM", 143)); process.on("SIGHUP", () => handleSignal("SIGHUP", 129)); childProcess.on("exit", async (code, signal) => { if (capturedAlready) return; capturedAlready = true; const exitCode = code ?? (signal ? 128 : 0); if (signal) { await captureHandoff( signal, exitCode, wrappedCommand, sessionStart, quiet ); } else if (exitCode !== 0) { if (!quiet) { console.log(` Command exited with code: ${exitCode}`); } await captureHandoff( "unexpected_exit", exitCode, wrappedCommand, sessionStart, quiet ); } else if (autoCapture) { await captureHandoff( "normal_exit", 0, wrappedCommand, sessionStart, quiet ); } else { if (process.stdin.isTTY) { console.log( "\nSession ending. Use -a flag for auto-capture on normal exit." ); } } process.exit(exitCode); }); childProcess.on("error", async (err) => { if (capturedAlready) return; capturedAlready = true; console.error(`Command error: ${err.message}`); await captureHandoff( "spawn_error", 1, wrappedCommand, sessionStart, quiet ); process.exit(1); }); }); return cmd; } function createHandoffCommand() { const cmd = new Command("handoff"); cmd.description('(deprecated) Use "capture" or "restore" instead'); return cmd; } export { createAutoCaptureCommand, createCaptureCommand, createHandoffCommand, createRestoreCommand }; //# sourceMappingURL=handoff.js.map