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

497 lines (495 loc) 15.5 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { logger } from "../../../core/monitoring/logger.js"; import { execSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; class DiscoveryHandlers { constructor(deps) { this.deps = deps; } /** * Discover relevant files based on current context */ async handleDiscover(args) { try { const { query, depth = "medium", includePatterns = ["*.ts", "*.tsx", "*.js", "*.md", "*.json"], excludePatterns = ["node_modules", "dist", ".git", "*.min.js"], maxFiles = 20 } = args; logger.info("Starting discovery", { query, depth }); const keywords = this.extractContextKeywords(query); const mdContext = this.parseMdFiles(); const recentFiles = this.getRecentFilesFromContext(); const discoveredFiles = await this.searchCodebase( keywords, includePatterns, excludePatterns, depth, maxFiles ); const rankedFiles = this.rankFiles( discoveredFiles, recentFiles, keywords ); const contextSummary = this.generateContextSummary(keywords, rankedFiles); const result = { files: rankedFiles.slice(0, maxFiles), keywords, contextSummary, mdContext }; return { content: [ { type: "text", text: this.formatDiscoveryResult(result) } ], metadata: result }; } catch (error) { logger.error("Discovery failed", error); throw error; } } /** * Get related files to a specific file or concept */ async handleRelatedFiles(args) { try { const { file, concept, maxFiles = 10 } = args; if (!file && !concept) { throw new Error("Either file or concept is required"); } let relatedFiles = []; if (file) { relatedFiles = this.findFileReferences(file, maxFiles); } if (concept) { const conceptFiles = this.searchForConcept(concept, maxFiles); relatedFiles = this.mergeAndDedupe(relatedFiles, conceptFiles); } return { content: [ { type: "text", text: this.formatRelatedFiles(relatedFiles, file, concept) } ], metadata: { relatedFiles } }; } catch (error) { logger.error("Related files search failed", error); throw error; } } /** * Get session summary with actionable context */ async handleSessionSummary(args) { try { const { includeFiles = true, includeDecisions = true } = args; const hotStack = this.deps.frameManager.getHotStackContext(50); const recentFiles = includeFiles ? this.getRecentFilesFromContext() : []; const decisions = includeDecisions ? this.getRecentDecisions() : []; const summary = { activeFrames: hotStack.length, currentGoal: hotStack[hotStack.length - 1]?.header?.goal || "No active task", recentFiles: recentFiles.slice(0, 10), decisions: decisions.slice(0, 5), stackDepth: this.deps.frameManager.getStackDepth() }; return { content: [ { type: "text", text: this.formatSessionSummary(summary) } ], metadata: summary }; } catch (error) { logger.error("Session summary failed", error); throw error; } } // =============================== // Private helper methods // =============================== extractContextKeywords(query) { const keywords = /* @__PURE__ */ new Set(); if (query) { const queryWords = query.toLowerCase().split(/\s+/).filter((w) => w.length > 2); queryWords.forEach((w) => keywords.add(w)); } const hotStack = this.deps.frameManager.getHotStackContext(20); for (const frame of hotStack) { if (frame.header?.goal) { const goalWords = frame.header.goal.toLowerCase().split(/[\s\-_]+/).filter((w) => w.length > 2); goalWords.forEach((w) => keywords.add(w)); } frame.header?.constraints?.forEach((c) => { const words = c.toLowerCase().split(/[\s\-_]+/).filter((w) => w.length > 2); words.forEach((w) => keywords.add(w)); }); frame.recentEvents?.forEach((evt) => { if (evt.data?.content) { const words = String(evt.data.content).toLowerCase().split(/[\s\-_]+/).filter((w) => w.length > 3).slice(0, 5); words.forEach((w) => keywords.add(w)); } }); } try { const fileEvents = this.deps.db.prepare( ` SELECT DISTINCT data FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE e.type IN ('file_read', 'file_write', 'file_edit') ORDER BY e.timestamp DESC LIMIT 20 ` ).all(); for (const evt of fileEvents) { try { const data = JSON.parse(evt.data || "{}"); if (data.path) { const pathParts = data.path.split("/").slice(-2); pathParts.forEach((part) => { const words = part.replace(/\.[^.]+$/, "").split(/[\-_]+/).filter((w) => w.length > 2); words.forEach((w) => keywords.add(w.toLowerCase())); }); } } catch { } } } catch { } const stopwords = /* @__PURE__ */ new Set([ "the", "and", "for", "with", "this", "that", "from", "have", "has", "been", "will", "can", "should", "would", "could", "function", "const", "let", "var", "import", "export", "return", "async", "await" ]); return Array.from(keywords).filter((k) => !stopwords.has(k)); } parseMdFiles() { const mdContext = {}; const mdFiles = ["CLAUDE.md", "README.md", ".stackmemory/context.md"]; for (const mdFile of mdFiles) { const fullPath = join(this.deps.projectRoot, mdFile); if (existsSync(fullPath)) { try { const content = readFileSync(fullPath, "utf8"); const sections = this.extractMdSections(content); mdContext[mdFile] = sections; } catch { } } } const homeClaude = join(process.env["HOME"] || "", ".claude", "CLAUDE.md"); if (existsSync(homeClaude)) { try { const content = readFileSync(homeClaude, "utf8"); mdContext["~/.claude/CLAUDE.md"] = this.extractMdSections(content); } catch { } } return mdContext; } extractMdSections(content) { const lines = content.split("\n"); const sections = []; let currentSection = ""; let inCodeBlock = false; for (const line of lines) { if (line.startsWith("```")) { inCodeBlock = !inCodeBlock; continue; } if (inCodeBlock) continue; if (line.startsWith("#")) { if (currentSection) sections.push(currentSection.trim()); currentSection = line + "\n"; } else if (currentSection && line.trim()) { currentSection += line + "\n"; } } if (currentSection) sections.push(currentSection.trim()); return sections.map((s) => s.length > 500 ? s.slice(0, 500) + "..." : s).join("\n\n"); } getRecentFilesFromContext() { const files = /* @__PURE__ */ new Set(); try { const fileEvents = this.deps.db.prepare( ` SELECT DISTINCT data FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE e.type IN ('file_read', 'file_write', 'file_edit', 'tool_call') AND e.timestamp > ? ORDER BY e.timestamp DESC LIMIT 50 ` ).all(Math.floor(Date.now() / 1e3) - 3600); for (const evt of fileEvents) { try { const data = JSON.parse(evt.data || "{}"); if (data.path) files.add(data.path); if (data.file) files.add(data.file); if (data.file_path) files.add(data.file_path); } catch { } } } catch { } try { const gitStatus = execSync("git status --porcelain", { cwd: this.deps.projectRoot, encoding: "utf8" }); const modifiedFiles = gitStatus.split("\n").filter((l) => l.trim()).map((l) => l.slice(3).trim()).filter((f) => f); modifiedFiles.forEach((f) => files.add(f)); } catch { } return Array.from(files); } async searchCodebase(keywords, includePatterns, excludePatterns, depth, _maxFiles) { const files = []; const maxResults = depth === "shallow" ? 10 : depth === "medium" ? 25 : 50; for (const keyword of keywords.slice(0, 10)) { try { const excludeArgs = excludePatterns.map((p) => `--exclude-dir=${p}`).join(" "); const includeArgs = includePatterns.map((p) => `--include=${p}`).join(" "); const cmd = `grep -ril ${excludeArgs} ${includeArgs} "${keyword}" . 2>/dev/null | head -${maxResults}`; const result = execSync(cmd, { cwd: this.deps.projectRoot, encoding: "utf8", timeout: 5e3 }); const matchedFiles = result.split("\n").filter((f) => f.trim()); for (const file of matchedFiles) { const cleanPath = file.replace(/^\.\//, ""); const existing = files.find((f) => f.path === cleanPath); if (existing) { existing.matchedKeywords = existing.matchedKeywords || []; if (!existing.matchedKeywords.includes(keyword)) { existing.matchedKeywords.push(keyword); } } else { files.push({ path: cleanPath, relevance: "medium", reason: `Contains keyword: ${keyword}`, matchedKeywords: [keyword] }); } } } catch { } } for (const file of files) { const matchCount = file.matchedKeywords?.length || 0; if (matchCount >= 3) { file.relevance = "high"; file.reason = `Matches ${matchCount} keywords: ${file.matchedKeywords?.slice(0, 3).join(", ")}`; } } return files; } rankFiles(discovered, recent, _keywords) { const _recentSet = new Set(recent); for (const recentFile of recent) { const existing = discovered.find((f) => f.path === recentFile); if (existing) { existing.relevance = "high"; existing.reason = "Recently accessed + " + existing.reason; } else { discovered.push({ path: recentFile, relevance: "high", reason: "Recently accessed in context" }); } } return discovered.sort((a, b) => { const relevanceOrder = { high: 3, medium: 2, low: 1 }; const relDiff = relevanceOrder[b.relevance] - relevanceOrder[a.relevance]; if (relDiff !== 0) return relDiff; const aMatches = a.matchedKeywords?.length || 0; const bMatches = b.matchedKeywords?.length || 0; return bMatches - aMatches; }); } findFileReferences(file, maxFiles) { const results = []; try { const basename = file.replace(/\.[^.]+$/, ""); const cmd = `grep -ril "from.*${basename}" . --include="*.ts" --include="*.tsx" --include="*.js" --exclude-dir=node_modules --exclude-dir=dist 2>/dev/null | head -${maxFiles}`; const result = execSync(cmd, { cwd: this.deps.projectRoot, encoding: "utf8", timeout: 5e3 }); const files = result.split("\n").filter((f) => f.trim()); for (const f of files) { results.push({ path: f.replace(/^\.\//, ""), relevance: "high", reason: `Imports ${file}` }); } } catch { } return results; } searchForConcept(concept, maxFiles) { const results = []; try { const cmd = `grep -ril "${concept}" . --include="*.ts" --include="*.tsx" --include="*.md" --exclude-dir=node_modules --exclude-dir=dist 2>/dev/null | head -${maxFiles}`; const result = execSync(cmd, { cwd: this.deps.projectRoot, encoding: "utf8", timeout: 5e3 }); const files = result.split("\n").filter((f) => f.trim()); for (const f of files) { results.push({ path: f.replace(/^\.\//, ""), relevance: "medium", reason: `Contains "${concept}"` }); } } catch { } return results; } mergeAndDedupe(a, b) { const pathSet = new Set(a.map((f) => f.path)); const merged = [...a]; for (const file of b) { if (!pathSet.has(file.path)) { merged.push(file); pathSet.add(file.path); } } return merged; } getRecentDecisions() { try { const decisions = this.deps.db.prepare( ` SELECT a.text, a.type, a.priority, f.name as frame_name, a.created_at FROM anchors a JOIN frames f ON a.frame_id = f.frame_id WHERE a.type IN ('DECISION', 'CONSTRAINT', 'FACT') ORDER BY a.created_at DESC LIMIT 10 ` ).all(); return decisions; } catch { return []; } } generateContextSummary(keywords, files) { const hotStack = this.deps.frameManager.getHotStackContext(5); const currentGoal = hotStack[hotStack.length - 1]?.header?.goal; let summary = ""; if (currentGoal) { summary += `Current task: ${currentGoal} `; } summary += `Context keywords: ${keywords.slice(0, 10).join(", ")} `; summary += `Relevant files found: ${files.length} `; summary += `High relevance: ${files.filter((f) => f.relevance === "high").length}`; return summary; } formatDiscoveryResult(result) { let output = "# Discovery Results\n\n"; output += "## Context Summary\n"; output += result.contextSummary + "\n\n"; output += "## Relevant Files\n\n"; for (const file of result.files.slice(0, 15)) { const icon = file.relevance === "high" ? "[HIGH]" : file.relevance === "medium" ? "[MED]" : "[LOW]"; output += `${icon} ${file.path} `; output += ` ${file.reason} `; } if (result.keywords.length > 0) { output += "\n## Keywords Used\n"; output += result.keywords.slice(0, 15).join(", ") + "\n"; } return output; } formatRelatedFiles(files, file, concept) { let output = "# Related Files\n\n"; if (file) output += `Related to file: ${file} `; if (concept) output += `Related to concept: ${concept} `; output += "\n"; for (const f of files) { output += `- ${f.path} ${f.reason} `; } return output; } formatSessionSummary(summary) { let output = "# Session Summary\n\n"; output += `**Current Goal:** ${summary.currentGoal} `; output += `**Active Frames:** ${summary.activeFrames} `; output += `**Stack Depth:** ${summary.stackDepth} `; if (summary.recentFiles.length > 0) { output += "## Recent Files\n"; for (const f of summary.recentFiles) { output += `- ${f} `; } output += "\n"; } if (summary.decisions.length > 0) { output += "## Recent Decisions\n"; for (const d of summary.decisions) { output += `- [${d.type}] ${d.text} `; } } return output; } } export { DiscoveryHandlers };