@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
624 lines (617 loc) • 21.6 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 { 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/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
};