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

528 lines (527 loc) 15.8 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { v4 as uuidv4 } from "uuid"; import { TraceType, DEFAULT_TRACE_CONFIG, TRACE_PATTERNS, CompressionStrategy } from "./types.js"; import { ConfigManager } from "../config/config-manager.js"; import { TraceStore } from "./trace-store.js"; class TraceDetector { config; activeTrace = []; lastToolTime = 0; traces = []; configManager; traceStore; constructor(config = {}, configManager, db) { this.config = { ...DEFAULT_TRACE_CONFIG, ...config }; this.configManager = configManager || new ConfigManager(); if (db) { this.traceStore = new TraceStore(db); this.loadTracesFromStore(); } } /** * Load traces from the database */ loadTracesFromStore() { if (!this.traceStore) return; try { const recentTraces = this.traceStore.getAllTraces(); const cutoff = Date.now() - 24 * 60 * 60 * 1e3; this.traces = recentTraces.filter((t) => t.metadata.startTime >= cutoff); } catch (error) { console.error("Failed to load traces from store:", error); this.traces = []; } } /** * Add a tool call and check if it belongs to current trace */ addToolCall(tool) { const _now = Date.now(); if (this.shouldStartNewTrace(tool)) { if (this.activeTrace.length > 0) { this.finalizeTrace(); } this.activeTrace = [tool]; } else { this.activeTrace.push(tool); } this.lastToolTime = tool.timestamp; if (this.activeTrace.length >= this.config.maxTraceSize) { this.finalizeTrace(); } } /** * Determine if a tool call should start a new trace */ shouldStartNewTrace(tool) { if (this.activeTrace.length === 0) { return false; } const lastTool = this.activeTrace[this.activeTrace.length - 1]; const timeDiff = tool.timestamp - lastTool.timestamp; if (timeDiff > this.config.timeProximityMs) { return true; } if (this.config.sameDirThreshold) { const lastFiles = lastTool.filesAffected || []; const currentFiles = tool.filesAffected || []; if (lastFiles.length > 0 && currentFiles.length > 0) { const lastDirs = lastFiles.map((f) => this.getDirectory(f)); const currentDirs = currentFiles.map((f) => this.getDirectory(f)); const hasCommonDir = lastDirs.some((d) => currentDirs.includes(d)); if (!hasCommonDir) { return true; } } } if (this.config.causalRelationship) { if (lastTool.error && !this.isFixAttempt(tool, lastTool)) { return true; } } return false; } /** * Check if a tool is attempting to fix an error from previous tool */ isFixAttempt(current, previous) { if (previous.error && (current.tool === "edit" || current.tool === "write")) { return true; } if (current.tool === "test" || current.tool === "bash") { return true; } return false; } /** * Finalize current trace and add to traces list */ finalizeTrace() { if (this.activeTrace.length === 0) return; const trace = this.createTrace(this.activeTrace); this.traces.push(trace); if (this.traceStore) { try { this.traceStore.saveTrace(trace); } catch (error) { console.error("Failed to persist trace:", error); } } this.activeTrace = []; } /** * Create a trace from a sequence of tool calls */ createTrace(tools) { const id = uuidv4(); const type = this.detectTraceType(tools); const metadata = this.extractMetadata(tools); const score = this.calculateTraceScore(tools, metadata); const summary = this.generateSummary(tools, type, metadata); const trace = { id, type, tools, score, summary, metadata }; const ageHours = (Date.now() - metadata.startTime) / (1e3 * 60 * 60); if (ageHours > this.config.compressionThreshold) { trace.compressed = this.compressTrace(trace); } return trace; } /** * Detect the type of trace based on tool patterns */ detectTraceType(tools) { const toolSequence = tools.map((t) => t.tool); for (const pattern of TRACE_PATTERNS) { if (this.matchesPattern(toolSequence, pattern.pattern)) { return pattern.type; } } if (toolSequence.includes("search") || toolSequence.includes("grep")) { if (toolSequence.includes("edit")) { return TraceType.SEARCH_DRIVEN; } return TraceType.EXPLORATION; } if (tools.some((t) => t.error)) { return TraceType.ERROR_RECOVERY; } if (toolSequence.includes("test")) { return TraceType.TESTING; } if (toolSequence.includes("write")) { return TraceType.FEATURE_IMPLEMENTATION; } return TraceType.UNKNOWN; } /** * Check if tool sequence matches a pattern */ matchesPattern(sequence, pattern) { if (pattern instanceof RegExp) { return pattern.test(sequence.join("\u2192")); } if (Array.isArray(pattern)) { let patternIndex = 0; for (const tool of sequence) { if (tool === pattern[patternIndex]) { patternIndex++; if (patternIndex >= pattern.length) { return true; } } } } return false; } /** * Extract metadata from tool calls */ extractMetadata(tools) { const startTime = tools[0].timestamp; const endTime = tools[tools.length - 1].timestamp; const filesModified = /* @__PURE__ */ new Set(); const errorsEncountered = []; const decisionsRecorded = []; let hasCausalChain = false; for (let i = 0; i < tools.length; i++) { const tool = tools[i]; if (tool.filesAffected) { tool.filesAffected.forEach((f) => filesModified.add(f)); } if (tool.error) { errorsEncountered.push(tool.error); if (i < tools.length - 1) { const nextTool = tools[i + 1]; if (this.isFixAttempt(nextTool, tool)) { hasCausalChain = true; } } } if (tool.tool === "decision_recording" && tool.arguments?.decision) { decisionsRecorded.push(tool.arguments.decision); } } return { startTime, endTime, filesModified: Array.from(filesModified), errorsEncountered, decisionsRecorded, causalChain: hasCausalChain }; } /** * Calculate importance score for a trace */ calculateTraceScore(tools, metadata) { const toolScores = tools.map( (t) => this.configManager.calculateScore(t.tool, { filesAffected: t.filesAffected?.length || 0, isPermanent: this.isPermanentChange(t), referenceCount: 0 // Would need to track references }) ); const maxScore = Math.max(...toolScores); let score = maxScore; if (metadata.causalChain) { score = Math.min(score + 0.1, 1); } if (metadata.decisionsRecorded.length > 0) { score = Math.min(score + 0.05 * metadata.decisionsRecorded.length, 1); } if (metadata.errorsEncountered.length > 0 && !metadata.causalChain) { score = Math.max(score - 0.1, 0); } return score; } /** * Check if a tool call represents a permanent change */ isPermanentChange(tool) { const permanentTools = ["write", "edit", "decision_recording"]; return permanentTools.includes(tool.tool); } /** * Generate a summary for the trace */ generateSummary(tools, type, metadata) { const toolChain = tools.map((t) => t.tool).join("\u2192"); switch (type) { case TraceType.SEARCH_DRIVEN: return `Search-driven modification: ${toolChain}`; case TraceType.ERROR_RECOVERY: const error = metadata.errorsEncountered[0] || "unknown error"; return `Error recovery: ${error} via ${toolChain}`; case TraceType.FEATURE_IMPLEMENTATION: const files = metadata.filesModified.length; return `Feature implementation: ${files} files via ${toolChain}`; case TraceType.REFACTORING: return `Code refactoring: ${toolChain}`; case TraceType.TESTING: return `Test execution: ${toolChain}`; case TraceType.EXPLORATION: return `Codebase exploration: ${toolChain}`; case TraceType.DEBUGGING: return `Debugging session: ${toolChain}`; case TraceType.BUILD_DEPLOY: return `Build and deploy: ${toolChain}`; default: return `Tool sequence: ${toolChain}`; } } /** * Compress a trace for long-term storage using strategy */ compressTrace(trace, strategy = CompressionStrategy.PATTERN_BASED) { switch (strategy) { case CompressionStrategy.SUMMARY_ONLY: return this.compressSummaryOnly(trace); case CompressionStrategy.PATTERN_BASED: return this.compressPatternBased(trace); case CompressionStrategy.SELECTIVE: return this.compressSelective(trace); case CompressionStrategy.FULL_COMPRESSION: return this.compressMaximal(trace); default: return this.compressPatternBased(trace); } } /** * Summary-only compression - minimal data retention */ compressSummaryOnly(trace) { return { pattern: "", // No pattern stored summary: trace.summary.substring(0, 100), // Limit summary score: trace.score, toolCount: trace.tools.length, duration: trace.metadata.endTime - trace.metadata.startTime, timestamp: trace.metadata.startTime }; } /** * Pattern-based compression - keep tool sequence */ compressPatternBased(trace) { const pattern = trace.tools.map((t) => t.tool).join("\u2192"); const duration = trace.metadata.endTime - trace.metadata.startTime; return { pattern, summary: trace.summary, score: trace.score, toolCount: trace.tools.length, duration, timestamp: trace.metadata.startTime }; } /** * Selective compression - keep high-score tools only */ compressSelective(trace, threshold = 0.5) { const significantTools = trace.tools.filter((tool) => { const score = this.configManager.calculateScore(tool.tool, { filesAffected: tool.filesAffected?.length || 0, isPermanent: this.isPermanentChange(tool), referenceCount: 0 }); return score >= threshold; }); const pattern = significantTools.length > 0 ? significantTools.map((t) => t.tool).join("\u2192") : trace.tools.map((t) => t.tool).join("\u2192"); return { pattern, summary: `${trace.summary} [${significantTools.length}/${trace.tools.length} significant]`, score: trace.score, toolCount: significantTools.length, duration: trace.metadata.endTime - trace.metadata.startTime, timestamp: trace.metadata.startTime }; } /** * Maximal compression - absolute minimum data */ compressMaximal(trace) { const typeAbbrev = this.getTraceTypeAbbreviation(trace.type); const pattern = `${typeAbbrev}:${trace.tools.length}`; return { pattern, summary: trace.type, // Just the type score: Math.round(trace.score * 10) / 10, // Round to 1 decimal toolCount: trace.tools.length, duration: Math.round((trace.metadata.endTime - trace.metadata.startTime) / 1e3) * 1e3, // Round to seconds timestamp: trace.metadata.startTime }; } /** * Get abbreviated trace type */ getTraceTypeAbbreviation(type) { const abbreviations = { [TraceType.SEARCH_DRIVEN]: "SD", [TraceType.ERROR_RECOVERY]: "ER", [TraceType.FEATURE_IMPLEMENTATION]: "FI", [TraceType.REFACTORING]: "RF", [TraceType.TESTING]: "TS", [TraceType.EXPLORATION]: "EX", [TraceType.DEBUGGING]: "DB", [TraceType.DOCUMENTATION]: "DC", [TraceType.BUILD_DEPLOY]: "BD", [TraceType.UNKNOWN]: "UN" }; return abbreviations[type] || "UN"; } /** * Choose compression strategy based on trace age and importance */ selectCompressionStrategy(trace) { const ageHours = (Date.now() - trace.metadata.startTime) / (1e3 * 60 * 60); const score = trace.score; if (ageHours < 24 && score > 0.7) { return CompressionStrategy.PATTERN_BASED; } if (ageHours < 24) { return CompressionStrategy.SELECTIVE; } if (ageHours < 168 && score > 0.5) { return CompressionStrategy.SELECTIVE; } if (ageHours < 720) { return CompressionStrategy.SUMMARY_ONLY; } return CompressionStrategy.FULL_COMPRESSION; } /** * Get directory from file path */ getDirectory(filePath) { const parts = filePath.split("/"); parts.pop(); return parts.join("/"); } /** * Flush any pending trace */ flush() { if (this.activeTrace.length > 0) { this.finalizeTrace(); } } /** * Get all detected traces */ getTraces() { return this.traces; } /** * Get traces by type */ getTracesByType(type) { return this.traces.filter((t) => t.type === type); } /** * Get high-importance traces */ getHighImportanceTraces(threshold = 0.7) { return this.traces.filter((t) => t.score >= threshold); } /** * Compress old traces with intelligent strategy selection */ compressOldTraces(ageHours = 24) { let compressed = 0; const now = Date.now(); for (const trace of this.traces) { const age = (now - trace.metadata.startTime) / (1e3 * 60 * 60); if (age > ageHours && !trace.compressed) { const strategy = this.selectCompressionStrategy(trace); trace.compressed = this.compressTrace(trace, strategy); if (strategy === CompressionStrategy.FULL_COMPRESSION || strategy === CompressionStrategy.SUMMARY_ONLY) { trace.tools = []; } else if (strategy === CompressionStrategy.SELECTIVE) { trace.tools = trace.tools.filter((tool) => { const score = this.configManager.calculateScore(tool.tool, { filesAffected: tool.filesAffected?.length || 0, isPermanent: this.isPermanentChange(tool), referenceCount: 0 }); return score >= 0.5; }); } compressed++; if (this.traceStore) { try { this.traceStore.updateCompression( trace.id, trace.compressed, strategy ); } catch (error) { console.error( "Failed to update trace compression in store:", error ); } } } } return compressed; } /** * Export traces for analysis */ exportTraces() { return JSON.stringify(this.traces, null, 2); } /** * Get statistics about traces */ getStatistics() { const stats = { totalTraces: this.traces.length, tracesByType: {}, averageScore: 0, averageLength: 0, compressedCount: 0, highImportanceCount: 0 }; if (this.traces.length === 0) return stats; let totalScore = 0; let totalLength = 0; for (const trace of this.traces) { stats.tracesByType[trace.type] = (stats.tracesByType[trace.type] || 0) + 1; totalScore += trace.score; totalLength += trace.tools.length; if (trace.compressed) { stats.compressedCount++; } if (trace.score >= 0.7) { stats.highImportanceCount++; } } stats.averageScore = totalScore / this.traces.length; stats.averageLength = totalLength / this.traces.length; return stats; } } export { TraceDetector };