@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
JavaScript
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
};