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

314 lines (313 loc) 10.1 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { execFileSync } from "child_process"; import { extname } from "path"; import { extractKeywords as extractKeywordsShared } from "../utils/text.js"; class PreflightChecker { repoPath; gitLogCache = /* @__PURE__ */ new Map(); constructor(repoPath) { this.repoPath = repoPath || process.cwd(); } /** * Run pre-flight check on a set of tasks. * Returns parallel-safe groupings and sequential recommendations. */ check(tasks) { if (tasks.length < 2) { return { parallelSafe: [tasks], sequential: [], allOverlaps: [], summary: "Single task - no overlap check needed." }; } const taskFiles = /* @__PURE__ */ new Map(); const taskKeywords = /* @__PURE__ */ new Map(); for (const task of tasks) { const files = this.predictFiles(task); taskFiles.set(task.name, files); taskKeywords.set( task.name, task.keywords || this.extractKeywords(task.description) ); } const allOverlaps = []; const overlapPairs = /* @__PURE__ */ new Map(); for (let i = 0; i < tasks.length; i++) { for (let j = i + 1; j < tasks.length; j++) { const a = tasks[i]; const b = tasks[j]; const filesA = taskFiles.get(a.name); const filesB = taskFiles.get(b.name); const shared = [...filesA].filter((f) => filesB.has(f)); if (shared.length > 0) { for (const file of shared) { allOverlaps.push({ file, tasks: [a.name, b.name], confidence: this.estimateConfidenceCached( file, taskKeywords.get(a.name), taskKeywords.get(b.name), a, b ), source: this.getSourceCached( file, taskKeywords.get(a.name), taskKeywords.get(b.name), a, b ) }); } if (!overlapPairs.has(a.name)) overlapPairs.set(a.name, /* @__PURE__ */ new Set()); if (!overlapPairs.has(b.name)) overlapPairs.set(b.name, /* @__PURE__ */ new Set()); overlapPairs.get(a.name).add(b.name); overlapPairs.get(b.name).add(a.name); } } } const parallelSafe = this.buildParallelGroups(tasks, overlapPairs); const sequential = []; for (const task of tasks) { const conflicts = overlapPairs.get(task.name); if (conflicts && conflicts.size > 0) { const overlaps = allOverlaps.filter((o) => o.tasks.includes(task.name)); const largestConflict = [...conflicts].sort((a, b) => { return (taskFiles.get(b)?.size || 0) - (taskFiles.get(a)?.size || 0); })[0]; sequential.push({ task, after: largestConflict, overlaps }); } } const deduped = this.deduplicateSequential(sequential, taskFiles); const summary = this.formatSummary(parallelSafe, deduped, allOverlaps); return { parallelSafe, sequential: deduped, allOverlaps, summary }; } /** * Predict which files a task will touch based on multiple signals. */ predictFiles(task) { const files = /* @__PURE__ */ new Set(); if (task.files) { task.files.forEach((f) => files.add(f)); } const keywords = task.keywords || this.extractKeywords(task.description); for (const keyword of keywords) { const historyFiles = this.searchGitHistory(keyword); historyFiles.forEach((f) => files.add(f)); } if (task.files && task.files.length > 0) { for (const file of task.files) { const dependents = this.findDependents(file); dependents.forEach((f) => files.add(f)); } } for (const keyword of keywords) { const matched = this.searchFilePaths(keyword); matched.forEach((f) => files.add(f)); } return files; } /** * Search git log for files changed in commits matching a keyword. */ searchGitHistory(keyword, maxCommits = 50) { const cacheKey = keyword.toLowerCase(); if (this.gitLogCache.has(cacheKey)) { return this.gitLogCache.get(cacheKey); } try { const output = execFileSync( "git", [ "log", `--max-count=${maxCommits}`, "--name-only", "--pretty=format:", "--grep", keyword, "-i" ], { cwd: this.repoPath, encoding: "utf-8", timeout: 1e4 } ); const files = output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); const freq = /* @__PURE__ */ new Map(); for (const f of files) { freq.set(f, (freq.get(f) || 0) + 1); } const result = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([f]) => f); this.gitLogCache.set(cacheKey, result); return result; } catch { return []; } } /** * Find files that import/depend on a given file (shallow, grep-based). */ findDependents(filePath) { const ext = extname(filePath); if (![".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) return []; const baseName = filePath.replace(extname(filePath), "").replace(/\/index$/, ""); try { const output = execFileSync( "git", ["grep", "-l", baseName, "--", "*.ts", "*.tsx", "*.js", "*.jsx"], { cwd: this.repoPath, encoding: "utf-8", timeout: 1e4 } ); return output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && l !== filePath); } catch { return []; } } /** * Search file paths for keyword matches using git ls-files. */ searchFilePaths(keyword) { try { const output = execFileSync("git", ["ls-files", `*${keyword}*`], { cwd: this.repoPath, encoding: "utf-8", timeout: 5e3 }); return output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).slice(0, 10); } catch { return []; } } extractKeywords(description) { return extractKeywordsShared(description); } isInHistory(keywords, file) { return keywords.some( (k) => (this.gitLogCache.get(k.toLowerCase()) || []).includes(file) ); } estimateConfidenceCached(file, keywordsA, keywordsB, taskA, taskB) { if (taskA.files?.includes(file) || taskB.files?.includes(file)) { return 0.9; } if (this.isInHistory(keywordsA, file) && this.isInHistory(keywordsB, file)) { return 0.7; } return 0.3; } getSourceCached(file, keywordsA, keywordsB, taskA, taskB) { if (taskA.files?.includes(file) || taskB.files?.includes(file)) { return "explicit"; } if (this.isInHistory(keywordsA, file) || this.isInHistory(keywordsB, file)) { return "git-history"; } return "keyword-match"; } /** * Build parallel-safe groups via greedy graph coloring. * Tasks that overlap go in different groups. */ buildParallelGroups(tasks, overlapPairs) { const groups = []; const assigned = /* @__PURE__ */ new Set(); const sorted = [...tasks].sort((a, b) => { const conflictsA = overlapPairs.get(a.name)?.size || 0; const conflictsB = overlapPairs.get(b.name)?.size || 0; return conflictsA - conflictsB; }); for (const task of sorted) { if (assigned.has(task.name)) continue; let placed = false; for (const group of groups) { const conflicts = overlapPairs.get(task.name) || /* @__PURE__ */ new Set(); const groupHasConflict = group.some((t) => conflicts.has(t.name)); if (!groupHasConflict) { group.push(task); assigned.add(task.name); placed = true; break; } } if (!placed) { groups.push([task]); assigned.add(task.name); } } return groups; } /** * Deduplicate sequential recommendations — keep only the smaller task. */ deduplicateSequential(sequential, taskFiles) { const seen = /* @__PURE__ */ new Set(); const result = []; for (const entry of sequential) { const key = [entry.task.name, entry.after].sort().join("|"); if (seen.has(key)) continue; seen.add(key); const mySize = taskFiles.get(entry.task.name)?.size || 0; const otherSize = taskFiles.get(entry.after)?.size || 0; if (mySize <= otherSize) { result.push(entry); } else { const otherTask = sequential.find((s) => s.task.name === entry.after); if (otherTask) { result.push({ task: otherTask.task, after: entry.task.name, overlaps: entry.overlaps }); } } } return result; } /** * Format human-readable summary. */ formatSummary(parallelSafe, sequential, overlaps) { const lines = []; if (overlaps.length === 0) { lines.push("All tasks are parallel-safe. No file overlaps detected."); return lines.join("\n"); } lines.push(`Found ${overlaps.length} file overlap(s). `); if (parallelSafe.length === 1) { lines.push( "All tasks can run in parallel (overlaps are low-confidence)." ); } else { lines.push(`Parallel groups (${parallelSafe.length}):`); parallelSafe.forEach((group, i) => { lines.push(` Group ${i + 1}: ${group.map((t) => t.name).join(", ")}`); }); } if (sequential.length > 0) { lines.push("\nSequential recommendations:"); for (const entry of sequential) { lines.push(` "${entry.task.name}" should run after "${entry.after}"`); for (const overlap of entry.overlaps.slice(0, 5)) { lines.push( ` - ${overlap.file} (${overlap.source}, ${Math.round(overlap.confidence * 100)}%)` ); } } } return lines.join("\n"); } } export { PreflightChecker };