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

465 lines (464 loc) 15.7 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import * as fs from "fs/promises"; import * as path from "path"; class ClearSurvival { frameManager; dbManager; handoffGenerator; ledgerPath; continuityPath; // Thresholds CONTEXT_WARNING_THRESHOLD = 0.6; // 60% CONTEXT_CRITICAL_THRESHOLD = 0.7; // 70% CONTEXT_MAX_THRESHOLD = 0.85; // 85% - force save constructor(frameManager, dbManager, handoffGenerator, projectRoot) { this.frameManager = frameManager; this.dbManager = dbManager; this.handoffGenerator = handoffGenerator; this.ledgerPath = path.join(projectRoot, ".stackmemory", "ledgers"); this.continuityPath = path.join(projectRoot, ".stackmemory", "continuity"); } /** * Get current context usage statistics */ async getContextUsage() { try { const frames = await this.frameManager.getAllFrames(); const activeFrames = frames.filter((f) => f.status === "open").length; const _sessionId = await this.dbManager.getCurrentSessionId(); const estimatedTokens = frames.length * 100; const maxTokens = 1e4; const percentageUsed = Math.min(100, estimatedTokens / maxTokens * 100); return { totalFrames: frames.length, activeFrames, sessionCount: 1, percentageUsed }; } catch { return { totalFrames: 50, activeFrames: 3, sessionCount: 2, percentageUsed: 25 }; } } /** * Assess context status based on usage */ assessContextStatus(usage) { if (usage.percentageUsed < 50) return "healthy"; if (usage.percentageUsed < 70) return "moderate"; if (usage.percentageUsed < 85) return "critical"; return "saved"; } /** * Monitor context usage and trigger saves when needed */ async monitorContextUsage(currentTokens, maxTokens) { const usage = currentTokens / maxTokens; if (usage < this.CONTEXT_WARNING_THRESHOLD) { return "ok"; } if (usage >= this.CONTEXT_MAX_THRESHOLD) { await this.saveContinuityLedger(); return "saved"; } if (usage >= this.CONTEXT_CRITICAL_THRESHOLD) { console.warn( `\u26A0\uFE0F Context at ${Math.round(usage * 100)}% - Consider /clear after saving` ); return "critical"; } console.warn(`Context at ${Math.round(usage * 100)}% - Approaching limit`); return "warning"; } /** * Save continuity ledger before /clear */ async saveContinuityLedger() { const sessionId = await this.dbManager.getCurrentSessionId(); const session = await this.dbManager.getSession(sessionId); const frameStack = await this.getCompressedFrameStack(); const decisions = await this.getCriticalDecisions(); const tasks = await this.getActiveTasks(); const context = await this.getCriticalContext(); const achievements = await this.getRecentAchievements(); const originalTokens = await this.estimateCurrentTokens(); const compressedTokens = this.estimateLedgerTokens( frameStack, decisions, tasks ); const ledger = { version: "1.0.0", timestamp: (/* @__PURE__ */ new Date()).toISOString(), session_id: sessionId, project: session?.project || "unknown", branch: session?.metadata?.branch, active_frame_stack: frameStack, key_decisions: decisions, active_tasks: tasks, critical_context: context, recent_achievements: achievements, current_focus: await this.getCurrentFocus(), next_actions: await this.suggestNextActions(tasks), warnings: await this.getWarnings(), original_token_count: originalTokens, compressed_token_count: compressedTokens, compression_ratio: originalTokens / compressedTokens }; await this.saveLedgerToFile(ledger); await this.saveBackupLedger(ledger); console.log( `\u2705 Continuity ledger saved (${Math.round(ledger.compression_ratio)}x compression)` ); return ledger; } /** * Restore from continuity ledger after /clear */ async restoreFromLedger() { try { const ledger = await this.loadLatestLedger(); if (!ledger) { console.log("No continuity ledger found"); return false; } console.log(`\u{1F4DA} Restoring from ledger (${ledger.timestamp})`); await this.restoreFrameStructure(ledger.active_frame_stack); await this.restoreDecisions(ledger.key_decisions); await this.restoreTasks(ledger.active_tasks); console.log(`\u2705 Restored:`); console.log(` - ${ledger.active_frame_stack.length} frames`); console.log(` - ${ledger.key_decisions.length} decisions`); console.log(` - ${ledger.active_tasks.length} tasks`); console.log(` - Current focus: ${ledger.current_focus}`); if (ledger.warnings.length > 0) { console.warn(`\u26A0\uFE0F Warnings:`, ledger.warnings); } return true; } catch (error) { console.error("Failed to restore from ledger:", error); return false; } } /** * Generate markdown summary for human review */ async generateLedgerMarkdown(ledger) { const lines = [ `# Continuity Ledger`, `**Saved**: ${new Date(ledger.timestamp).toLocaleString()}`, `**Project**: ${ledger.project}${ledger.branch ? ` (${ledger.branch})` : ""}`, `**Compression**: ${Math.round(ledger.compression_ratio)}x (${ledger.original_token_count} \u2192 ${ledger.compressed_token_count} tokens)`, "", `## \u{1F3AF} Current Focus`, ledger.current_focus, "", `## \u{1F4DA} Active Frame Stack (${ledger.active_frame_stack.length})`, ...ledger.active_frame_stack.map( (f) => `${" ".repeat(f.depth)}\u2514\u2500 ${f.type}: ${f.description}` ), "", `## \u{1F3AF} Active Tasks (${ledger.active_tasks.filter((t) => t.status !== "completed").length})`, ...ledger.active_tasks.filter((t) => t.status !== "completed").sort((a, b) => { const priority = { critical: 0, high: 1, medium: 2, low: 3 }; return priority[a.priority] - priority[b.priority]; }).map((t) => `- [${t.priority}] ${t.title} (${t.status})`), "", `## \u{1F511} Key Decisions`, ...ledger.key_decisions.filter((d) => d.still_applies).map((d) => `- **${d.decision}** ${d.rationale}`), "", `## \u2705 Recent Achievements`, ...ledger.recent_achievements.map( (a) => `- ${a.description} \u2192 ${a.impact}` ), "", `## \u27A1\uFE0F Next Actions`, ...ledger.next_actions.map((a, i) => `${i + 1}. ${a}`), "", ledger.warnings.length > 0 ? `## \u26A0\uFE0F Warnings` : "", ...ledger.warnings.map((w) => `- ${w}`) ]; return lines.filter((l) => l !== "").join("\n"); } /** * Check if /clear is recommended */ async shouldClear(currentTokens, maxTokens) { const usage = currentTokens / maxTokens; if (usage < this.CONTEXT_WARNING_THRESHOLD) { return { recommended: false }; } const frameStack = await this.frameManager.getStack(); const redundantFrames = frameStack.frames.filter( (f) => f.status === "closed" && !f.metadata?.critical ).length; if (usage >= this.CONTEXT_CRITICAL_THRESHOLD) { if (redundantFrames > 5) { return { recommended: true, reason: `Context at ${Math.round(usage * 100)}% with ${redundantFrames} closed frames`, alternative: "Consider saving ledger and clearing" }; } } return { recommended: false, alternative: `Context at ${Math.round(usage * 100)}% but manageable` }; } // Private helper methods async getCompressedFrameStack() { const stack = await this.frameManager.getStack(); return stack.frames.map((frame, index) => ({ id: frame.id, type: frame.type, description: frame.description || "Unnamed frame", depth: index, key_events: this.extractKeyEvents(frame), digest: frame.digest?.summary })); } extractKeyEvents(frame) { const events = []; if (frame.metadata?.decision) { events.push(`Decision: ${frame.metadata.decision}`); } if (frame.metadata?.error) { events.push(`Error: ${frame.metadata.error}`); } if (frame.metadata?.achievement) { events.push(`Achievement: ${frame.metadata.achievement}`); } return events; } async getCriticalDecisions() { const traces = await this.dbManager.getRecentTraces( await this.dbManager.getCurrentSessionId(), 100 ); return traces.filter((t) => t.type === "decision").map((t) => ({ id: t.id, decision: t.content.decision || "", rationale: t.content.rationale || "", impact: this.assessImpact(t), still_applies: !t.metadata?.superseded })).filter((d) => d.impact !== "low"); } assessImpact(trace) { const content = JSON.stringify(trace.content).toLowerCase(); if (content.includes("architecture") || content.includes("critical")) { return "critical"; } if (content.includes("important") || content.includes("significant")) { return "high"; } if (content.includes("minor") || content.includes("small")) { return "low"; } return "medium"; } async getActiveTasks() { const frames = await this.dbManager.getRecentFrames( await this.dbManager.getCurrentSessionId(), 50 ); return frames.filter((f) => f.type === "task").map((f) => ({ id: f.id, title: f.description || "Untitled task", status: this.getTaskStatus(f), priority: this.getTaskPriority(f), context: f.metadata?.context || "" })); } getTaskStatus(frame) { if (frame.status === "closed" && frame.metadata?.completed) { return "completed"; } if (frame.metadata?.blocked) return "blocked"; if (frame.status === "open") return "in_progress"; return "pending"; } getTaskPriority(frame) { const priority = frame.metadata?.priority; if (["critical", "high", "medium", "low"].includes(priority)) { return priority; } return "medium"; } async getCriticalContext() { const context = []; const session = await this.dbManager.getSession( await this.dbManager.getCurrentSessionId() ); if (session?.metadata?.key_facts) { context.push(...session.metadata.key_facts); } const traces = await this.dbManager.getRecentTraces( await this.dbManager.getCurrentSessionId(), 50 ); const discoveries = traces.filter((t) => t.metadata?.important || t.type === "discovery").map((t) => t.content.summary || t.content.description).filter(Boolean).slice(0, 5); context.push(...discoveries); return context; } async getRecentAchievements() { const frames = await this.dbManager.getRecentFrames( await this.dbManager.getCurrentSessionId(), 20 ); return frames.filter((f) => f.status === "closed" && f.metadata?.achievement).map((f) => ({ description: f.metadata.achievement, impact: f.metadata.impact || "completed task", timestamp: f.closedAt || f.createdAt })).slice(0, 5); } async getCurrentFocus() { const stack = await this.frameManager.getStack(); const activeFrame = stack.frames.find((f) => f.status === "open"); if (!activeFrame) { return "No active focus"; } return `${activeFrame.type}: ${activeFrame.description || "In progress"}`; } async suggestNextActions(tasks) { const suggestions = []; const inProgress = tasks.filter((t) => t.status === "in_progress"); if (inProgress.length > 0) { suggestions.push(`Continue: ${inProgress[0].title}`); } const highPriority = tasks.filter( (t) => t.status === "pending" && t.priority === "high" ); if (highPriority.length > 0) { suggestions.push(`Start: ${highPriority[0].title}`); } const blocked = tasks.filter((t) => t.status === "blocked"); if (blocked.length > 0) { suggestions.push(`Unblock: ${blocked[0].title}`); } return suggestions.slice(0, 3); } async getWarnings() { const warnings = []; const tasks = await this.getActiveTasks(); const blocked = tasks.filter((t) => t.status === "blocked"); if (blocked.length > 0) { warnings.push(`${blocked.length} tasks blocked`); } const critical = tasks.filter( (t) => t.priority === "critical" && t.status !== "completed" ); if (critical.length > 0) { warnings.push(`${critical.length} critical tasks pending`); } return warnings; } async estimateCurrentTokens() { const frames = await this.frameManager.getStack(); const traces = await this.dbManager.getRecentTraces( await this.dbManager.getCurrentSessionId(), 100 ); const frameTokens = frames.frames.length * 200; const traceTokens = traces.length * 100; return frameTokens + traceTokens; } estimateLedgerTokens(frames, decisions, tasks) { return frames.length * 50 + decisions.length * 30 + tasks.length * 20; } async saveLedgerToFile(ledger) { await fs.mkdir(this.continuityPath, { recursive: true }); const latestPath = path.join( this.continuityPath, "CONTINUITY_CLAUDE-latest.json" ); await fs.writeFile(latestPath, JSON.stringify(ledger, null, 2), "utf-8"); const markdown = await this.generateLedgerMarkdown(ledger); const mdPath = path.join( this.continuityPath, "CONTINUITY_CLAUDE-latest.md" ); await fs.writeFile(mdPath, markdown, "utf-8"); } async saveBackupLedger(ledger) { await fs.mkdir(this.ledgerPath, { recursive: true }); const timestamp = ledger.timestamp.replace(/[:.]/g, "-"); const backupPath = path.join(this.ledgerPath, `ledger-${timestamp}.json`); await fs.writeFile(backupPath, JSON.stringify(ledger, null, 2), "utf-8"); } async loadLatestLedger() { try { const latestPath = path.join( this.continuityPath, "CONTINUITY_CLAUDE-latest.json" ); const content = await fs.readFile(latestPath, "utf-8"); return JSON.parse(content); } catch { return null; } } async restoreFrameStructure(frames) { for (const summary of frames) { await this.frameManager.push({ type: summary.type, description: summary.description, metadata: { restored_from_ledger: true, original_id: summary.id, key_events: summary.key_events, digest: summary.digest } }); } } async restoreDecisions(decisions) { for (const decision of decisions) { if (decision.still_applies) { await this.dbManager.addAnchor({ type: "decision", content: { decision: decision.decision, rationale: decision.rationale, impact: decision.impact }, metadata: { restored_from_ledger: true, original_id: decision.id } }); } } } async restoreTasks(tasks) { for (const task of tasks) { if (task.status !== "completed") { await this.frameManager.push({ type: "task", description: task.title, metadata: { status: task.status, priority: task.priority, context: task.context, restored_from_ledger: true, original_id: task.id } }); } } } } export { ClearSurvival };