@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
614 lines (605 loc) • 20.2 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 { QueryParser } from "../query/query-parser.js";
import { CompressedSummaryGenerator } from "./summary-generator.js";
import {
DEFAULT_RETRIEVAL_CONFIG
} from "./types.js";
import { logger } from "../monitoring/logger.js";
import { LazyContextLoader } from "../performance/lazy-context-loader.js";
import { ContextCache } from "../performance/context-cache.js";
import { createLLMProvider } from "./llm-provider.js";
import { RetrievalAuditStore } from "./retrieval-audit.js";
class HeuristicAnalyzer {
analyze(query, summary, parsedQuery) {
const framesToRetrieve = [];
const recommendations = [];
const matchedPatterns = [];
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/\W+/).filter((w) => w.length > 2);
for (const frame of summary.recentSession.frames) {
let priority = 5;
const reasons = [];
const ageHours = (Date.now() - frame.createdAt) / (1e3 * 60 * 60);
if (ageHours < 1) {
priority += 3;
reasons.push("very recent");
} else if (ageHours < 6) {
priority += 2;
reasons.push("recent");
}
priority += Math.floor(frame.score * 3);
const nameLower = frame.name.toLowerCase();
const nameMatches = queryWords.filter((w) => nameLower.includes(w));
if (nameMatches.length > 0) {
priority += nameMatches.length * 2;
reasons.push(`matches: ${nameMatches.join(", ")}`);
matchedPatterns.push(`name_match:${nameMatches.join(",")}`);
}
if (parsedQuery?.frame?.type) {
const frameType = frame.type.toLowerCase();
if (parsedQuery.frame.type.some((t) => t.toLowerCase() === frameType)) {
priority += 2;
reasons.push("type match");
}
}
if (parsedQuery?.content?.topic) {
const topics = parsedQuery.content.topic;
const topicMatches = topics.filter(
(t) => nameLower.includes(t.toLowerCase()) || frame.digestPreview && frame.digestPreview.toLowerCase().includes(t.toLowerCase())
);
if (topicMatches.length > 0) {
priority += topicMatches.length;
reasons.push(`topic: ${topicMatches.join(", ")}`);
}
}
priority = Math.min(priority, 10);
if (priority >= 5) {
framesToRetrieve.push({
frameId: frame.frameId,
priority,
reason: reasons.length > 0 ? reasons.join("; ") : "relevant context",
includeEvents: priority >= 7,
includeAnchors: true,
includeDigest: true,
estimatedTokens: this.estimateFrameTokens(frame)
});
}
}
framesToRetrieve.sort((a, b) => b.priority - a.priority);
if (summary.recentSession.errorsEncountered.length > 0) {
recommendations.push({
type: "include",
target: "error_context",
reason: `${summary.recentSession.errorsEncountered.length} errors encountered recently`,
impact: "medium"
});
}
if (queryLower.includes("decision") || queryLower.includes("why") || queryLower.includes("chose")) {
recommendations.push({
type: "include",
target: "decisions",
reason: "Query appears to be about past decisions",
impact: "high"
});
}
const avgPriority = framesToRetrieve.length > 0 ? framesToRetrieve.reduce((sum, f) => sum + f.priority, 0) / framesToRetrieve.length : 0;
const confidenceScore = Math.min(avgPriority / 10, 0.95);
const reasoning = this.generateReasoning(
query,
framesToRetrieve,
summary,
matchedPatterns
);
return {
reasoning,
framesToRetrieve: framesToRetrieve.slice(0, 10),
// Limit to top 10
confidenceScore,
recommendations,
metadata: {
analysisTimeMs: 0,
// Will be set by caller
summaryTokens: this.estimateSummaryTokens(summary),
queryComplexity: this.assessQueryComplexity(query, parsedQuery),
matchedPatterns,
fallbackUsed: true
}
};
}
estimateFrameTokens(frame) {
let tokens = 50;
tokens += frame.eventCount * 30;
tokens += frame.anchorCount * 40;
if (frame.digestPreview) tokens += frame.digestPreview.length / 4;
return Math.floor(tokens);
}
estimateSummaryTokens(summary) {
return Math.floor(JSON.stringify(summary).length / 4);
}
assessQueryComplexity(query, parsedQuery) {
const wordCount = query.split(/\s+/).length;
const hasTimeFilter = !!parsedQuery?.time;
const hasContentFilter = !!parsedQuery?.content;
const hasPeopleFilter = !!parsedQuery?.people;
const hasFrameFilter = !!parsedQuery?.frame;
const filterCount = [
hasTimeFilter,
hasContentFilter,
hasPeopleFilter,
hasFrameFilter
].filter(Boolean).length;
if (wordCount <= 5 && filterCount <= 1) return "simple";
if (wordCount <= 15 && filterCount <= 2) return "moderate";
return "complex";
}
generateReasoning(query, frames, summary, matchedPatterns) {
const parts = [];
parts.push(`Query: "${query}"`);
parts.push(
`Analyzed ${summary.recentSession.frames.length} recent frames.`
);
if (matchedPatterns.length > 0) {
parts.push(`Matched patterns: ${matchedPatterns.join(", ")}`);
}
if (frames.length > 0) {
parts.push(`Selected ${frames.length} frames for retrieval.`);
const topFrames = frames.slice(0, 3);
parts.push(
`Top frames: ${topFrames.map((f) => `${f.frameId} (priority: ${f.priority})`).join(", ")}`
);
} else {
parts.push("No highly relevant frames found. Using general context.");
}
return parts.join(" ");
}
}
class LLMContextRetrieval {
db;
frameManager;
summaryGenerator;
queryParser;
heuristicAnalyzer;
llmProvider;
config;
projectId;
lazyLoader;
contextCache;
auditStore;
enableAudit;
constructor(db, frameManager, projectId, config = {}, llmProvider) {
this.db = db;
this.frameManager = frameManager;
this.projectId = projectId;
this.config = { ...DEFAULT_RETRIEVAL_CONFIG, ...config };
this.llmProvider = llmProvider ?? createLLMProvider();
if (this.llmProvider) {
logger.info("LLM provider configured for context retrieval", {
projectId,
provider: this.config.llmConfig.provider
});
}
this.summaryGenerator = new CompressedSummaryGenerator(
db,
frameManager,
projectId,
config
);
this.queryParser = new QueryParser();
this.heuristicAnalyzer = new HeuristicAnalyzer();
this.auditStore = new RetrievalAuditStore(db, projectId);
this.enableAudit = true;
this.lazyLoader = new LazyContextLoader(db, projectId);
this.contextCache = new ContextCache({
maxSize: 50 * 1024 * 1024,
// 50MB for context cache
maxItems: 100,
defaultTTL: 6e5
// 10 minutes
});
this.contextCache.startCleanup(6e4);
}
/**
* Get the audit store for external access
*/
getAuditStore() {
return this.auditStore;
}
/**
* Check if LLM provider is available
*/
hasLLMProvider() {
return !!this.llmProvider;
}
/**
* Retrieve context based on query using LLM analysis (with caching)
*/
async retrieveContext(query, options = {}) {
const startTime = Date.now();
const tokenBudget = options.tokenBudget || this.config.defaultTokenBudget;
if (!options.forceRefresh) {
const cacheKey = `${query}:${tokenBudget}:${JSON.stringify(options.hints || {})}`;
const cached = this.contextCache.get(cacheKey);
if (cached) {
logger.debug("Context cache hit", {
query: query.substring(0, 50),
cacheStats: this.contextCache.getStats()
});
return cached;
}
}
logger.info("Starting context retrieval", {
projectId: this.projectId,
query: query.substring(0, 100),
tokenBudget
});
const parsedQuery = this.queryParser.parseNaturalLanguage(query);
const summary = this.summaryGenerator.generateSummary({
forceRefresh: options.forceRefresh
});
const analysis = await this.analyzeWithLLM({
currentQuery: query,
parsedQuery,
compressedSummary: summary,
tokenBudget,
hints: options.hints
});
const { frames, anchors, events, tokensUsed } = await this.retrieveFrames(
analysis,
tokenBudget
);
const context = this.assembleContext(frames, anchors, events, analysis);
const metadata = {
retrievalTimeMs: Date.now() - startTime,
cacheHit: false,
// Would need cache tracking
framesScanned: summary.recentSession.frames.length,
framesIncluded: frames.length,
compressionRatio: tokensUsed > 0 ? tokenBudget / tokensUsed : 1
};
logger.info("Context retrieval complete", {
projectId: this.projectId,
framesIncluded: frames.length,
tokensUsed,
retrievalTimeMs: metadata.retrievalTimeMs,
confidence: analysis.confidenceScore
});
const result = {
context,
frames,
anchors,
events,
analysis,
tokenUsage: {
budget: tokenBudget,
used: tokensUsed,
remaining: tokenBudget - tokensUsed
},
metadata
};
if (this.enableAudit) {
const provider = analysis.metadata.fallbackUsed ? "heuristic" : this.llmProvider ? "anthropic" : "heuristic";
this.auditStore.record(query, analysis, {
tokensUsed,
tokenBudget,
provider
});
}
if (!options.forceRefresh) {
const cacheKey = `${query}:${tokenBudget}:${JSON.stringify(options.hints || {})}`;
this.contextCache.set(cacheKey, result, {
ttl: 6e5
// 10 minutes
});
}
return result;
}
/**
* Perform LLM analysis or fall back to heuristics
*/
async analyzeWithLLM(request) {
const startTime = Date.now();
if (this.llmProvider) {
try {
const prompt = this.buildAnalysisPrompt(request);
const response = await this.llmProvider.analyze(
prompt,
this.config.llmConfig.maxTokens
);
const analysis = this.parseAnalysisResponse(response, request);
analysis.metadata.analysisTimeMs = Date.now() - startTime;
analysis.metadata.fallbackUsed = false;
if (analysis.confidenceScore >= this.config.minConfidenceThreshold) {
return analysis;
}
logger.warn("LLM confidence below threshold, using fallback", {
confidence: analysis.confidenceScore,
threshold: this.config.minConfidenceThreshold
});
} catch (error) {
logger.error(
"LLM analysis failed, using fallback",
error instanceof Error ? error : new Error(String(error))
);
}
}
if (this.config.enableFallback) {
const analysis = this.heuristicAnalyzer.analyze(
request.currentQuery,
request.compressedSummary,
request.parsedQuery
);
analysis.metadata.analysisTimeMs = Date.now() - startTime;
return analysis;
}
return {
reasoning: "Unable to perform analysis - LLM unavailable and fallback disabled",
framesToRetrieve: [],
confidenceScore: 0,
recommendations: [],
metadata: {
analysisTimeMs: Date.now() - startTime,
summaryTokens: 0,
queryComplexity: "simple",
matchedPatterns: [],
fallbackUsed: false
}
};
}
/**
* Build the prompt for LLM analysis
*/
buildAnalysisPrompt(request) {
const summary = request.compressedSummary;
return `You are analyzing a code project's memory to retrieve relevant context.
## Current Query
"${request.currentQuery}"
## Token Budget
${request.tokenBudget} tokens available
## Recent Session Summary
- Frames: ${summary.recentSession.frames.length}
- Time range: ${new Date(summary.recentSession.timeRange.start).toISOString()} to ${new Date(summary.recentSession.timeRange.end).toISOString()}
- Dominant operations: ${summary.recentSession.dominantOperations.map((o) => `${o.operation}(${o.count})`).join(", ")}
- Files touched: ${summary.recentSession.filesTouched.slice(0, 5).map((f) => f.path).join(", ")}
- Errors: ${summary.recentSession.errorsEncountered.length}
## Available Frames
${summary.recentSession.frames.slice(0, 15).map(
(f) => `- ${f.frameId}: "${f.name}" (${f.type}, score: ${f.score.toFixed(2)}, events: ${f.eventCount})`
).join("\n")}
## Key Decisions
${summary.historicalPatterns.keyDecisions.slice(0, 5).map((d) => `- ${d.text.substring(0, 80)}...`).join("\n")}
## Task
Analyze the query and select the most relevant frames to retrieve.
Return a JSON object with:
{
"reasoning": "Your analysis of why these frames are relevant",
"framesToRetrieve": [
{"frameId": "...", "priority": 1-10, "reason": "...", "includeEvents": true/false, "includeAnchors": true/false}
],
"confidenceScore": 0.0-1.0,
"recommendations": [{"type": "include/exclude/summarize", "target": "...", "reason": "...", "impact": "low/medium/high"}]
}
${request.hints ? `
## Hints
${JSON.stringify(request.hints)}` : ""}
Respond with only the JSON object, no other text.`;
}
/**
* Parse LLM response into structured analysis
*/
parseAnalysisResponse(response, request) {
try {
let jsonStr = response;
const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1];
}
const parsed = JSON.parse(jsonStr.trim());
return {
reasoning: parsed.reasoning || "No reasoning provided",
framesToRetrieve: (parsed.framesToRetrieve || []).map((f) => ({
frameId: f.frameId,
priority: Math.min(10, Math.max(1, f.priority || 5)),
reason: f.reason || "Selected by LLM",
includeEvents: f.includeEvents ?? true,
includeAnchors: f.includeAnchors ?? true,
includeDigest: f.includeDigest ?? true,
estimatedTokens: f.estimatedTokens || 100
})),
confidenceScore: Math.min(
1,
Math.max(0, parsed.confidenceScore || 0.5)
),
recommendations: (parsed.recommendations || []).map((r) => ({
type: r.type || "include",
target: r.target || "",
reason: r.reason || "",
impact: r.impact || "medium"
})),
metadata: {
analysisTimeMs: 0,
summaryTokens: Math.floor(
JSON.stringify(request.compressedSummary).length / 4
),
queryComplexity: this.assessQueryComplexity(request.currentQuery),
matchedPatterns: [],
fallbackUsed: false
}
};
} catch (error) {
logger.warn("Failed to parse LLM response, using fallback", {
error,
response
});
return this.heuristicAnalyzer.analyze(
request.currentQuery,
request.compressedSummary,
request.parsedQuery
);
}
}
assessQueryComplexity(query) {
const wordCount = query.split(/\s+/).length;
if (wordCount <= 5) return "simple";
if (wordCount <= 15) return "moderate";
return "complex";
}
/**
* Retrieve frames based on analysis (with lazy loading)
*/
async retrieveFrames(analysis, tokenBudget) {
const frames = [];
const anchors = [];
const events = [];
let tokensUsed = 0;
const frameIds = analysis.framesToRetrieve.map((p) => p.frameId);
await this.lazyLoader.preloadContext(frameIds, {
parallel: true,
depth: 2
// Load frames, anchors, and events
});
for (const plan of analysis.framesToRetrieve) {
if (tokensUsed + plan.estimatedTokens > tokenBudget) {
logger.debug("Token budget exceeded, stopping retrieval", {
tokensUsed,
budget: tokenBudget
});
break;
}
try {
const frame = await this.lazyLoader.lazyFrame(plan.frameId).get();
frames.push(frame);
tokensUsed += 50;
if (plan.includeAnchors) {
const frameAnchors = await this.lazyLoader.lazyAnchors(plan.frameId).get();
anchors.push(...frameAnchors);
tokensUsed += frameAnchors.length * 40;
}
if (plan.includeEvents) {
const frameEvents = await this.lazyLoader.lazyEvents(plan.frameId, 10).get();
events.push(...frameEvents);
tokensUsed += frameEvents.length * 30;
}
} catch (error) {
logger.warn("Failed to retrieve frame", {
frameId: plan.frameId,
error
});
}
}
return { frames, anchors, events, tokensUsed };
}
getFrameAnchors(frameId) {
try {
const rows = this.db.prepare(
`
SELECT * FROM anchors WHERE frame_id = ?
ORDER BY priority DESC, created_at DESC
`
).all(frameId);
return rows.map((row) => ({
...row,
metadata: JSON.parse(row.metadata || "{}")
}));
} catch {
return [];
}
}
/**
* Assemble final context string
*/
assembleContext(frames, anchors, events, analysis) {
const sections = [];
sections.push("## Context Retrieval Analysis");
sections.push(
`*Confidence: ${(analysis.confidenceScore * 100).toFixed(0)}%*`
);
sections.push(analysis.reasoning);
sections.push("");
if (frames.length > 0) {
sections.push("## Relevant Frames");
for (const frame of frames) {
sections.push(`### ${frame.name} (${frame.type})`);
if (frame.digest_text) {
sections.push(frame.digest_text);
}
sections.push("");
}
}
const decisions = anchors.filter((a) => a.type === "DECISION");
const constraints = anchors.filter((a) => a.type === "CONSTRAINT");
const facts = anchors.filter((a) => a.type === "FACT");
if (decisions.length > 0) {
sections.push("## Key Decisions");
for (const d of decisions.slice(0, 5)) {
sections.push(`- ${d.text}`);
}
sections.push("");
}
if (constraints.length > 0) {
sections.push("## Active Constraints");
for (const c of constraints.slice(0, 5)) {
sections.push(`- ${c.text}`);
}
sections.push("");
}
if (facts.length > 0) {
sections.push("## Important Facts");
for (const f of facts.slice(0, 5)) {
sections.push(`- ${f.text}`);
}
sections.push("");
}
if (events.length > 0) {
sections.push("## Recent Activity");
const eventSummary = this.summarizeEvents(events);
sections.push(eventSummary);
sections.push("");
}
if (analysis.recommendations.length > 0) {
sections.push("## Recommendations");
for (const rec of analysis.recommendations) {
const icon = rec.type === "include" ? "+" : rec.type === "exclude" ? "-" : "~";
sections.push(`${icon} [${rec.impact.toUpperCase()}] ${rec.reason}`);
}
}
return sections.join("\n");
}
summarizeEvents(events) {
const byType = {};
for (const event of events) {
byType[event.event_type] = (byType[event.event_type] || 0) + 1;
}
return Object.entries(byType).map(([type, count]) => `- ${type}: ${count} occurrences`).join("\n");
}
/**
* Get just the compressed summary (useful for external analysis)
*/
getSummary(forceRefresh = false) {
return this.summaryGenerator.generateSummary({ forceRefresh });
}
/**
* Set LLM provider
*/
setLLMProvider(provider) {
this.llmProvider = provider;
}
/**
* Clear all caches
*/
clearCache() {
this.summaryGenerator.clearCache();
this.lazyLoader.clearCache();
this.contextCache.clear();
logger.info("Cleared all caches", {
projectId: this.projectId,
cacheStats: this.contextCache.getStats()
});
}
}
export {
LLMContextRetrieval
};
//# sourceMappingURL=llm-context-retrieval.js.map