diff --git a/README.md b/README.md index 7116e2b..ce3ca2a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Producer-side tooling for [MemPalace](https://github.com/MemPalace/mempalace) - `bin/mempalace-docs` — mines project directories into MemPalace while excluding source code, keeping the palace signal-dense. - [`ARCHITECTURE.md`](ARCHITECTURE.md) — **canonical spec**: architecture diagram, component details, setup recipe, operational notes, upstream-retirement roadmap. - [`SKILL.md`](SKILL.md) — the companion agent skill, symlinked into `~/.agents/skills/opencode-mempalace-bridge/` on install. +- [`extensions/pi/`](extensions/pi/) — the [pi coding-agent](https://github.com/mariozechner/pi-coding-agent) extension that bridges pi to the MemPalace MCP server (wake-up auto-injection, `/mempalace-diary` command, schema passthrough). Symlinked into `~/.pi/agent/extensions/` on install when pi is detected. **If you're just trying to get this working on a new machine → jump to [Setup](#setup).** **If you want the full architecture story → read [`ARCHITECTURE.md`](ARCHITECTURE.md).** @@ -221,7 +222,7 @@ cd ~/mempalace-toolkit ./install.sh ``` -The installer symlinks `bin/*` into `~/.local/bin/` and optionally installs the agent skill into `~/.agents/skills/opencode-mempalace-bridge/`. +The installer symlinks `bin/*` into `~/.local/bin/` and installs the agent skill into `~/.agents/skills/opencode-mempalace-bridge/`. If [pi](https://github.com/mariozechner/pi-coding-agent) is installed (detected via `~/.pi/agent/extensions/`), it also symlinks [`extensions/pi/mempalace.ts`](extensions/pi/) into that directory so the pi↔mempalace bridge tracks version control. On machines without pi this step is silently skipped. Works on macOS and Linux. Ensure `~/.local/bin` is on `$PATH`: diff --git a/extensions/pi/README.md b/extensions/pi/README.md new file mode 100644 index 0000000..1471c59 --- /dev/null +++ b/extensions/pi/README.md @@ -0,0 +1,89 @@ +# pi ↔ MemPalace extension + +The canonical source of `~/.pi/agent/extensions/mempalace.ts` — the bridge +that wires the [MemPalace](https://github.com/MemPalace/mempalace) MCP +server into the [pi coding-agent](https://github.com/mariozechner/pi-coding-agent) +harness. + +`install.sh` at the repo root symlinks `mempalace.ts` from this directory +into `~/.pi/agent/extensions/` so the live file on every machine tracks +version control. Works on macOS and Linux (the extension itself is plain +Node / TypeScript; the symlink is a POSIX `ln -s`). + +--- + +## What it does + +1. **Spawns `mempalace-mcp`** as a subprocess and does the MCP stdio + JSON-RPC handshake (`initialize` + `notifications/initialized` + + `tools/list`). +2. **Registers each MCP tool** as a pi tool with its real `inputSchema` + passed through via `Type.Unsafe(...)` (see gotcha below). +3. **Wake-up auto-injection** (`before_agent_start`, one-shot per fresh + session): calls `mempalace_status` + `mempalace_diary_read` and + injects the result as a `mempalace-wakeup` system message so the + agent orients itself the way `~/.agents/skills/mempalace/SKILL.md` + describes. Skipped on resume/fork (context is already in the thread). +4. **Manual wind-down** via a `/mempalace-diary [topic]` slash command: + sends a prompt asking the LLM to call `mempalace_diary_write` with + an AAAK-formatted entry summarizing the session. Not fully auto + because pi sessions are typically short/tactical and + `session_shutdown` fires too late to drive another LLM turn. + +## Fail-soft + +If `mempalace-mcp` can't be spawned (PATH missing, binary crashes at +startup, …) the extension logs to stderr and returns early. pi keeps +working without palace tools rather than refusing to start. + +## Identity + +`agent_name` for diary calls comes from `$MEMPALACE_AGENT_NAME`, defaulting +to `"pi"`. First diary write against that identity creates `wing_` +in the palace. Set the env var if you want to run pi under a distinct +identity on a given machine (e.g. `pi-laptop` vs `pi-server`). + +## Debugging + +- `MEMPALACE_EXT_DEBUG=1` — surface `mempalace-mcp` stderr into pi's + stderr. Without this, stderr is drained silently so a misbehaving + server doesn't flood the TUI. +- If a tool call fails with a generic "Internal tool error", spawn + `mempalace-mcp` manually with raw JSON-RPC on stdin to read the + server-side error — much faster than guessing. + +## The `Type.Unsafe` gotcha + +Earlier versions of this extension registered every MCP tool with +`parameters: Type.Object({}, { additionalProperties: true })`, which +discarded each tool's real `inputSchema`. The LLM then saw no parameter +names and had to guess, leading to bugs like `mempalace_diary_read` +being called with `agent=` instead of the required `agent_name=` and +crashing the Python server with `TypeError: missing 1 required +positional argument`. + +The fix (≈ lines 160-170) is to wrap the incoming JSON Schema with +`Type.Unsafe<...>(tool.inputSchema)`. TypeBox schemas are plain JSON +Schema at runtime plus a `Symbol` marker, so wrapping an +externally-sourced schema with `Unsafe` is sufficient — no conversion +to a full TypeBox tree is needed, and the LLM now sees every tool's +real parameter names. + +If you ever need to re-loosen the schema for debugging, fall back to +the `Type.Object({}, { additionalProperties: true })` default only for +that specific tool, not globally. + +## File layout + +``` +mempalace-toolkit/ +└── extensions/ + └── pi/ + ├── README.md ← this file + └── mempalace.ts ← symlinked into ~/.pi/agent/extensions/ +``` + +`install.sh` detects pi by probing for `~/.pi/agent/extensions/` and +only creates the symlink when that directory exists. On machines +without pi the file stays dormant in the repo. Re-runs are idempotent +(same pattern as `bin/` and `SKILL.md`). diff --git a/extensions/pi/mempalace.ts b/extensions/pi/mempalace.ts new file mode 100644 index 0000000..fdfe491 --- /dev/null +++ b/extensions/pi/mempalace.ts @@ -0,0 +1,286 @@ +/** + * 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"); +} diff --git a/install.sh b/install.sh index 3ba7e2b..3932c49 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,11 @@ SKILL_SRC="${SCRIPT_DIR}/SKILL.md" SKILL_DEST_DIR="${HOME}/.agents/skills/opencode-mempalace-bridge" SKILL_DEST="${SKILL_DEST_DIR}/SKILL.md" +# pi coding-agent extension (optional — only linked if pi is installed) +PI_EXT_SRC="${SCRIPT_DIR}/extensions/pi/mempalace.ts" +PI_EXT_DEST_DIR="${HOME}/.pi/agent/extensions" +PI_EXT_DEST="${PI_EXT_DEST_DIR}/mempalace.ts" + # ── args ───────────────────────────────────────────── ACTION="install" ASSUME_YES="no" @@ -38,12 +43,15 @@ What install does: - Symlinks SKILL.md into ~/.agents/skills/opencode-mempalace-bridge/SKILL.md (auto-discovered by opencode; run agents-sync from cli_utils to also reach Claude Code and Kiro) + - If pi (~/.pi/agent/extensions/) exists, symlinks extensions/pi/mempalace.ts + into ~/.pi/agent/extensions/mempalace.ts (pi bridge). Skipped otherwise. - Drops a .skill-source marker in the skill dir so sibling tooling (deploy-skills.sh, agents-sync.zsh) knows the dir is externally owned What uninstall does: - Removes symlinks in ~/.local/bin/ that point into this repo - Removes the skill symlink if it points into this repo + - Removes the pi extension symlink if it points into this repo - Removes the .skill-source marker and empty skill dir EOF exit 0 ;; @@ -219,6 +227,36 @@ check_opencode_mcp() { return 0 } +install_pi_extension() { + # The pi coding-agent extension is optional: link it only if pi is + # already installed on this machine (its ~/.pi/agent/extensions/ + # directory exists). Otherwise silently skip — mempalace-toolkit is + # useful on opencode-only boxes too. + if [[ ! -d "$PI_EXT_DEST_DIR" ]]; then + note "pi not detected at $PI_EXT_DEST_DIR — skipping pi extension" + printf ' (install pi first if you want the pi↔mempalace bridge:\n' + printf ' https://github.com/mariozechner/pi-coding-agent)\n' + return 0 + fi + + note "Linking pi extension into $PI_EXT_DEST_DIR" + if [[ -e "$PI_EXT_DEST" || -L "$PI_EXT_DEST" ]]; then + if link_if_into_repo "$PI_EXT_DEST"; then + ok "pi extension already linked" + return 0 + fi + # Non-symlink (or foreign symlink) in the way — back it up rather + # than clobber. User may have local edits they want to preserve. + local backup="${PI_EXT_DEST}.bak.$(date +%Y%m%d-%H%M%S)" + mv "$PI_EXT_DEST" "$backup" + warn "Existing $PI_EXT_DEST backed up to $backup" + fi + ln -s "$PI_EXT_SRC" "$PI_EXT_DEST" + ok "Linked mempalace.ts → $PI_EXT_SRC" + printf ' Restart pi to load the extension (it reads ~/.pi/agent/extensions/\n' + printf ' at startup only).\n' +} + do_install() { echo echo "mempalace-toolkit installer" @@ -227,6 +265,9 @@ do_install() { echo "==> Installation plan:" echo " Symlink executables in bin/ into $BIN_DEST" echo " Symlink SKILL.md into $SKILL_DEST" + if [[ -d "$PI_EXT_DEST_DIR" ]]; then + echo " Symlink extensions/pi/mempalace.ts into $PI_EXT_DEST" + fi echo confirm || { echo "Aborted."; exit 0; } echo @@ -234,6 +275,8 @@ do_install() { echo install_skill echo + install_pi_extension + echo check_path echo check_wake_up_protocol @@ -278,6 +321,15 @@ do_uninstall() { ok "No skill symlink to remove" fi + echo + note "Removing pi extension symlink" + if link_if_into_repo "$PI_EXT_DEST"; then + rm "$PI_EXT_DEST" + ok "Removed pi extension symlink" + else + ok "No pi extension symlink to remove" + fi + # Remove the marker and the now-empty skill directory, but only if # the marker was written by us and the directory has nothing else in it. local marker="$SKILL_DEST_DIR/.skill-source"