/** * MemPalace ↔ pi bridge. * * Spawns the `mempalace-mcp` MCP stdio server as a subprocess, performs the * MCP `initialize` handshake, lists available tools, and registers each * one as a pi tool that proxies to `tools/call`. * * Lifecycle automation (per ~/.agents/skills/mempalace/SKILL.md): * - Wake-up (auto): on first user prompt of a fresh session, inject * `mempalace_status` + `mempalace_diary_read` output as context so the * agent orients itself the way the mempalace skill describes. Skipped * on resume/fork (palace context is already in the thread). * - Wind-down (manual): `/mempalace-diary` command prompts the LLM to * write an AAAK-formatted diary entry. Not fully auto because pi * sessions are typically short/tactical and session_shutdown is too * late to drive an LLM turn. * * Identity: `agent_name` for diary calls comes from $MEMPALACE_AGENT_NAME, * defaulting to "pi". First diary write creates `wing_pi`. * * Fail-soft: if the MCP subprocess can't start, pi keeps working without * palace tools (warning on stderr only). * * Debug: set MEMPALACE_EXT_DEBUG=1 to surface mempalace-mcp stderr. */ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; // Minimal MCP stdio JSON-RPC client. MCP uses newline-delimited JSON. interface McpTool { name: string; description?: string; inputSchema?: unknown; } class McpClient { private proc: ChildProcessWithoutNullStreams | null = null; private nextId = 1; private pending = new Map void; reject: (e: Error) => void }>(); private stdoutBuf = ""; private ready: Promise | null = null; public tools: McpTool[] = []; async start(command: string, args: string[] = []): Promise { if (this.ready) return this.ready; this.ready = (async () => { this.proc = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); this.proc.on("error", (err) => this.failAll(err)); this.proc.on("exit", (code) => this.failAll(new Error(`mempalace-mcp exited (code=${code})`)), ); this.proc.stdout.setEncoding("utf8"); this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk)); // Drain stderr silently. Re-enable by setting MEMPALACE_EXT_DEBUG=1. this.proc.stderr.setEncoding("utf8"); if (process.env.MEMPALACE_EXT_DEBUG) { this.proc.stderr.on("data", (chunk: string) => { process.stderr.write(`[mempalace-mcp stderr] ${chunk}`); }); } else { this.proc.stderr.resume(); // drain without logging } // MCP initialize handshake. await this.request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "pi-mempalace-ext", version: "0.1.0" }, }); this.notify("notifications/initialized", {}); const listed = await this.request("tools/list", {}); this.tools = (listed?.tools as McpTool[]) ?? []; })(); return this.ready; } private onStdout(chunk: string) { this.stdoutBuf += chunk; let nl: number; while ((nl = this.stdoutBuf.indexOf("\n")) !== -1) { const line = this.stdoutBuf.slice(0, nl).trim(); this.stdoutBuf = this.stdoutBuf.slice(nl + 1); if (!line) continue; let msg: any; try { msg = JSON.parse(line); } catch { continue; } if (typeof msg.id === "number" && this.pending.has(msg.id)) { const { resolve, reject } = this.pending.get(msg.id)!; this.pending.delete(msg.id); if (msg.error) reject(new Error(msg.error.message ?? "MCP error")); else resolve(msg.result); } // notifications (no id) ignored for now. } } private failAll(err: Error) { for (const { reject } of this.pending.values()) reject(err); this.pending.clear(); } private write(obj: unknown) { if (!this.proc) throw new Error("MCP process not started"); this.proc.stdin.write(`${JSON.stringify(obj)}\n`); } private notify(method: string, params: unknown) { this.write({ jsonrpc: "2.0", method, params }); } request(method: string, params: unknown): Promise { const id = this.nextId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.write({ jsonrpc: "2.0", id, method, params }); }); } async callTool(name: string, args: Record): Promise { return this.request("tools/call", { name, arguments: args }); } stop() { if (this.proc) { try { this.proc.kill("SIGTERM"); } catch {} this.proc = null; } } } export default async function mempalaceExtension(pi: ExtensionAPI) { const client = new McpClient(); let available = false; const agentName = process.env.MEMPALACE_AGENT_NAME ?? "pi"; // Gate: inject wake-up context only on the first before_agent_start of a // fresh session. Set true on resume/fork (context already in thread). let wokeUp = false; try { await client.start("mempalace-mcp"); available = true; } catch (err) { process.stderr.write( `[mempalace ext] failed to start mempalace-mcp: ${(err as Error).message}\n`, ); return; // fail-soft: pi keeps working without palace tools } // Register MCP tools as pi tools. Pass the MCP `inputSchema` through as // the pi `parameters` schema so the LLM sees the real parameter names // (e.g. `agent_name`, not guessed `agent`). TypeBox schemas are plain // JSON Schema at runtime, so `Type.Unsafe` is sufficient to wrap an // externally-sourced JSON Schema — no conversion needed. for (const tool of client.tools) { const schema = tool.inputSchema && typeof tool.inputSchema === "object" ? (Type.Unsafe>(tool.inputSchema as object) as unknown as ReturnType) : Type.Object({}, { additionalProperties: true }); pi.registerTool({ name: tool.name, label: tool.name, description: tool.description ?? `MemPalace tool: ${tool.name}`, parameters: schema, async execute(_toolCallId, params) { if (!available) { return { content: [{ type: "text", text: "mempalace-mcp not available" }], details: {}, isError: true, }; } try { const result = await client.callTool(tool.name, (params ?? {}) as Record); // MCP tool results use { content: [...], isError?: boolean } return { content: result?.content ?? [{ type: "text", text: JSON.stringify(result) }], details: { raw: result }, isError: result?.isError === true, }; } catch (err) { return { content: [{ type: "text", text: `MCP call failed: ${(err as Error).message}` }], details: {}, isError: true, }; } }, }); } pi.on("session_shutdown", async () => { client.stop(); }); pi.on("session_start", async (event, ctx) => { // On resume/fork, the previous session's palace context is already in // the thread — skip the wake-up injection. if (event.reason === "resume" || event.reason === "fork") { wokeUp = true; } ctx.ui.notify( `mempalace bridge: ${client.tools.length} tools registered (agent=${agentName})`, "info", ); }); // --- Auto wake-up (mempalace skill Phase 1) --- pi.on("before_agent_start", async (_event, _ctx) => { if (wokeUp || !available) return; wokeUp = true; // one-shot, even if the calls below fail const sections: string[] = []; try { const status = await client.callTool("mempalace_status", {}); const text = extractText(status); if (text) sections.push(`## mempalace_status\n\n${text}`); } catch (err) { sections.push(`## mempalace_status\n\n(error: ${(err as Error).message})`); } try { const diary = await client.callTool("mempalace_diary_read", { agent_name: agentName, last_n: 5, }); const text = extractText(diary); if (text) sections.push(`## mempalace_diary_read (agent=${agentName}, last_n=5)\n\n${text}`); } catch (err) { sections.push(`## mempalace_diary_read\n\n(error: ${(err as Error).message})`); } if (sections.length === 0) return; const body = `MemPalace wake-up context (auto-injected by the mempalace extension). ` + `This is your palace orientation for this session — do not announce it to the user, ` + `just use it to inform your answers. Agent identity for diary tools: "${agentName}".\n\n` + sections.join("\n\n---\n\n"); return { message: { customType: "mempalace-wakeup", content: body, display: true, }, }; }); // --- Manual wind-down (mempalace skill Phase 3) --- pi.registerCommand("mempalace-diary", { description: "Ask the LLM to write an AAAK diary entry summarizing this session", handler: async (args, ctx) => { if (!available) { ctx.ui.notify("mempalace bridge not available", "warning"); return; } const topic = args.trim() || "session-summary"; const prompt = `Write a MemPalace diary entry for this session using the AAAK format ` + `described in the mempalace skill. Call mempalace_diary_write with ` + `agent_name="${agentName}", topic="${topic}", and a compressed AAAK entry ` + `that summarizes what we worked on, what was discovered, and any open ` + `threads. Then confirm the write succeeded. Do not ask me for ` + `clarification — draft from the session so far.`; pi.sendUserMessage(prompt); }, }); } /** Flatten MCP tool result content into plain text for context injection. */ function extractText(mcpResult: any): string { const parts = mcpResult?.content; if (!Array.isArray(parts)) return typeof mcpResult === "string" ? mcpResult : ""; return parts .map((p: any) => (typeof p?.text === "string" ? p.text : "")) .filter(Boolean) .join("\n"); }