@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.
529 lines (528 loc) • 15.9 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 { 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
};
//# sourceMappingURL=trace-detector.js.map