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.

1,449 lines (1,447 loc) 85.4 kB
#!/usr/bin/env node import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import Database from "better-sqlite3"; import { validateInput, StartFrameSchema, AddAnchorSchema, CreateTaskSchema } from "./schemas.js"; import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs"; import { compactPlan } from "../../orchestrators/multimodal/utils.js"; import { filterPending } from "./pending-utils.js"; import { join, dirname } from "path"; import { execSync } from "child_process"; import { FrameManager } from "../../core/context/index.js"; import { logger } from "../../core/monitoring/logger.js"; import { isFeatureEnabled } from "../../core/config/feature-flags.js"; import { TaskPriority, TaskStatus } from "../../features/tasks/linear-task-manager.js"; import { BrowserMCPIntegration } from "../../features/browser/browser-mcp.js"; import { TraceDetector } from "../../core/trace/trace-detector.js"; import { LLMContextRetrieval } from "../../core/retrieval/index.js"; import { DiscoveryHandlers } from "./handlers/discovery-handlers.js"; import { DiffMemHandlers } from "./handlers/diffmem-handlers.js"; import { v4 as uuidv4 } from "uuid"; import { DEFAULT_PLANNER_MODEL, DEFAULT_IMPLEMENTER, DEFAULT_MAX_ITERS } from "../../orchestrators/multimodal/constants.js"; function getEnv(key, defaultValue) { const value = process.env[key]; if (value === void 0) { if (defaultValue !== void 0) return defaultValue; throw new Error(`Environment variable ${key} is required`); } return value; } function getOptionalEnv(key) { return process.env[key]; } class LocalStackMemoryMCP { server; db; projectRoot; frameManager; taskStore = null; linearAuthManager = null; linearSync = null; projectId; contexts = /* @__PURE__ */ new Map(); browserMCP; traceDetector; contextRetrieval; discoveryHandlers; diffMemHandlers; pendingPlans = /* @__PURE__ */ new Map(); constructor() { this.projectRoot = this.findProjectRoot(); this.projectId = this.getProjectId(); const dbDir = join(this.projectRoot, ".stackmemory"); if (!existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }); } const dbPath = join(dbDir, "context.db"); this.db = new Database(dbPath); this.initDB(); this.frameManager = new FrameManager(this.db, this.projectId); this.initLinearIfEnabled(); this.server = new Server( { name: "stackmemory-local", version: "0.1.0" }, { capabilities: { tools: {} } } ); this.browserMCP = new BrowserMCPIntegration({ headless: process.env["BROWSER_HEADLESS"] !== "false", defaultViewport: { width: 1280, height: 720 } }); this.traceDetector = new TraceDetector({}, void 0, this.db); this.contextRetrieval = new LLMContextRetrieval( this.db, this.frameManager, this.projectId ); this.discoveryHandlers = new DiscoveryHandlers({ frameManager: this.frameManager, contextRetrieval: this.contextRetrieval, db: this.db, projectRoot: this.projectRoot }); this.diffMemHandlers = new DiffMemHandlers(); this.setupHandlers(); this.loadInitialContext(); this.loadPendingPlans(); this.browserMCP.initialize(this.server).catch((error) => { logger.error("Failed to initialize Browser MCP", error); }); logger.info("StackMemory MCP Server initialized", { projectRoot: this.projectRoot, projectId: this.projectId }); } findProjectRoot() { let dir = process.cwd(); while (dir !== "/") { if (existsSync(join(dir, ".git"))) { return dir; } dir = dirname(dir); } return process.cwd(); } /** * Initialize Linear integration if enabled and credentials available */ async initLinearIfEnabled() { if (!isFeatureEnabled("linear")) { logger.info("Linear integration disabled (no API key or LOCAL mode)"); return; } try { const { LinearTaskManager } = await import("../../features/tasks/linear-task-manager.js"); const { LinearAuthManager } = await import("../linear/auth.js"); const { LinearSyncEngine, DEFAULT_SYNC_CONFIG: DEFAULT_SYNC_CONFIG2 } = await import("../linear/sync.js"); this.taskStore = new LinearTaskManager(this.projectRoot, this.db); this.linearAuthManager = new LinearAuthManager(this.projectRoot); this.linearSync = new LinearSyncEngine( this.taskStore, this.linearAuthManager, DEFAULT_SYNC_CONFIG2 ); logger.info("Linear integration initialized"); } catch (error) { logger.warn("Failed to initialize Linear integration", { error }); } } initDB() { this.db.exec(` CREATE TABLE IF NOT EXISTS contexts ( id TEXT PRIMARY KEY, type TEXT NOT NULL, content TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at INTEGER DEFAULT (unixepoch()), last_accessed INTEGER DEFAULT (unixepoch()), access_count INTEGER DEFAULT 1 ); CREATE TABLE IF NOT EXISTS attention_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, context_id TEXT, query TEXT, response TEXT, influence_score REAL, timestamp INTEGER DEFAULT (unixepoch()) ); `); } loadInitialContext() { const projectInfo = this.getProjectInfo(); this.addContext( "project", `Project: ${projectInfo.name} Path: ${projectInfo.path}`, 0.9 ); try { const recentCommits = execSync("git log --oneline -10", { cwd: this.projectRoot }).toString(); this.addContext("git_history", `Recent commits: ${recentCommits}`, 0.6); } catch { } const readmePath = join(this.projectRoot, "README.md"); if (existsSync(readmePath)) { const readme = readFileSync(readmePath, "utf-8"); const summary = readme.substring(0, 500); this.addContext("readme", `Project README: ${summary}...`, 0.8); } this.loadStoredContexts(); } getProjectId() { let identifier; try { identifier = execSync("git config --get remote.origin.url", { cwd: this.projectRoot, stdio: "pipe", timeout: 5e3 }).toString().trim(); } catch { identifier = this.projectRoot; } const cleaned = identifier.replace(/\.git$/, "").replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); return cleaned.substring(cleaned.length - 50) || "unknown"; } getProjectInfo() { const packageJsonPath = join(this.projectRoot, "package.json"); if (existsSync(packageJsonPath)) { const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")); return { name: pkg.name || "unknown", path: this.projectRoot }; } return { name: this.projectRoot.split("/").pop() || "unknown", path: this.projectRoot }; } addContext(type, content, importance = 0.5) { const id = `${type}_${Date.now()}`; this.db.prepare( ` INSERT OR REPLACE INTO contexts (id, type, content, importance) VALUES (?, ?, ?, ?) ` ).run(id, type, content, importance); this.contexts.set(id, { type, content, importance }); return id; } loadStoredContexts() { const stored = this.db.prepare( ` SELECT * FROM contexts ORDER BY importance DESC, last_accessed DESC LIMIT 50 ` ).all(); stored.forEach((ctx) => { this.contexts.set(ctx.id, ctx); }); } setupHandlers() { this.server.setRequestHandler( z.object({ method: z.literal("tools/list") }), async () => { return { tools: [ { name: "get_context", description: "Get current project context", inputSchema: { type: "object", properties: { query: { type: "string", description: "What you want to know" }, limit: { type: "number", description: "Max contexts to return" } } } }, { name: "plan_and_code", description: "Generate a plan (Claude), attempt implementation (Codex/Claude), and return JSON result. Quiet by default.", inputSchema: { type: "object", properties: { task: { type: "string", description: "Task description" }, implementer: { type: "string", enum: ["codex", "claude"], default: "codex", description: "Which agent implements code" }, maxIters: { type: "number", default: 2, description: "Retry loop iterations" }, execute: { type: "boolean", default: false, description: "Actually call implementer (otherwise dry-run)" }, record: { type: "boolean", default: false, description: "Record plan & critique into StackMemory context" }, recordFrame: { type: "boolean", default: false, description: "Record as real frame with anchors" } }, required: ["task"] } }, { name: "plan_gate", description: "Phase 1: Generate a plan and return an approvalId for later execution", inputSchema: { type: "object", properties: { task: { type: "string", description: "Task description" }, plannerModel: { type: "string", description: "Claude model (optional)" } }, required: ["task"] } }, { name: "approve_plan", description: "Phase 2: Execute a previously generated plan by approvalId (runs implement + critique)", inputSchema: { type: "object", properties: { approvalId: { type: "string", description: "Id from plan_gate" }, implementer: { type: "string", enum: ["codex", "claude"], default: "codex", description: "Which agent implements code" }, maxIters: { type: "number", default: 2 }, recordFrame: { type: "boolean", default: true }, execute: { type: "boolean", default: true } }, required: ["approvalId"] } }, { name: "pending_list", description: "List pending approval-gated plans (supports filters)", inputSchema: { type: "object", properties: { taskContains: { type: "string", description: "Filter tasks containing this substring" }, olderThanMs: { type: "number", description: "Only items older than this age (ms)" }, newerThanMs: { type: "number", description: "Only items newer than this age (ms)" }, sort: { type: "string", enum: ["asc", "desc"], description: "Sort by createdAt" }, limit: { type: "number", description: "Max items to return" } } } }, { name: "pending_clear", description: "Clear pending approval-gated plans (by id, all, or olderThanMs)", inputSchema: { type: "object", properties: { approvalId: { type: "string", description: "Clear a single approval by id" }, all: { type: "boolean", description: "Clear all pending approvals", default: false }, olderThanMs: { type: "number", description: "Clear approvals older than this age (ms)" } } } }, { name: "pending_show", description: "Show a pending plan by approvalId", inputSchema: { type: "object", properties: { approvalId: { type: "string", description: "Approval id from plan_gate" } }, required: ["approvalId"] } }, { name: "plan_only", description: "Generate an implementation plan (Claude) and return JSON only", inputSchema: { type: "object", properties: { task: { type: "string", description: "Task description" }, plannerModel: { type: "string", description: "Claude model for planning (optional)" } }, required: ["task"] } }, { name: "call_codex", description: "Invoke Codex via codex-sm with a prompt and args; dry-run by default", inputSchema: { type: "object", properties: { prompt: { type: "string", description: "Prompt for Codex" }, args: { type: "array", items: { type: "string" }, description: "Additional CLI args for codex-sm" }, execute: { type: "boolean", default: false, description: "Actually run codex-sm (otherwise dry-run)" } }, required: ["prompt"] } }, { name: "call_claude", description: "Invoke Claude with a prompt (Anthropic SDK)", inputSchema: { type: "object", properties: { prompt: { type: "string", description: "Prompt for Claude" }, model: { type: "string", description: "Claude model (optional)" }, system: { type: "string", description: "System prompt (optional)" } }, required: ["prompt"] } }, { name: "add_decision", description: "Record a decision or important information", inputSchema: { type: "object", properties: { content: { type: "string", description: "The decision or information" }, type: { type: "string", enum: ["decision", "constraint", "learning"] } }, required: ["content", "type"] } }, { name: "start_frame", description: "Start a new frame (task/subtask) on the call stack", inputSchema: { type: "object", properties: { name: { type: "string", description: "Frame name/goal" }, type: { type: "string", enum: [ "task", "subtask", "tool_scope", "review", "write", "debug" ], description: "Frame type" }, constraints: { type: "array", items: { type: "string" }, description: "Constraints for this frame" } }, required: ["name", "type"] } }, { name: "close_frame", description: "Close current frame and generate digest", inputSchema: { type: "object", properties: { result: { type: "string", description: "Frame completion result" }, outputs: { type: "object", description: "Final outputs from frame" } } } }, { name: "add_anchor", description: "Add anchored fact/decision/constraint to current frame", inputSchema: { type: "object", properties: { type: { type: "string", enum: [ "FACT", "DECISION", "CONSTRAINT", "INTERFACE_CONTRACT", "TODO", "RISK" ], description: "Anchor type" }, text: { type: "string", description: "Anchor content" }, priority: { type: "number", description: "Priority (0-10)", minimum: 0, maximum: 10 } }, required: ["type", "text"] } }, { name: "get_hot_stack", description: "Get current active frames and context", inputSchema: { type: "object", properties: { maxEvents: { type: "number", description: "Max recent events per frame", default: 20 } } } }, { name: "create_task", description: "Create a new task in git-tracked JSONL storage", inputSchema: { type: "object", properties: { title: { type: "string", description: "Task title" }, description: { type: "string", description: "Task description" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"], description: "Task priority" }, estimatedEffort: { type: "number", description: "Estimated effort in minutes" }, dependsOn: { type: "array", items: { type: "string" }, description: "Task IDs this depends on" }, tags: { type: "array", items: { type: "string" }, description: "Tags for categorization" } }, required: ["title"] } }, { name: "update_task_status", description: "Update task status with automatic time tracking", inputSchema: { type: "object", properties: { taskId: { type: "string", description: "Task ID to update" }, status: { type: "string", enum: [ "pending", "in_progress", "completed", "blocked", "cancelled" ], description: "New status" }, reason: { type: "string", description: "Reason for status change (especially for blocked)" } }, required: ["taskId", "status"] } }, { name: "get_active_tasks", description: "Get currently active tasks synced from Linear", inputSchema: { type: "object", properties: { frameId: { type: "string", description: "Filter by specific frame ID" }, status: { type: "string", enum: [ "pending", "in_progress", "completed", "blocked", "cancelled" ], description: "Filter by status" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"], description: "Filter by priority" }, search: { type: "string", description: "Search in task title or description" }, limit: { type: "number", description: "Max number of tasks to return (default: 20)" } } } }, { name: "get_task_metrics", description: "Get project task metrics and analytics", inputSchema: { type: "object", properties: {} } }, { name: "add_task_dependency", description: "Add dependency relationship between tasks", inputSchema: { type: "object", properties: { taskId: { type: "string", description: "Task that depends on another" }, dependsOnId: { type: "string", description: "Task ID that this depends on" } }, required: ["taskId", "dependsOnId"] } }, { name: "linear_sync", description: "Sync tasks with Linear", inputSchema: { type: "object", properties: { direction: { type: "string", enum: ["bidirectional", "to_linear", "from_linear"], description: "Sync direction" } } } }, { name: "linear_update_task", description: "Update a Linear task status", inputSchema: { type: "object", properties: { issueId: { type: "string", description: "Linear issue ID or identifier (e.g., STA-34)" }, status: { type: "string", enum: ["todo", "in-progress", "done", "canceled"], description: "New status for the task" }, title: { type: "string", description: "Update task title (optional)" }, description: { type: "string", description: "Update task description (optional)" }, priority: { type: "number", enum: [1, 2, 3, 4], description: "Priority (1=urgent, 2=high, 3=medium, 4=low)" } }, required: ["issueId"] } }, { name: "linear_get_tasks", description: "Get Linear tasks", inputSchema: { type: "object", properties: { status: { type: "string", enum: ["todo", "in-progress", "done", "all"], description: "Filter by status" }, limit: { type: "number", description: "Maximum number of tasks to return" } } } }, { name: "linear_status", description: "Get Linear integration status", inputSchema: { type: "object", properties: {} } }, { name: "get_traces", description: "Get detected traces (bundled tool call sequences)", inputSchema: { type: "object", properties: { type: { type: "string", enum: [ "search_driven", "error_recovery", "feature_implementation", "refactoring", "testing", "exploration", "debugging", "documentation", "build_deploy", "unknown" ], description: "Filter by trace type" }, minScore: { type: "number", description: "Minimum importance score (0-1)" }, limit: { type: "number", description: "Maximum number of traces to return" } } } }, { name: "get_trace_statistics", description: "Get statistics about detected traces", inputSchema: { type: "object", properties: {} } }, { name: "flush_traces", description: "Flush any pending trace and finalize detection", inputSchema: { type: "object", properties: {} } }, { name: "compress_old_traces", description: "Compress traces older than specified hours", inputSchema: { type: "object", properties: { ageHours: { type: "number", description: "Age threshold in hours (default: 24)" } } } }, { name: "smart_context", description: "LLM-driven context retrieval - intelligently selects relevant frames based on query", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language query describing what context you need" }, tokenBudget: { type: "number", description: "Maximum tokens to use for context (default: 4000)" }, forceRefresh: { type: "boolean", description: "Force refresh of cached summaries" } }, required: ["query"] } }, { name: "get_summary", description: "Get compressed summary of project memory for analysis", inputSchema: { type: "object", properties: { forceRefresh: { type: "boolean", description: "Force refresh of cached summary" } } } }, // Discovery tools { name: "sm_discover", description: "Discover relevant files based on current context. Extracts keywords from active frames and searches codebase.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Optional query to focus the discovery" }, depth: { type: "string", enum: ["shallow", "medium", "deep"], description: "Search depth" }, maxFiles: { type: "number", description: "Maximum files to return" } } } }, { name: "sm_related_files", description: "Find files related to a specific file or concept", inputSchema: { type: "object", properties: { file: { type: "string", description: "File path to find related files for" }, concept: { type: "string", description: "Concept to search for" }, maxFiles: { type: "number", description: "Maximum files to return" } } } }, { name: "sm_session_summary", description: "Get summary of current session with active tasks, files, and decisions", inputSchema: { type: "object", properties: { includeFiles: { type: "boolean", description: "Include recently accessed files" }, includeDecisions: { type: "boolean", description: "Include recent decisions" } } } }, { name: "sm_search", description: "Search across StackMemory - frames, events, decisions, tasks", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query" }, scope: { type: "string", enum: ["all", "frames", "events", "decisions", "tasks"], description: "Scope of search" }, limit: { type: "number", description: "Maximum results" } }, required: ["query"] } }, // DiffMem tools for user memory management { name: "diffmem_get_user_context", description: "Fetch user knowledge and preferences from memory. Use to personalize responses based on learned user patterns.", inputSchema: { type: "object", properties: { categories: { type: "array", items: { type: "string", enum: [ "preference", "expertise", "project_knowledge", "pattern", "correction" ] }, description: "Filter by memory categories" }, limit: { type: "number", default: 10, description: "Maximum memories to return" } } } }, { name: "diffmem_store_learning", description: "Store a new insight about the user (preference, expertise, pattern, or correction)", inputSchema: { type: "object", properties: { content: { type: "string", description: "The insight to store" }, category: { type: "string", enum: [ "preference", "expertise", "project_knowledge", "pattern", "correction" ], description: "Category of the insight" }, confidence: { type: "number", minimum: 0, maximum: 1, default: 0.7, description: "Confidence level (0-1)" }, context: { type: "object", description: "Additional context for the insight" } }, required: ["content", "category"] } }, { name: "diffmem_search", description: "Semantic search across user memories. Find relevant past insights and preferences.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query" }, timeRange: { type: "string", enum: ["day", "week", "month", "all"], default: "all", description: "Time range filter" }, minConfidence: { type: "number", minimum: 0, maximum: 1, default: 0.5, description: "Minimum confidence threshold" }, limit: { type: "number", default: 10, description: "Maximum results" } }, required: ["query"] } }, { name: "diffmem_status", description: "Check DiffMem connection status and memory statistics", inputSchema: { type: "object", properties: {} } } ] }; } ); this.server.setRequestHandler( z.object({ method: z.literal("tools/call"), params: z.object({ name: z.string(), arguments: z.record(z.unknown()) }) }), async (request) => { const { name, arguments: args } = request.params; const callId = uuidv4(); const startTime = Date.now(); const currentFrameId = this.frameManager.getCurrentFrameId(); if (currentFrameId) { this.frameManager.addEvent("tool_call", { tool_name: name, arguments: args, timestamp: startTime }); } const toolCall = { id: callId, tool: name, arguments: args, timestamp: startTime }; let result; let error; try { switch (name) { case "get_context": result = await this.handleGetContext(args); break; case "add_decision": result = await this.handleAddDecision(args); break; case "start_frame": result = await this.handleStartFrame(args); break; case "close_frame": result = await this.handleCloseFrame(args); break; case "add_anchor": result = await this.handleAddAnchor(args); break; case "get_hot_stack": result = await this.handleGetHotStack(args); break; case "create_task": result = await this.handleCreateTask(args); break; case "update_task_status": result = await this.handleUpdateTaskStatus(args); break; case "get_active_tasks": result = await this.handleGetActiveTasks(args); break; case "get_task_metrics": result = await this.handleGetTaskMetrics(args); break; case "add_task_dependency": result = await this.handleAddTaskDependency(args); break; case "linear_sync": result = await this.handleLinearSync(args); break; case "linear_update_task": result = await this.handleLinearUpdateTask(args); break; case "linear_get_tasks": result = await this.handleLinearGetTasks(args); break; case "linear_status": result = await this.handleLinearStatus(args); break; case "get_traces": result = await this.handleGetTraces(args); break; case "get_trace_statistics": result = await this.handleGetTraceStatistics(args); break; case "flush_traces": result = await this.handleFlushTraces(args); break; case "compress_old_traces": result = await this.handleCompressOldTraces(args); break; case "plan_only": result = await this.handlePlanOnly(args); break; case "call_codex": result = await this.handleCallCodex(args); break; case "call_claude": result = await this.handleCallClaude(args); break; case "plan_gate": result = await this.handlePlanGate(args); break; case "approve_plan": result = await this.handleApprovePlan(args); break; case "pending_list": result = await this.handlePendingList(); break; case "pending_clear": result = await this.handlePendingClear(args); break; case "pending_show": result = await this.handlePendingShow(args); break; case "smart_context": result = await this.handleSmartContext(args); break; case "get_summary": result = await this.handleGetSummary(args); break; // Discovery tools case "sm_discover": result = await this.handleSmDiscover(args); break; case "sm_related_files": result = await this.handleSmRelatedFiles(args); break; case "sm_session_summary": result = await this.handleSmSessionSummary(args); break; case "sm_search": result = await this.handleSmSearch(args); break; // DiffMem handlers case "diffmem_get_user_context": result = await this.diffMemHandlers.handleGetUserContext(args); break; case "diffmem_store_learning": result = await this.diffMemHandlers.handleStoreLearning(args); break; case "diffmem_search": result = await this.diffMemHandlers.handleSearch(args); break; case "diffmem_status": result = await this.diffMemHandlers.handleStatus(); break; default: throw new Error(`Unknown tool: ${name}`); } } catch (err) { error = err instanceof Error ? err : new Error(String(err)); toolCall.error = error.message; throw err; } finally { const endTime = Date.now(); if (currentFrameId && name !== "close_frame") { try { this.frameManager.addEvent("tool_result", { tool_name: name, success: !error, result: error ? { error: error.message } : result, timestamp: endTime }); } catch { } } toolCall.result = error ? void 0 : result; toolCall.duration = endTime - startTime; if (args.file_path || args.path) { toolCall.filesAffected = [args.file_path || args.path].filter( Boolean ); } else if (result?.files) { const files = result.files; toolCall.filesAffected = Array.isArray(files) ? files : [files]; } this.traceDetector.addToolCall(toolCall); } return result; } ); } // Handle plan_and_code tool by invoking the mm harness async handlePlanAndCode(args) { const { runSpike } = await import("../../orchestrators/multimodal/harness.js"); const envPlanner = process.env["STACKMEMORY_MM_PLANNER_MODEL"]; const plannerModel = envPlanner || DEFAULT_PLANNER_MODEL; const reviewerModel = process.env["STACKMEMORY_MM_REVIEWER_MODEL"] || plannerModel; const implementer = args.implementer || process.env["STACKMEMORY_MM_IMPLEMENTER"] || DEFAULT_IMPLEMENTER; const maxIters = Number( args.maxIters ?? process.env["STACKMEMORY_MM_MAX_ITERS"] ?? DEFAULT_MAX_ITERS ); const execute = Boolean(args.execute); const record = Boolean(args.record); const recordFrame = Boolean(args.recordFrame); const compact = Boolean(args.compact); const task = String(args.task || "Plan and implement change"); const result = await runSpike( { task, repoPath: this.projectRoot }, { plannerModel, reviewerModel, implementer: implementer === "claude" ? "claude" : "codex", maxIters: isFinite(maxIters) ? Math.max(1, maxIters) : 2, dryRun: !execute, auditDir: void 0, recordFrame } ); if (record || recordFrame) { try { const planSummary = result.plan.summary || task; this.addContext("decision", `Plan: ${planSummary}`, 0.8); const approved = result.critique?.approved ? "approved" : "needs_changes"; this.addContext("decision", `Critique: ${approved}`, 0.6); } catch { } } const payload = compact ? { ...result, plan: compactPlan(result.plan) } : result; return { content: [ { type: "text", text: JSON.stringify({ ok: true, result: payload }) } ], isError: false }; } async handlePlanOnly(args) { const { runPlanOnly } = await import("../../orchestrators/multimodal/harness.js"); const task = String(args.task || "Plan change"); const plannerModel = args.plannerModel || process.env["STACKMEMORY_MM_PLANNER_MODEL"] || DEFAULT_PLANNER_MODEL; const plan = await runPlanOnly( { task, repoPath: this.projectRoot }, { plannerModel } ); return { content: [ { type: "text", text: JSON.stringify({ ok: true, plan }) } ], isError: false }; } async handleCallCodex(args) { const { callCodexCLI } = await import("../../orchestrators/multimodal/providers.js"); const prompt = String(args.prompt || ""); const extraArgs = Array.isArray(args.args) ? args.args : []; const execute = Boolean(args.execute); const resp = callCodexCLI(prompt, extraArgs, !execute); return { content: [ { type: "text", text: JSON.stringify({ ok: resp.ok, command: resp.command, output: resp.output }) } ], isError: false }; } async handleCallClaude(args) { const { callClaude } = await import("../../orchestrators/multimodal/providers.js"); const prompt = String(args.prompt || ""); const model = args.model || process.env["STACKMEMORY_MM_PLANNER_MODEL"] || DEFAULT_PLANNER_MODEL; const system = args.system || "You are a precise assistant. Return plain text unless asked for JSON."; const text = await callClaude(prompt, { model, system }); return { content: [ { type: "text", text: JSON.stringify({ ok: true, text }) } ], isError: false }; } // Pending plan persistence (best-effort) getPendingStoreDir() { return join(this.projectRoot, ".stackmemory", "build"); } getPendingStorePath() { return join(this.getPendingStoreDir(), "pending.json"); } loadPendingPlans() { try { const file = this.getPendingStorePath(); let sourceFile = file; if (!existsSync(file)) { const legacy = join( this.projectRoot, ".stackmemory", "mm-spike", "pending.json" ); if (existsSync(legacy)) sourceFile = legacy; else return; } const data = JSON.parse(readFileSync(sourceFile, "utf-8")); if (data && typeof data === "object") { this.pendingPlans = new Map(Object.entries(data)); if (sourceFile !== file) this.savePendingPlans(); } } catch { } } savePendingPlans() { try { const dir = this.getPendingStoreDir(); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const file = this.getPendingStorePath(); const obj = Object.fromEntries(this.pendingPlans); writeFileSync(file, JSON.stringify(obj, null, 2)); } catch { } } async handlePlanGate(args) { const { runPlanOnly } = await import("../../orchestrators/multimodal/harness.js"); const task = String(args.task || "Plan change"); const plannerModel = args.plannerModel || process.env["STACKMEMORY_MM_PLANNER_MODEL"] || DEFAULT_PLANNER_MODEL; const plan = await runPlanOnly( { task, repoPath: this.projectRoot }, { plannerModel } ); const approvalId = `appr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; this.pendingPlans.set(approvalId, { task, plan, createdAt: Date.now() }); this.savePendingPlans(); const compact = Boolean(args.compact); const planOut = compact ? compactPlan(plan) : plan; return { content: [ { type: "text", text: JSON.stringify({ ok: true, approvalId, plan: planOut }) } ], isError: false }; } async handleApprovePlan(args) { const { runSpike } = await import("../../orchestrators/multimodal/harness.js"); const approvalId = String(args.approvalId || ""); const pending = this.pendingPlans.get(approvalId); if (!pending) { return { content: [ { type: "text", text: JSON.stringify({ ok: false, error: "Invalid approvalId" }) } ], isError: false }; } const implementer = args.implementer || process.env["STACKMEMORY_MM_IMPLEMENTER"] || DEFAULT_IMPLEMENTER; const maxIters = Number( args.maxIters ?? process.env["STACKMEMORY_MM_MAX_ITERS"] ?? DEFAULT_MAX_ITERS ); const recordFrame = args.recordFrame !== false; const execute = args.execute !== false; const result = await runSpike( { task: pending.task, repoPath: this.projectRoot }, { plannerModel: process.env["STACKMEMORY_MM_PLANNER_MODEL"] || DEFAULT_PLANNER_MODEL, reviewerModel: process.env["STACKMEMORY_MM_REVIEWER_MODEL"] || process.env["STACKMEMORY_MM_PLANNER_MODEL"] || DEFAULT_PLANNER_MODEL, implementer: implementer === "claude" ? "claude" : "codex", maxIters: isFinite(maxIters) ? Math.max(1, maxIters) : 2, dryRun: !execute, recordFrame } ); this.pendingPlans.delete(approvalId); this.savePendingPlans(); const compact = Boolean(args.compact); const payload = compact ? { ...result, plan: compactPlan(result.plan) } : result; return { content: [ { type: "text", text: JSON.stringify({ ok: true, approvalId, result: payload }) } ], isError: false }; }