UNPKG

@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
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