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

179 lines (178 loc) 5.71 kB
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 };