@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
JavaScript
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
};