@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
266 lines (265 loc) • 9.57 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 {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
readdirSync
} from "fs";
import { join, basename } from "path";
import { homedir } from "os";
import { createHash } from "crypto";
function getDecisionStorePath(projectRoot) {
return join(projectRoot, ".stackmemory", "session-decisions.json");
}
function getProjectId(projectRoot) {
const hash = createHash("sha256").update(projectRoot).digest("hex");
return hash.slice(0, 12);
}
function getHistoryDir() {
return join(homedir(), ".stackmemory", "decision-history");
}
function archiveDecisions(projectRoot, decisions) {
if (decisions.length === 0) return;
const historyDir = getHistoryDir();
const projectId = getProjectId(projectRoot);
const projectDir = join(historyDir, projectId);
if (!existsSync(projectDir)) {
mkdirSync(projectDir, { recursive: true });
}
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
const archivePath = join(projectDir, `${timestamp}.json`);
const archive = {
projectRoot,
projectName: basename(projectRoot),
archivedAt: (/* @__PURE__ */ new Date()).toISOString(),
decisions
};
writeFileSync(archivePath, JSON.stringify(archive, null, 2));
}
function loadDecisionHistory(projectRoot) {
const historyDir = getHistoryDir();
if (!existsSync(historyDir)) return [];
const allDecisions = [];
try {
const projectDirs = projectRoot ? [getProjectId(projectRoot)] : readdirSync(historyDir);
for (const projectId of projectDirs) {
const projectDir = join(historyDir, projectId);
if (!existsSync(projectDir)) continue;
try {
const files = readdirSync(projectDir).filter(
(f) => f.endsWith(".json")
);
for (const file of files) {
try {
const content = JSON.parse(
readFileSync(join(projectDir, file), "utf-8")
);
for (const d of content.decisions || []) {
allDecisions.push({
...d,
projectName: content.projectName || "unknown",
archivedAt: content.archivedAt
});
}
} catch {
}
}
} catch {
}
}
} catch {
}
return allDecisions.sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
}
function loadDecisions(projectRoot) {
const storePath = getDecisionStorePath(projectRoot);
if (existsSync(storePath)) {
try {
return JSON.parse(readFileSync(storePath, "utf-8"));
} catch {
}
}
return {
decisions: [],
sessionStart: (/* @__PURE__ */ new Date()).toISOString()
};
}
function saveDecisions(projectRoot, store) {
const storePath = getDecisionStorePath(projectRoot);
const dir = join(projectRoot, ".stackmemory");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(storePath, JSON.stringify(store, null, 2));
}
function createDecisionCommand() {
const cmd = new Command("decision");
cmd.description("Capture key decisions for session handoff context");
cmd.command("add <what>").description("Record a decision made during this session").option("-w, --why <rationale>", "Why this decision was made").option(
"-a, --alternatives <alts>",
"Comma-separated alternatives considered"
).option(
"-c, --category <category>",
"Category (architecture, tooling, approach, etc.)"
).action((what, options) => {
const projectRoot = process.cwd();
const store = loadDecisions(projectRoot);
const decision = {
id: `d-${Date.now()}`,
what,
why: options.why || "",
alternatives: options.alternatives?.split(",").map((a) => a.trim()),
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
category: options.category
};
store.decisions.push(decision);
saveDecisions(projectRoot, store);
console.log("Decision recorded:");
console.log(` What: ${decision.what}`);
if (decision.why) {
console.log(` Why: ${decision.why}`);
}
if (decision.alternatives) {
console.log(` Alternatives: ${decision.alternatives.join(", ")}`);
}
console.log(`
Total decisions this session: ${store.decisions.length}`);
});
cmd.command("list").description("List all decisions from this session").option("--json", "Output as JSON").option("--history", "Include historical decisions").option("--all", "Show all projects (with --history)").action((options) => {
const projectRoot = process.cwd();
const store = loadDecisions(projectRoot);
if (options.history) {
const history = loadDecisionHistory(
options.all ? void 0 : projectRoot
);
if (options.json) {
console.log(JSON.stringify(history, null, 2));
return;
}
if (history.length === 0) {
console.log("No decision history found.");
return;
}
console.log(`Decision History (${history.length}):
`);
for (const d of history.slice(0, 50)) {
const category = d.category ? `[${d.category}] ` : "";
const project = options.all ? `(${d.projectName}) ` : "";
console.log(`${project}${category}${d.what}`);
if (d.why) {
console.log(` Rationale: ${d.why}`);
}
const date = new Date(d.timestamp).toLocaleDateString();
console.log(` Date: ${date}`);
console.log("");
}
return;
}
if (options.json) {
console.log(JSON.stringify(store.decisions, null, 2));
return;
}
if (store.decisions.length === 0) {
console.log("No decisions recorded this session.");
console.log("\nRecord decisions with:");
console.log(' stackmemory decision add "Decision" --why "Rationale"');
return;
}
console.log(`Session Decisions (${store.decisions.length}):
`);
for (const d of store.decisions) {
const category = d.category ? `[${d.category}] ` : "";
console.log(`${category}${d.what}`);
if (d.why) {
console.log(` Rationale: ${d.why}`);
}
if (d.alternatives && d.alternatives.length > 0) {
console.log(` Alternatives: ${d.alternatives.join(", ")}`);
}
console.log("");
}
});
cmd.command("clear").description("Clear all decisions (archives to history first)").option("--force", "Skip confirmation").option("--no-archive", "Do not archive decisions").action((options) => {
const projectRoot = process.cwd();
const store = loadDecisions(projectRoot);
if (store.decisions.length === 0) {
console.log("No decisions to clear.");
return;
}
if (!options.force) {
console.log(`This will clear ${store.decisions.length} decisions.`);
console.log("Decisions will be archived to history.");
console.log("Use --force to confirm.");
return;
}
if (options.archive !== false) {
archiveDecisions(projectRoot, store.decisions);
console.log(`Archived ${store.decisions.length} decisions to history.`);
}
const newStore = {
decisions: [],
sessionStart: (/* @__PURE__ */ new Date()).toISOString()
};
saveDecisions(projectRoot, newStore);
console.log("Decisions cleared. New session started.");
});
cmd.command("arch <description>").description("Record an architecture decision").option("-w, --why <rationale>", "Why this architecture choice").action((description, options) => {
const projectRoot = process.cwd();
const store = loadDecisions(projectRoot);
const decision = {
id: `d-${Date.now()}`,
what: description,
why: options.why || "",
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
category: "architecture"
};
store.decisions.push(decision);
saveDecisions(projectRoot, store);
console.log(`Architecture decision recorded: ${description}`);
});
cmd.command("tool <description>").description("Record a tooling decision").option("-w, --why <rationale>", "Why this tool choice").action((description, options) => {
const projectRoot = process.cwd();
const store = loadDecisions(projectRoot);
const decision = {
id: `d-${Date.now()}`,
what: description,
why: options.why || "",
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
category: "tooling"
};
store.decisions.push(decision);
saveDecisions(projectRoot, store);
console.log(`Tooling decision recorded: ${description}`);
});
return cmd;
}
function getSessionDecisions(projectRoot) {
const store = loadDecisions(projectRoot);
return store.decisions;
}
function createMemoryCommand() {
const cmd = createDecisionCommand();
return new Command("memory").description("Store memories for session context (alias for decision)").addCommand(
cmd.commands.find((c) => c.name() === "add").copyInheritedSettings(cmd)
).addCommand(
cmd.commands.find((c) => c.name() === "list").copyInheritedSettings(cmd)
).addCommand(
cmd.commands.find((c) => c.name() === "clear").copyInheritedSettings(cmd)
).addCommand(
cmd.commands.find((c) => c.name() === "arch").copyInheritedSettings(cmd)
).addCommand(
cmd.commands.find((c) => c.name() === "tool").copyInheritedSettings(cmd)
);
}
export {
createDecisionCommand,
createMemoryCommand,
getSessionDecisions
};