@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
179 lines (178 loc) • 5.71 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,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync
} from "fs";
import { join } from "path";
import { homedir } from "os";
import { formatDuration } from "../../utils/formatting.js";
import { logger } from "../monitoring/logger.js";
import {
getCurrentBranch,
detectBaseBranch,
getDiffStats,
getCommitsSince
} from "../utils/git.js";
import { pruneOldFiles } from "../utils/fs.js";
const MAX_CAPTURES = 50;
class ContextCapture {
repoPath;
capturesDir;
constructor(repoPath) {
this.repoPath = repoPath || process.cwd();
const localDir = join(this.repoPath, ".stackmemory", "captures");
const globalDir = join(homedir(), ".stackmemory", "captures");
this.capturesDir = existsSync(join(this.repoPath, ".stackmemory")) ? localDir : globalDir;
if (!existsSync(this.capturesDir)) {
mkdirSync(this.capturesDir, { recursive: true });
}
}
/**
* Capture current state after task completion.
* Compares current branch against base (default: main).
*/
capture(options) {
const branch = getCurrentBranch(this.repoPath);
const baseBranch = options?.baseBranch || detectBaseBranch(this.repoPath);
const task = options?.task || branch;
const { changed, created, deleted } = getDiffStats(
baseBranch,
this.repoPath
);
const commits = getCommitsSince(baseBranch, this.repoPath);
const commitDecisions = this.extractDecisions(commits);
const decisions = [...options?.decisions || [], ...commitDecisions];
const duration = this.estimateDuration(commits);
const result = {
id: `${Date.now()}-${branch.replace(/[^a-zA-Z0-9]/g, "-")}`,
task,
branch,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
filesChanged: changed,
filesCreated: created,
filesDeleted: deleted,
commits,
decisions,
duration,
baseBranch
};
this.save(result);
logger.info("Context captured", {
task,
branch,
filesChanged: changed.length,
filesCreated: created.length,
commits: commits.length
});
return result;
}
/**
* List all captures, newest first.
*/
list(limit = 20) {
if (!existsSync(this.capturesDir)) return [];
const files = readdirSync(this.capturesDir).filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit);
return files.map((f) => {
const content = readFileSync(join(this.capturesDir, f), "utf-8");
return JSON.parse(content);
});
}
/**
* Get the most recent capture for a branch.
*/
getLatest(branch) {
const captures = this.list();
if (branch) {
return captures.find((c) => c.branch === branch);
}
return captures[0];
}
/**
* Format a capture as a human-readable summary for session restore.
*/
format(capture) {
const lines = [];
lines.push(`# Capture: ${capture.task}`);
lines.push(`Branch: ${capture.branch} (base: ${capture.baseBranch})`);
lines.push(
`Time: ${capture.timestamp}${capture.duration ? ` (${capture.duration})` : ""}`
);
lines.push("");
if (capture.filesChanged.length > 0) {
lines.push(`## Files Changed (${capture.filesChanged.length})`);
capture.filesChanged.forEach((f) => lines.push(` - ${f}`));
lines.push("");
}
if (capture.filesCreated.length > 0) {
lines.push(`## Files Created (${capture.filesCreated.length})`);
capture.filesCreated.forEach((f) => lines.push(` + ${f}`));
lines.push("");
}
if (capture.filesDeleted.length > 0) {
lines.push(`## Files Deleted (${capture.filesDeleted.length})`);
capture.filesDeleted.forEach((f) => lines.push(` - ${f}`));
lines.push("");
}
if (capture.commits.length > 0) {
lines.push(`## Commits (${capture.commits.length})`);
capture.commits.forEach(
(c) => lines.push(` ${c.hash.slice(0, 7)} ${c.message}`)
);
lines.push("");
}
if (capture.decisions.length > 0) {
lines.push("## Decisions");
capture.decisions.forEach((d) => lines.push(` - ${d}`));
lines.push("");
}
return lines.join("\n");
}
// --- Private ---
/**
* Extract decision-like statements from commit messages.
* Looks for patterns like "chose X over Y", "switched to", "decided", etc.
*/
extractDecisions(commits) {
const decisionPatterns = [
/chose\s+.+\s+over\s+/i,
/switched\s+(to|from)\s+/i,
/decided\s+/i,
/replaced\s+.+\s+with\s+/i,
/migrated?\s+(to|from)\s+/i,
/refactor/i,
/breaking\s+change/i
];
const decisions = [];
for (const commit of commits) {
for (const pattern of decisionPatterns) {
if (pattern.test(commit.message)) {
decisions.push(commit.message);
break;
}
}
}
return decisions;
}
estimateDuration(commits) {
if (commits.length < 2) return void 0;
const first = new Date(commits[commits.length - 1].date);
const last = new Date(commits[0].date);
const diffMs = last.getTime() - first.getTime();
return formatDuration(diffMs);
}
save(result) {
const filename = `${result.timestamp.replace(/[:.]/g, "-")}-${result.branch.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 30)}.json`;
const filePath = join(this.capturesDir, filename);
writeFileSync(filePath, JSON.stringify(result, null, 2));
pruneOldFiles(this.capturesDir, ".json", MAX_CAPTURES);
}
}
export {
ContextCapture
};