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.

622 lines (621 loc) 20.3 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 * as fs from "fs/promises"; import * as path from "path"; import { sessionManager } from "../session/session-manager.js"; import { logger } from "../monitoring/logger.js"; class SharedContextLayer { static instance; contextDir; cache = /* @__PURE__ */ new Map(); MAX_CACHE_SIZE = 100; CACHE_TTL = 5 * 60 * 1e3; // 5 minutes lastCacheClean = Date.now(); constructor() { const homeDir = process.env["HOME"] || process.env["USERPROFILE"] || ""; this.contextDir = path.join(homeDir, ".stackmemory", "shared-context"); } static getInstance() { if (!SharedContextLayer.instance) { SharedContextLayer.instance = new SharedContextLayer(); } return SharedContextLayer.instance; } async initialize() { await fs.mkdir(this.contextDir, { recursive: true }); await fs.mkdir(path.join(this.contextDir, "projects"), { recursive: true }); await fs.mkdir(path.join(this.contextDir, "patterns"), { recursive: true }); await fs.mkdir(path.join(this.contextDir, "decisions"), { recursive: true }); } /** * Get or create shared context for current project/branch */ async getSharedContext(options) { const session = sessionManager.getCurrentSession(); const projectId = options?.projectId || session?.projectId || "global"; const branch = options?.branch || session?.branch; const cacheKey = `${projectId}:${branch || "main"}`; if (this.cache.has(cacheKey)) { const cached = this.cache.get(cacheKey); if (Date.now() - cached.lastUpdated < this.CACHE_TTL) { return cached; } } const context = await this.loadProjectContext(projectId, branch); if (options?.includeOtherBranches) { const otherBranches = await this.loadOtherBranchContexts( projectId, branch ); context.sessions.push(...otherBranches); } this.cache.set(cacheKey, context); this.cleanCache(); return context; } /** * Add current session's important frames to shared context */ async addToSharedContext(frames, options) { const session = sessionManager.getCurrentSession(); if (!session) return; const context = await this.getSharedContext(); const minScore = options?.minScore || 0.7; const importantFrames = frames.filter((f) => { const score = this.calculateFrameScore(f); return score >= minScore; }); const sessionContext = { sessionId: session.sessionId, runId: session.runId, summary: this.generateSessionSummary(importantFrames), keyFrames: importantFrames.map((f) => this.summarizeFrame(f)), createdAt: session.startedAt, lastActiveAt: Date.now(), metadata: session.metadata }; const existingIndex = context.sessions.findIndex( (s) => s.sessionId === session.sessionId ); if (existingIndex >= 0) { context.sessions[existingIndex] = sessionContext; } else { context.sessions.push(sessionContext); } this.updatePatterns(context, importantFrames); this.updateReferenceIndex(context, importantFrames); await this.saveProjectContext(context); } /** * Query shared context for relevant frames */ async querySharedContext(query) { const context = await this.getSharedContext({ includeOtherBranches: true }); let results = []; for (const session of context.sessions) { if (query.sessionId && session.sessionId !== query.sessionId) continue; if (!session.keyFrames || !Array.isArray(session.keyFrames)) continue; const filtered = session.keyFrames.filter((f) => { if (query.tags && !query.tags.some((tag) => f.tags.includes(tag))) return false; if (query.type && f.type !== query.type) return false; if (query.minScore && f.score < query.minScore) return false; return true; }); results.push(...filtered); } results.sort((a, b) => { const scoreWeight = 0.7; const recencyWeight = 0.3; const aScore = a.score * scoreWeight + (1 - (Date.now() - a.createdAt) / (30 * 24 * 60 * 60 * 1e3)) * recencyWeight; const bScore = b.score * scoreWeight + (1 - (Date.now() - b.createdAt) / (30 * 24 * 60 * 60 * 1e3)) * recencyWeight; return bScore - aScore; }); if (query.limit) { results = results.slice(0, query.limit); } const index = context.referenceIndex; if (!index.recentlyAccessed) { index.recentlyAccessed = []; } if (results.length > 0) { const frameIds = results.map((r) => r.frameId); index.recentlyAccessed = [ ...frameIds, ...index.recentlyAccessed.filter((id) => !frameIds.includes(id)) ].slice(0, 100); await this.saveProjectContext(context); } return results; } /** * Get relevant patterns from shared context */ async getPatterns(type) { const context = await this.getSharedContext(); if (type) { return context.globalPatterns.filter((p) => p.type === type); } return context.globalPatterns; } /** * Add a decision to the shared context */ async addDecision(decision) { const session = sessionManager.getCurrentSession(); if (!session) return; const context = await this.getSharedContext(); const newDecision = { id: uuidv4(), timestamp: Date.now(), sessionId: session.sessionId, outcome: "pending", ...decision }; context.decisionLog.push(newDecision); if (context.decisionLog.length > 100) { context.decisionLog = context.decisionLog.slice(-100); } await this.saveProjectContext(context); } /** * Get recent decisions from shared context */ async getDecisions(limit = 10) { const context = await this.getSharedContext(); return context.decisionLog.slice(-limit); } /** * Automatic context discovery on CLI startup */ async autoDiscoverContext() { const context = await this.getSharedContext({ includeOtherBranches: false }); const recentPatterns = context.globalPatterns.filter((p) => Date.now() - p.lastSeen < 7 * 24 * 60 * 60 * 1e3).sort((a, b) => b.frequency - a.frequency).slice(0, 5); const lastDecisions = context.decisionLog.slice(-5); const suggestedFrames = await this.querySharedContext({ minScore: 0.8, limit: 5 }); return { hasSharedContext: context.sessions.length > 0, sessionCount: context.sessions.length, recentPatterns, lastDecisions, suggestedFrames }; } async loadProjectContext(projectId, branch) { const contextFile = path.join( this.contextDir, "projects", `${projectId}_${branch || "main"}.json` ); try { const data = await fs.readFile(contextFile, "utf-8"); const context = JSON.parse(data); context.referenceIndex.byTag = new Map( Object.entries(context.referenceIndex.byTag || {}) ); context.referenceIndex.byType = new Map( Object.entries(context.referenceIndex.byType || {}) ); return context; } catch { return { projectId, branch, lastUpdated: Date.now(), sessions: [], globalPatterns: [], decisionLog: [], referenceIndex: { byTag: /* @__PURE__ */ new Map(), byType: /* @__PURE__ */ new Map(), byScore: [], recentlyAccessed: [] } }; } } async saveProjectContext(context) { const contextFile = path.join( this.contextDir, "projects", `${context.projectId}_${context.branch || "main"}.json` ); const serializable = { ...context, lastUpdated: Date.now(), referenceIndex: { ...context.referenceIndex, byTag: Object.fromEntries(context.referenceIndex.byTag), byType: Object.fromEntries(context.referenceIndex.byType) } }; await fs.writeFile(contextFile, JSON.stringify(serializable, null, 2)); } async loadOtherBranchContexts(projectId, currentBranch) { const projectsDir = path.join(this.contextDir, "projects"); const files = await fs.readdir(projectsDir); const sessions = []; for (const file of files) { if (file.startsWith(`${projectId}_`) && !file.includes(currentBranch || "main")) { try { const data = await fs.readFile(path.join(projectsDir, file), "utf-8"); const context = JSON.parse(data); sessions.push(...context.sessions); } catch { } } } return sessions; } calculateFrameScore(frame) { let score = 0.5; if (frame.type === "task" || frame.type === "review") score += 0.2; if (frame.type === "debug" || frame.type === "write") score += 0.15; if (frame.type === "error") score += 0.15; const frameWithData = frame; if (frameWithData.data) score += 0.2; if (frame.outputs && Object.keys(frame.outputs).length > 0) score += 0.2; if (frame.digest_text || frame.digest_json && Object.keys(frame.digest_json).length > 0) score += 0.1; if (frame.created_at) { const age = Date.now() - frame.created_at; const daysSinceCreation = age / (24 * 60 * 60 * 1e3); score *= Math.max(0.3, 1 - daysSinceCreation / 30); } return Math.min(1, score); } summarizeFrame(frame) { return { frameId: frame.frame_id, title: frame.name, type: frame.type, score: this.calculateFrameScore(frame), tags: [], summary: this.generateFrameSummary(frame), createdAt: frame.created_at }; } generateFrameSummary(frame) { const parts = []; const frameWithData = frame; if (frame.type) parts.push(`[${frame.type}]`); if (frame.name) parts.push(frame.name); if (frameWithData.title) parts.push(frameWithData.title); if (frameWithData.data?.error) parts.push(`Error: ${frameWithData.data.error}`); if (frameWithData.data?.resolution) parts.push(`Resolution: ${frameWithData.data.resolution}`); return parts.join(" - ").slice(0, 200); } generateSessionSummary(frames) { const types = [...new Set(frames.map((f) => f.type))]; return `Session with ${frames.length} key frames: ${types.join(", ")}`; } updatePatterns(context, frames) { for (const frame of frames) { const frameWithData = frame; if (frameWithData.data?.error) { this.addPattern( context, frameWithData.data.error, "error", frameWithData.data?.resolution ); } else if (frame.type === "error" && frame.name) { const errorText = frame.outputs?.error || frame.name; const resolution = frame.outputs?.resolution; if (errorText) { this.addPattern(context, errorText, "error", resolution); } } if (frame.type === "decision" && frameWithData.data?.decision) { this.addPattern(context, frameWithData.data.decision, "decision"); } else if (frame.digest_json?.decision) { this.addPattern(context, frame.digest_json.decision, "decision"); } } } addPattern(context, pattern, type, resolution) { const existing = context.globalPatterns.find( (p) => p.pattern === pattern && p.type === type ); if (existing) { existing.frequency++; existing.lastSeen = Date.now(); if (resolution) existing.resolution = resolution; } else { context.globalPatterns.push({ pattern, type, frequency: 1, lastSeen: Date.now(), resolution }); } if (context.globalPatterns.length > 100) { context.globalPatterns.sort((a, b) => b.frequency - a.frequency); context.globalPatterns = context.globalPatterns.slice(0, 100); } } updateReferenceIndex(context, frames) { for (const frame of frames) { const summary = this.summarizeFrame(frame); for (const tag of summary.tags) { if (!context.referenceIndex.byTag.has(tag)) { context.referenceIndex.byTag.set(tag, []); } context.referenceIndex.byTag.get(tag).push(frame.frameId); } if (!context.referenceIndex.byType.has(frame.type)) { context.referenceIndex.byType.set(frame.type, []); } context.referenceIndex.byType.get(frame.type).push(frame.frameId); const scoreIndex = context.referenceIndex.byScore; const insertIndex = scoreIndex.findIndex((id) => { const otherFrame = context.sessions.flatMap((s) => s.keyFrames).find((f) => f.frameId === id); return otherFrame && otherFrame.score < summary.score; }); if (insertIndex >= 0) { scoreIndex.splice(insertIndex, 0, frame.frameId); } else { scoreIndex.push(frame.frameId); } context.referenceIndex.byScore = scoreIndex.slice(0, 1e3); } } cleanCache() { if (Date.now() - this.lastCacheClean < 6e4) return; if (this.cache.size > this.MAX_CACHE_SIZE) { const entries = Array.from(this.cache.entries()).sort( (a, b) => b[1].lastUpdated - a[1].lastUpdated ); this.cache = new Map(entries.slice(0, this.MAX_CACHE_SIZE / 2)); } this.lastCacheClean = Date.now(); } } const sharedContextLayer = SharedContextLayer.getInstance(); class ContextBridge { static instance; frameManager = null; syncTimer = null; lastSyncTime = 0; options = { autoSync: true, syncInterval: 6e4, // 1 minute minFrameScore: 0.5, // Include frames with score above 0.5 importantTags: ["decision", "error", "milestone", "learning"] }; constructor() { } static getInstance() { if (!ContextBridge.instance) { ContextBridge.instance = new ContextBridge(); } return ContextBridge.instance; } /** * Initialize the bridge with a frame manager */ async initialize(frameManager, options) { this.frameManager = frameManager; this.options = { ...this.options, ...options }; await this.loadSharedContext(); if (this.options.autoSync) { this.startAutoSync(); } logger.info("Context bridge initialized", { autoSync: this.options.autoSync, syncInterval: this.options.syncInterval }); } /** * Load relevant shared context into current session */ async loadSharedContext() { try { const session = sessionManager.getCurrentSession(); if (!session) return; const discovery = await sharedContextLayer.autoDiscoverContext(); if (!discovery.hasSharedContext) { logger.info("No shared context available to load"); return; } if (discovery.recentPatterns.length > 0) { logger.info("Loaded recent patterns from shared context", { patternCount: discovery.recentPatterns.length }); } if (discovery.lastDecisions.length > 0) { logger.info("Loaded recent decisions from shared context", { decisionCount: discovery.lastDecisions.length }); } if (discovery.suggestedFrames.length > 0) { const metadata = { suggestedFrames: discovery.suggestedFrames, loadedAt: Date.now() }; if (this.frameManager) { await this.frameManager.addContext( "shared-context-suggestions", metadata ); } logger.info("Loaded suggested frames from shared context", { frameCount: discovery.suggestedFrames.length }); } } catch (error) { logger.error("Failed to load shared context", error); } } /** * Sync current session's important frames to shared context */ async syncToSharedContext() { try { if (!this.frameManager) return; const session = sessionManager.getCurrentSession(); if (!session) return; const activeFrames = this.frameManager.getActiveFramePath().filter(Boolean); const recentFrames = await this.frameManager.getRecentFrames(100); const allFrames = [...activeFrames, ...recentFrames].filter(Boolean); const importantFrames = this.filterImportantFrames(allFrames); if (importantFrames.length === 0) { logger.debug("No important frames to sync"); return; } await sharedContextLayer.addToSharedContext(importantFrames, { minScore: this.options.minFrameScore, tags: this.options.importantTags }); this.lastSyncTime = Date.now(); logger.info("Synced frames to shared context", { frameCount: importantFrames.length, sessionId: session.sessionId }); } catch (error) { logger.error("Failed to sync to shared context", error); } } /** * Query shared context for relevant frames */ async querySharedFrames(query) { try { const results = await sharedContextLayer.querySharedContext({ ...query, minScore: this.options.minFrameScore }); logger.info("Queried shared context", { query, resultCount: results.length }); return results; } catch (error) { logger.error("Failed to query shared context", error); return []; } } /** * Add a decision to shared context */ async addDecision(decision, reasoning) { try { await sharedContextLayer.addDecision({ decision, reasoning, outcome: "pending" }); logger.info("Added decision to shared context", { decision }); } catch (error) { logger.error("Failed to add decision", error); } } /** * Start automatic synchronization */ startAutoSync() { if (this.syncTimer) { clearInterval(this.syncTimer); } this.syncTimer = setInterval(() => { this.syncToSharedContext().catch((error) => { logger.error("Auto-sync failed", error); }); }, this.options.syncInterval); this.setupEventListeners(); } /** * Stop automatic synchronization */ stopAutoSync() { if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = null; } } /** * Filter frames that are important enough to share */ filterImportantFrames(frames) { return frames.filter((frame) => { const hasImportantTag = this.options.importantTags.some( (tag) => frame.metadata?.tags?.includes(tag) ); const isImportantType = [ "task", "milestone", "error", "resolution", "decision" ].includes(frame.type); const markedImportant = frame.metadata?.importance === "high"; return hasImportantTag || isImportantType || markedImportant; }); } /** * Setup event listeners for automatic syncing */ setupEventListeners() { if (!this.frameManager) return; const originalClose = this.frameManager.closeFrame.bind(this.frameManager); this.frameManager.closeFrame = async (frameId, metadata) => { const result = await originalClose(frameId, metadata); const frame = await this.frameManager.getFrame(frameId); if (frame && this.filterImportantFrames([frame]).length > 0) { await this.syncToSharedContext(); } return result; }; const originalMilestone = this.frameManager.createFrame.bind( this.frameManager ); this.frameManager.createFrame = async (params) => { const result = await originalMilestone(params); if (params.type === "milestone") { await this.syncToSharedContext(); } return result; }; } /** * Get sync statistics */ getSyncStats() { return { lastSyncTime: this.lastSyncTime, autoSyncEnabled: this.options.autoSync, syncInterval: this.options.syncInterval }; } /** * Manual trigger for immediate sync */ async forceSyncNow() { logger.info("Force sync triggered"); await this.syncToSharedContext(); } } const contextBridge = ContextBridge.getInstance(); export { ContextBridge, SharedContextLayer, contextBridge, sharedContextLayer }; //# sourceMappingURL=shared-context-layer.js.map