@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
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 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