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.

344 lines (343 loc) 12.6 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 HandoffGenerator { frameManager; dbManager; handoffDir; constructor(frameManager, dbManager, projectRoot) { this.frameManager = frameManager; this.dbManager = dbManager; this.handoffDir = path.join(projectRoot, ".stackmemory", "handoffs"); } /** * Generate a handoff document for the current session */ async generateHandoff(sessionId) { const session = await this.dbManager.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); const activeFramePath = await this.getActiveFramePath(); const recentTraces = await this.dbManager.getRecentTraces(sessionId, 100); const recentFrames = await this.dbManager.getRecentFrames(sessionId, 20); const tasks = await this.extractTasks(recentFrames); const decisions = await this.extractDecisions(recentTraces); const blockers = await this.extractBlockers(recentTraces, recentFrames); const fileEdits = await this.extractFileEdits(recentTraces); const commands = await this.extractCommands(recentTraces); const errors = await this.extractErrors(recentTraces); const patterns = await this.detectPatterns(recentTraces); const approaches = await this.extractApproaches(recentFrames); const sessionDuration = Math.floor( (Date.now() - new Date(session.startedAt).getTime()) / 6e4 ); const handoff = { session_id: sessionId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), project: session.project, branch: session.metadata?.branch, active_frame_path: activeFramePath, active_tasks: tasks, pending_decisions: decisions.filter((d) => !d.resolved), blockers, recent_files: fileEdits.slice(0, 10), recent_commands: commands.slice(0, 10), recent_errors: errors.slice(0, 5), patterns_detected: patterns, approaches_tried: approaches, successful_strategies: this.extractSuccessfulStrategies(approaches), suggested_next_actions: await this.suggestNextActions( tasks, blockers, activeFramePath ), warnings: await this.generateWarnings(errors, blockers), session_duration_minutes: sessionDuration, frames_created: recentFrames.length, tool_calls_made: recentTraces.filter((t) => t.type === "tool_call").length, decisions_recorded: decisions.length }; await this.saveHandoff(handoff); return handoff; } /** * Load the most recent handoff document */ async loadHandoff() { try { await fs.mkdir(this.handoffDir, { recursive: true }); const files = await fs.readdir(this.handoffDir); const handoffFiles = files.filter((f) => f.endsWith(".json")).sort().reverse(); if (handoffFiles.length === 0) return null; const mostRecent = handoffFiles[0]; const content = await fs.readFile( path.join(this.handoffDir, mostRecent), "utf-8" ); return JSON.parse(content); } catch (error) { console.error("Error loading handoff:", error); return null; } } /** * Generate a markdown summary of the handoff */ async generateMarkdownSummary(handoff) { const lines = [ `# Session Handoff`, `**Generated**: ${new Date(handoff.timestamp).toLocaleString()}`, `**Project**: ${handoff.project}`, handoff.branch ? `**Branch**: ${handoff.branch}` : "", `**Duration**: ${handoff.session_duration_minutes} minutes`, "", `## Current Context`, `**Active Frame Path**: ${handoff.active_frame_path.join(" \u2192 ")}`, "", `## Active Tasks (${handoff.active_tasks.length})`, ...handoff.active_tasks.map( (t) => `- [${t.status}] ${t.title} (${t.progress_percentage}%)${t.blocker ? ` \u26A0\uFE0F Blocked: ${t.blocker}` : ""}` ), "", handoff.blockers.length > 0 ? "## Blockers" : "", ...handoff.blockers.map( (b) => `- **${b.severity}**: ${b.description} Tried: ${b.attempted_solutions.join( ", " )}` ), "", handoff.pending_decisions.length > 0 ? "## Pending Decisions" : "", ...handoff.pending_decisions.map( (d) => `- **${d.decision}** Rationale: ${d.rationale}` ), "", "## Recent Activity", `- Files edited: ${handoff.recent_files.length}`, `- Commands run: ${handoff.recent_commands.length}`, `- Errors encountered: ${handoff.recent_errors.length}`, "", handoff.patterns_detected.length > 0 ? "## Patterns Detected" : "", ...handoff.patterns_detected.map((p) => `- ${p}`), "", handoff.successful_strategies.length > 0 ? "## Successful Strategies" : "", ...handoff.successful_strategies.map((s) => `- ${s}`), "", "## Suggested Next Actions", ...handoff.suggested_next_actions.map((a) => `1. ${a}`), "", handoff.warnings.length > 0 ? "## \u26A0\uFE0F Warnings" : "", ...handoff.warnings.map((w) => `- ${w}`) ]; return lines.filter((l) => l !== "").join("\n"); } /** * Auto-detect session end and trigger handoff */ async detectSessionEnd(sessionId) { const idleThreshold = 5 * 60 * 1e3; const lastActivity = await this.dbManager.getLastActivityTime(sessionId); if (!lastActivity) return false; const idleTime = Date.now() - lastActivity.getTime(); if (idleTime > idleThreshold) { await this.generateHandoff(sessionId); return true; } return false; } // Private helper methods async getActiveFramePath() { const stack = await this.frameManager.getStack(); return stack.frames.map((f) => f.description || f.type); } async extractTasks(frames) { return frames.filter((f) => f.type === "task").map((f) => ({ id: f.id, title: f.description || "Untitled task", status: this.getTaskStatus(f), progress_percentage: f.metadata?.progress || 0, blocker: f.metadata?.blocker })); } getTaskStatus(frame) { if (frame.status === "closed") return "completed"; if (frame.metadata?.blocker) return "blocked"; if (frame.status === "open") return "in_progress"; return "pending"; } async extractDecisions(traces) { return traces.filter((t) => t.type === "decision").map((t) => ({ decision: t.content.decision || "", rationale: t.content.rationale || "", alternatives_considered: t.content.alternatives, timestamp: t.timestamp, resolved: t.metadata?.resolved || false })); } async extractBlockers(traces, frames) { const blockers = []; const errorTraces = traces.filter( (t) => t.type === "error" && !t.metadata?.resolved ); for (const trace of errorTraces) { blockers.push({ description: trace.content.error || "Unknown error", attempted_solutions: trace.metadata?.attempts || [], suggested_approach: trace.metadata?.suggestion, severity: this.getErrorSeverity(trace) }); } const blockedFrames = frames.filter((f) => f.metadata?.blocker); for (const frame of blockedFrames) { blockers.push({ description: frame.metadata.blocker, attempted_solutions: frame.metadata.attempts || [], severity: "medium" }); } return blockers; } getErrorSeverity(trace) { const error = trace.content.error?.toLowerCase() || ""; if (error.includes("critical") || error.includes("fatal")) return "critical"; if (error.includes("error") || error.includes("fail")) return "high"; if (error.includes("warning")) return "medium"; return "low"; } async extractFileEdits(traces) { const fileMap = /* @__PURE__ */ new Map(); const editTraces = traces.filter( (t) => ["edit", "write", "create", "delete"].includes(t.type) ); for (const trace of editTraces) { const path2 = trace.content.file_path || trace.content.path; if (!path2) continue; if (!fileMap.has(path2)) { fileMap.set(path2, { path: path2, operations: [], line_changes: { added: 0, removed: 0 } }); } const file = fileMap.get(path2); const op = this.getFileOperation(trace.type); if (!file.operations.includes(op)) { file.operations.push(op); } file.line_changes.added += trace.metadata?.lines_added || 0; file.line_changes.removed += trace.metadata?.lines_removed || 0; } return Array.from(fileMap.values()); } getFileOperation(traceType) { switch (traceType) { case "create": case "write": return "created"; case "edit": return "modified"; case "delete": return "deleted"; default: return "modified"; } } async extractCommands(traces) { return traces.filter((t) => t.type === "bash" || t.type === "command").map((t) => ({ command: t.content.command || "", success: !t.metadata?.error, output_summary: t.content.output?.substring(0, 100) })); } async extractErrors(traces) { return traces.filter((t) => t.type === "error").map((t) => ({ error: t.content.error || "", context: t.content.context || "", resolved: t.metadata?.resolved || false, resolution: t.metadata?.resolution })); } async detectPatterns(traces) { const patterns = []; const testFirst = traces.some( (t) => t.type === "test" && traces.some( (t2) => t2.type === "implement" && t2.timestamp > t.timestamp ) ); if (testFirst) patterns.push("Test-Driven Development"); const refactoring = traces.filter( (t) => t.content.description?.includes("refactor") || t.metadata?.operation === "refactor" ).length > 3; if (refactoring) patterns.push("Active Refactoring"); const debugging = traces.filter((t) => t.type === "error" || t.type === "debug").length > 5; if (debugging) patterns.push("Deep Debugging Session"); return patterns; } async extractApproaches(frames) { return frames.filter((f) => f.metadata?.approach).map((f) => ({ approach: f.metadata.approach, outcome: this.getApproachOutcome(f), learnings: f.metadata.learnings })); } getApproachOutcome(frame) { if (frame.status === "closed" && frame.metadata?.success) return "successful"; if (frame.status === "closed" && !frame.metadata?.success) return "failed"; return "partial"; } extractSuccessfulStrategies(approaches) { return approaches.filter((a) => a.outcome === "successful").map((a) => a.approach); } async suggestNextActions(tasks, blockers, framePath) { const suggestions = []; const inProgress = tasks.filter((t) => t.status === "in_progress"); if (inProgress.length > 0) { suggestions.push(`Resume task: ${inProgress[0].title}`); } const criticalBlockers = blockers.filter((b) => b.severity === "critical"); if (criticalBlockers.length > 0) { suggestions.push( `Resolve critical blocker: ${criticalBlockers[0].description}` ); } const nearlyDone = tasks.filter((t) => t.progress_percentage >= 80); if (nearlyDone.length > 0) { suggestions.push( `Complete task: ${nearlyDone[0].title} (${nearlyDone[0].progress_percentage}% done)` ); } return suggestions; } async generateWarnings(errors, blockers) { const warnings = []; const unresolved = errors.filter((e) => !e.resolved); if (unresolved.length > 0) { warnings.push(`${unresolved.length} unresolved errors`); } const critical = blockers.filter((b) => b.severity === "critical"); if (critical.length > 0) { warnings.push( `${critical.length} critical blockers need immediate attention` ); } return warnings; } async saveHandoff(handoff) { await fs.mkdir(this.handoffDir, { recursive: true }); const filename = `${handoff.timestamp.replace(/[:.]/g, "-")}.json`; const filepath = path.join(this.handoffDir, filename); await fs.writeFile(filepath, JSON.stringify(handoff, null, 2), "utf-8"); const markdown = await this.generateMarkdownSummary(handoff); const mdPath = filepath.replace(".json", ".md"); await fs.writeFile(mdPath, markdown, "utf-8"); } } export { HandoffGenerator }; //# sourceMappingURL=handoff-generator.js.map