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.

590 lines (589 loc) 17.4 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { DEFAULT_RETRIEVAL_CONFIG } from "./types.js"; import { logger } from "../monitoring/logger.js"; class CompressedSummaryGenerator { db; frameManager; traceDetector; projectId; config; cache = /* @__PURE__ */ new Map(); constructor(db, frameManager, projectId, config = {}, traceDetector) { this.db = db; this.frameManager = frameManager; this.projectId = projectId; this.config = { ...DEFAULT_RETRIEVAL_CONFIG, ...config }; this.traceDetector = traceDetector; } /** * Generate a compressed summary for LLM analysis */ generateSummary(options = {}) { const cacheKey = `summary_${this.projectId}_${options.maxFrames || this.config.maxSummaryFrames}`; if (!options.forceRefresh) { const cached = this.cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { logger.debug("Using cached summary", { projectId: this.projectId }); return cached.summary; } } const startTime = Date.now(); const maxFrames = options.maxFrames || this.config.maxSummaryFrames; const timeRangeHours = options.timeRangeHours || 24; const recentSession = this.generateRecentSessionSummary( maxFrames, timeRangeHours ); const historicalPatterns = this.generateHistoricalPatterns(); const queryableIndices = this.generateQueryableIndices(); const stats = this.generateStats(); const summary = { projectId: this.projectId, generatedAt: Date.now(), recentSession, historicalPatterns, queryableIndices, stats }; this.cache.set(cacheKey, { summary, expiresAt: Date.now() + this.config.cacheTtlSeconds * 1e3 }); logger.info("Generated compressed summary", { projectId: this.projectId, frames: recentSession.frames.length, generationTimeMs: Date.now() - startTime }); return summary; } /** * Generate recent session summary */ generateRecentSessionSummary(maxFrames, timeRangeHours) { const cutoffTime = Math.floor(Date.now() / 1e3) - timeRangeHours * 3600; const frames = this.getRecentFrames(maxFrames, cutoffTime); const frameSummaries = frames.map((f) => this.summarizeFrame(f)); const dominantOperations = this.getDominantOperations(cutoffTime); const filesTouched = this.getFilesTouched(cutoffTime); const errorsEncountered = this.getErrorsEncountered(cutoffTime); const timestamps = frames.map((f) => f.created_at).filter((t) => t); const start = timestamps.length > 0 ? Math.min(...timestamps) : cutoffTime; const end = timestamps.length > 0 ? Math.max(...timestamps) : Math.floor(Date.now() / 1e3); return { frames: frameSummaries, dominantOperations, filesTouched, errorsEncountered, timeRange: { start: start * 1e3, end: end * 1e3, durationMs: (end - start) * 1e3 } }; } /** * Generate historical patterns */ generateHistoricalPatterns() { return { topicFrameCounts: this.getTopicFrameCounts(), keyDecisions: this.getKeyDecisions(), recurringIssues: this.getRecurringIssues(), commonToolSequences: this.getCommonToolSequences(), activityPatterns: this.getActivityPatterns() }; } /** * Generate queryable indices */ generateQueryableIndices() { return { byErrorType: this.indexByErrorType(), byTimeframe: this.indexByTimeframe(), byContributor: this.indexByContributor(), byTopic: this.indexByTopic(), byFile: this.indexByFile() }; } /** * Generate summary statistics */ generateStats() { try { const frameStats = this.db.prepare( ` SELECT COUNT(*) as totalFrames, MIN(created_at) as oldestFrame, MAX(created_at) as newestFrame, AVG(depth) as avgDepth FROM frames WHERE project_id = ? ` ).get(this.projectId) || {}; const eventCount = this.db.prepare( ` SELECT COUNT(*) as count FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? ` ).get(this.projectId) || { count: 0 }; const anchorCount = this.db.prepare( ` SELECT COUNT(*) as count FROM anchors a JOIN frames f ON a.frame_id = f.frame_id WHERE f.project_id = ? ` ).get(this.projectId) || { count: 0 }; const decisionCount = this.db.prepare( ` SELECT COUNT(*) as count FROM anchors a JOIN frames f ON a.frame_id = f.frame_id WHERE f.project_id = ? AND a.type = 'DECISION' ` ).get(this.projectId) || { count: 0 }; const totalFrames = frameStats.totalFrames || 0; return { totalFrames, totalEvents: eventCount.count || 0, totalAnchors: anchorCount.count || 0, totalDecisions: decisionCount.count || 0, oldestFrame: (frameStats.oldestFrame || 0) * 1e3, newestFrame: (frameStats.newestFrame || 0) * 1e3, avgFrameDepth: frameStats.avgDepth || 0, avgEventsPerFrame: totalFrames > 0 ? (eventCount.count || 0) / totalFrames : 0 }; } catch (error) { logger.warn("Error generating stats, using defaults", { error }); return { totalFrames: 0, totalEvents: 0, totalAnchors: 0, totalDecisions: 0, oldestFrame: 0, newestFrame: 0, avgFrameDepth: 0, avgEventsPerFrame: 0 }; } } // Helper methods for recent session getRecentFrames(limit, cutoffTime) { try { const rows = this.db.prepare( ` SELECT * FROM frames WHERE project_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ? ` ).all(this.projectId, cutoffTime, limit); return rows.map((row) => ({ ...row, inputs: JSON.parse(row.inputs || "{}"), outputs: JSON.parse(row.outputs || "{}"), digest_json: JSON.parse(row.digest_json || "{}") })); } catch { return []; } } summarizeFrame(frame) { const eventCount = this.db.prepare( ` SELECT COUNT(*) as count FROM events WHERE frame_id = ? ` ).get(frame.frame_id) || { count: 0 }; const anchorCount = this.db.prepare( ` SELECT COUNT(*) as count FROM anchors WHERE frame_id = ? ` ).get(frame.frame_id) || { count: 0 }; const score = this.calculateFrameScore( frame, eventCount.count, anchorCount.count ); return { frameId: frame.frame_id, name: frame.name, type: frame.type, depth: frame.depth, eventCount: eventCount.count, anchorCount: anchorCount.count, score, createdAt: frame.created_at * 1e3, closedAt: frame.closed_at ? frame.closed_at * 1e3 : void 0, digestPreview: frame.digest_text?.substring(0, 100) }; } calculateFrameScore(frame, eventCount, anchorCount) { let score = 0.3; score += Math.min(eventCount / 50, 0.3); score += Math.min(anchorCount / 10, 0.2); const ageHours = (Date.now() / 1e3 - frame.created_at) / 3600; if (ageHours < 1) score += 0.2; else if (ageHours < 6) score += 0.1; if (frame.state === "active") score += 0.1; return Math.min(score, 1); } getDominantOperations(cutoffTime) { try { const rows = this.db.prepare( ` SELECT e.event_type as operation, COUNT(*) as count, MAX(e.ts) as lastOccurrence, SUM(CASE WHEN json_extract(e.payload, '$.success') = 1 THEN 1 ELSE 0 END) as successCount FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? AND e.ts >= ? GROUP BY e.event_type ORDER BY count DESC LIMIT 10 ` ).all(this.projectId, cutoffTime); return rows.map((row) => ({ operation: row.operation, count: row.count, lastOccurrence: row.lastOccurrence * 1e3, successRate: row.count > 0 ? row.successCount / row.count : 0 })); } catch { return []; } } getFilesTouched(cutoffTime) { try { const rows = this.db.prepare( ` SELECT json_extract(e.payload, '$.file_path') as path, e.event_type as operation, MAX(e.ts) as lastModified FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? AND e.ts >= ? AND json_extract(e.payload, '$.file_path') IS NOT NULL GROUP BY json_extract(e.payload, '$.file_path'), e.event_type ` ).all(this.projectId, cutoffTime); const fileMap = /* @__PURE__ */ new Map(); for (const row of rows) { if (!row.path) continue; const existing = fileMap.get(row.path); if (existing) { existing.operationCount++; existing.operations.push(row.operation); existing.lastModified = Math.max( existing.lastModified, row.lastModified * 1e3 ); } else { fileMap.set(row.path, { path: row.path, operationCount: 1, lastModified: row.lastModified * 1e3, operations: [row.operation] }); } } return Array.from(fileMap.values()).sort((a, b) => b.operationCount - a.operationCount).slice(0, 20); } catch { return []; } } getErrorsEncountered(cutoffTime) { try { const rows = this.db.prepare( ` SELECT json_extract(e.payload, '$.error_type') as errorType, json_extract(e.payload, '$.error') as message, COUNT(*) as count, MAX(e.ts) as lastOccurrence FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? AND e.ts >= ? AND (json_extract(e.payload, '$.error') IS NOT NULL OR json_extract(e.payload, '$.error_type') IS NOT NULL) GROUP BY json_extract(e.payload, '$.error_type'), json_extract(e.payload, '$.error') ORDER BY count DESC LIMIT 15 ` ).all(this.projectId, cutoffTime); return rows.map((row) => ({ errorType: row.errorType || "unknown", message: row.message || "", count: row.count, lastOccurrence: row.lastOccurrence * 1e3, resolved: false // Would need more context to determine })); } catch { return []; } } // Helper methods for historical patterns getTopicFrameCounts() { try { const rows = this.db.prepare( ` SELECT type, COUNT(*) as count FROM frames WHERE project_id = ? GROUP BY type ` ).all(this.projectId); const counts = {}; for (const row of rows) { counts[row.type] = row.count; } return counts; } catch { return {}; } } getKeyDecisions() { try { const rows = this.db.prepare( ` SELECT a.anchor_id as id, a.text, a.frame_id as frameId, a.created_at as timestamp, a.priority FROM anchors a JOIN frames f ON a.frame_id = f.frame_id WHERE f.project_id = ? AND a.type = 'DECISION' ORDER BY a.priority DESC, a.created_at DESC LIMIT 20 ` ).all(this.projectId); return rows.map((row) => ({ id: row.id, text: row.text, frameId: row.frameId, timestamp: row.timestamp * 1e3, impact: row.priority >= 7 ? "high" : row.priority >= 4 ? "medium" : "low" })); } catch { return []; } } getRecurringIssues() { try { const rows = this.db.prepare( ` SELECT json_extract(e.payload, '$.error_type') as issueType, COUNT(*) as occurrenceCount, MAX(e.ts) as lastSeen FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? AND json_extract(e.payload, '$.error_type') IS NOT NULL GROUP BY json_extract(e.payload, '$.error_type') HAVING COUNT(*) > 1 ORDER BY occurrenceCount DESC LIMIT 10 ` ).all(this.projectId); return rows.map((row) => ({ issueType: row.issueType, occurrenceCount: row.occurrenceCount, lastSeen: row.lastSeen * 1e3, resolutionRate: 0.5 // Would need resolution tracking })); } catch { return []; } } getCommonToolSequences() { if (this.traceDetector) { const stats = this.traceDetector.getStatistics(); const sequences = []; for (const [type, count] of Object.entries(stats.tracesByType)) { sequences.push({ pattern: type, frequency: count, avgDuration: 0, // Would need more data successRate: 0.8 // Estimate }); } return sequences; } return []; } getActivityPatterns() { try { const hourlyRows = this.db.prepare( ` SELECT strftime('%H', datetime(e.ts, 'unixepoch')) as hour, COUNT(*) as count FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? GROUP BY hour ORDER BY count DESC ` ).all(this.projectId); const peakHours = hourlyRows.slice(0, 3).map((r) => `${r.hour}:00`); const totalEvents = hourlyRows.reduce((sum, r) => sum + r.count, 0); return [ { periodType: "hourly", peakPeriods: peakHours, avgEventsPerPeriod: totalEvents / 24 } ]; } catch { return []; } } // Helper methods for queryable indices indexByErrorType() { try { const rows = this.db.prepare( ` SELECT DISTINCT json_extract(e.payload, '$.error_type') as errorType, f.frame_id FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? AND json_extract(e.payload, '$.error_type') IS NOT NULL ` ).all(this.projectId); const index = {}; for (const row of rows) { if (!row.errorType) continue; if (!index[row.errorType]) index[row.errorType] = []; if (!index[row.errorType].includes(row.frame_id)) { index[row.errorType].push(row.frame_id); } } return index; } catch { return {}; } } indexByTimeframe() { try { const now = Math.floor(Date.now() / 1e3); const timeframes = { last_hour: now - 3600, last_day: now - 86400, last_week: now - 604800, last_month: now - 2592e3 }; const index = {}; for (const [label, cutoff] of Object.entries(timeframes)) { const rows = this.db.prepare( ` SELECT frame_id FROM frames WHERE project_id = ? AND created_at >= ? ` ).all(this.projectId, cutoff); index[label] = rows.map((r) => r.frame_id); } return index; } catch { return {}; } } indexByContributor() { return {}; } indexByTopic() { try { const rows = this.db.prepare( ` SELECT frame_id, type, name FROM frames WHERE project_id = ? ` ).all(this.projectId); const index = {}; for (const row of rows) { if (!index[row.type]) index[row.type] = []; index[row.type].push(row.frame_id); const keywords = this.extractKeywords(row.name); for (const keyword of keywords) { if (!index[keyword]) index[keyword] = []; if (!index[keyword].includes(row.frame_id)) { index[keyword].push(row.frame_id); } } } return index; } catch { return {}; } } indexByFile() { try { const rows = this.db.prepare( ` SELECT DISTINCT json_extract(e.payload, '$.file_path') as filePath, f.frame_id FROM events e JOIN frames f ON e.frame_id = f.frame_id WHERE f.project_id = ? AND json_extract(e.payload, '$.file_path') IS NOT NULL ` ).all(this.projectId); const index = {}; for (const row of rows) { if (!row.filePath) continue; if (!index[row.filePath]) index[row.filePath] = []; if (!index[row.filePath].includes(row.frame_id)) { index[row.filePath].push(row.frame_id); } } return index; } catch { return {}; } } extractKeywords(text) { const stopWords = /* @__PURE__ */ new Set([ "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for" ]); return text.toLowerCase().split(/\W+/).filter((word) => word.length > 2 && !stopWords.has(word)); } /** * Clear the cache */ clearCache() { this.cache.clear(); logger.debug("Summary cache cleared", { projectId: this.projectId }); } } export { CompressedSummaryGenerator }; //# sourceMappingURL=summary-generator.js.map