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.

683 lines (679 loc) 20.2 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 express from "express"; import cors from "cors"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; import Database from "better-sqlite3"; import { validateInput, StartFrameSchema, AddAnchorSchema } from "./schemas.js"; import { readFileSync, existsSync, mkdirSync } from "fs"; import { randomUUID } from "crypto"; 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"; const DEFAULT_PORT = 3847; class RemoteStackMemoryMCP { server; db; projectRoot; frameManager; taskStore = null; linearAuthManager = null; linearSync = null; projectId; contexts = /* @__PURE__ */ new Map(); transport = null; constructor(projectRoot) { this.projectRoot = 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-remote", version: "0.1.0" }, { capabilities: { tools: {} } } ); this.setupHandlers(); this.loadInitialContext(); logger.info("StackMemory Remote 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(); } async initLinearIfEnabled() { if (!isFeatureEnabled("linear")) { return; } try { const { LinearTaskManager } = await import("../../features/tasks/linear-task-manager.js"); const { LinearAuthManager } = await import("../linear/auth.js"); const { LinearSyncEngine, DEFAULT_SYNC_CONFIG } = 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_CONFIG ); } 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: "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: "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"] } } ] }; } ); 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; try { switch (name) { case "get_context": return this.handleGetContext(args); case "add_decision": return this.handleAddDecision(args); case "start_frame": return this.handleStartFrame(args); case "close_frame": return this.handleCloseFrame(args); case "add_anchor": return this.handleAddAnchor(args); case "get_hot_stack": return this.handleGetHotStack(args); case "sm_search": return this.handleSmSearch(args); default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [{ type: "text", text: `Error: ${error.message}` }] }; } } ); } async handleGetContext(args) { const { query = "", limit = 10 } = args; const contexts = Array.from(this.contexts.values()).sort((a, b) => b.importance - a.importance).slice(0, limit); const response = contexts.map( (ctx) => `[${ctx.type.toUpperCase()}] (importance: ${ctx.importance.toFixed(2)}) ${ctx.content}` ).join("\n\n---\n\n"); return { content: [ { type: "text", text: response || "No context available yet." } ] }; } async handleAddDecision(args) { const { content, type = "decision" } = args; const id = this.addContext(type, content, 0.8); return { content: [ { type: "text", text: `Added ${type}: ${content} ID: ${id}` } ] }; } async handleStartFrame(args) { const { name, type, constraints } = validateInput( StartFrameSchema, args, "start_frame" ); const inputs = {}; if (constraints) { inputs.constraints = constraints; } const frameId = this.frameManager.createFrame({ type, name, inputs }); this.addContext("active_frame", `Active frame: ${name} (${type})`, 0.9); return { content: [ { type: "text", text: `Started ${type}: ${name} Frame ID: ${frameId} Stack depth: ${this.frameManager.getStackDepth()}` } ] }; } async handleCloseFrame(args) { const { result, outputs } = args; const currentFrameId = this.frameManager.getCurrentFrameId(); if (!currentFrameId) { return { content: [{ type: "text", text: "No active frame to close" }] }; } this.frameManager.closeFrame(currentFrameId, outputs); return { content: [ { type: "text", text: `Closed frame: ${result || "completed"} Stack depth: ${this.frameManager.getStackDepth()}` } ] }; } async handleAddAnchor(args) { const { type, text, priority } = validateInput( AddAnchorSchema, args, "add_anchor" ); const anchorId = this.frameManager.addAnchor(type, text, priority); return { content: [ { type: "text", text: `Added ${type}: ${text} Anchor ID: ${anchorId}` } ] }; } async handleGetHotStack(args) { const { maxEvents = 20 } = args; const hotStack = this.frameManager.getHotStackContext(maxEvents); const activePath = this.frameManager.getActiveFramePath(); if (hotStack.length === 0) { return { content: [ { type: "text", text: "No active frames. Start a frame with start_frame tool." } ] }; } let response = "Active Call Stack:\n\n"; activePath.forEach((frame, index) => { const indent = " ".repeat(index); const context = hotStack[index]; response += `${indent}${index + 1}. ${frame.name} (${frame.type}) `; if (context?.anchors?.length > 0) { response += `${indent} Anchors: ${context.anchors.length} `; } if (context?.recentEvents?.length > 0) { response += `${indent} Events: ${context.recentEvents.length} `; } response += "\n"; }); response += `Total stack depth: ${hotStack.length}`; return { content: [{ type: "text", text: response }] }; } async handleSmSearch(args) { const { query, scope = "all", limit = 20 } = args; if (!query) { throw new Error("Query is required"); } const results = []; if (scope === "all" || scope === "frames") { const frames = this.db.prepare( ` SELECT frame_id, name, type, created_at FROM frames WHERE project_id = ? AND (name LIKE ? OR inputs LIKE ? OR outputs LIKE ?) ORDER BY created_at DESC LIMIT ? ` ).all( this.projectId, `%${query}%`, `%${query}%`, `%${query}%`, limit ); frames.forEach((f) => { results.push({ type: "frame", id: f.frame_id, name: f.name, frameType: f.type }); }); } if (scope === "all" || scope === "decisions") { const anchors = this.db.prepare( ` SELECT a.anchor_id, a.type, a.text, f.name as frame_name FROM anchors a JOIN frames f ON a.frame_id = f.frame_id WHERE f.project_id = ? AND a.text LIKE ? ORDER BY a.created_at DESC LIMIT ? ` ).all(this.projectId, `%${query}%`, limit); anchors.forEach((a) => { results.push({ type: "decision", id: a.anchor_id, decisionType: a.type, text: a.text, frame: a.frame_name }); }); } let response = `Search Results for "${query}" `; response += `Found ${results.length} results `; results.slice(0, 10).forEach((r) => { if (r.type === "frame") { response += `[Frame] ${r.name} (${r.frameType}) `; } else if (r.type === "decision") { response += `[${r.decisionType}] ${r.text.slice(0, 60)}... `; } }); return { content: [{ type: "text", text: response }] }; } /** * Start the HTTP/SSE server */ async startHttpServer(port = DEFAULT_PORT) { const app = express(); app.use( cors({ origin: [ "https://claude.ai", "https://console.anthropic.com", /^http:\/\/localhost:\d+$/ ], credentials: true }) ); app.use(express.json()); app.get("/health", (req, res) => { res.json({ status: "ok", server: "stackmemory-remote", projectId: this.projectId, projectRoot: this.projectRoot }); }); this.transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); await this.server.connect(this.transport); app.all("/mcp", async (req, res) => { logger.info("MCP request", { method: req.method }); try { await this.transport.handleRequest(req, res, req.body); } catch (error) { logger.error("MCP request error", { error }); if (!res.headersSent) { res.status(500).json({ error: "Internal server error" }); } } }); app.get("/sse", (req, res) => { res.redirect(307, "/mcp"); }); app.post("/message", (req, res) => { res.redirect(307, "/mcp"); }); app.get("/info", (req, res) => { res.json({ name: "stackmemory-remote", version: "0.2.0", protocol: "mcp", transport: "streamable-http", endpoints: { mcp: "/mcp", health: "/health", info: "/info" }, project: { id: this.projectId, root: this.projectRoot, name: this.getProjectInfo().name } }); }); return new Promise((resolve) => { app.listen(port, () => { console.log( `StackMemory Remote MCP Server running on http://localhost:${port}` ); console.log(` Endpoints:`); console.log(` MCP: http://localhost:${port}/mcp`); console.log(` Health: http://localhost:${port}/health`); console.log(` Info: http://localhost:${port}/info`); console.log( ` Project: ${this.getProjectInfo().name} (${this.projectId})` ); console.log( ` For Claude.ai connector, use: http://localhost:${port}/sse` ); resolve(); }); }); } } var remote_server_default = RemoteStackMemoryMCP; async function runRemoteMCPServer(port = DEFAULT_PORT, projectRoot) { const server = new RemoteStackMemoryMCP(projectRoot); await server.startHttpServer(port); } if (import.meta.url === `file://${process.argv[1]}`) { const port = parseInt( process.env.PORT || process.argv[2] || String(DEFAULT_PORT), 10 ); const projectRoot = process.argv[3] || process.cwd(); runRemoteMCPServer(port, projectRoot).catch((error) => { console.error("Failed to start remote MCP server:", error); process.exit(1); }); } export { remote_server_default as default, runRemoteMCPServer }; //# sourceMappingURL=remote-server.js.map