Files
mempalace-toolkit/extensions/pi/mempalace.ts
T
pi 96699f2a17 feat(pi-bridge): external MemPalace transport via MEMPALACE_REMOTE_URL
Let the pi<->mempalace bridge connect to a shared MemPalace over HTTP instead
of always spawning a local mempalace-mcp:
- Extract IMcpClient; rename McpClient -> StdioMcpClient (ctor command, arg-less start()).
- Add RemoteMcpClient (vendored from pi-extensions/mcp-loader.ts): streamable-HTTP
  with AbortController timeouts, protocolVersion pinned 2024-11-05, alive/ensureAlive.
  mempalace-mcp --transport http is sessionless JSON-RPC today; session/SSE/404
  branches retained for a future streamable-HTTP server.
- createClient() selects transport from MEMPALACE_REMOTE_URL; MEMPALACE_REMOTE_TOKEN
  -> Authorization: Bearer. Lifecycle automation (wake-up, /mempalace-diary) unchanged.
- scripts/check-mcp-client-sync.sh: drift guard vs canonical mcp-loader.ts.
- README: document local-vs-external transport.

Typechecks clean (strict); both transports smoke-tested against live mempalace-mcp.
2026-07-02 13:09:29 +02:00

814 lines
29 KiB
TypeScript

/**
* MemPalace ↔ pi bridge.
*
* Registers every MemPalace MCP tool as a pi tool that proxies to `tools/call`.
* Two interchangeable transports, selected at load time:
*
* - LOCAL (default): spawn the `mempalace-mcp` stdio server as a subprocess
* (StdioMcpClient) — hardened with per-request timeouts + generation-tracked
* respawn/self-heal for slow cold-opens (see below).
* - EXTERNAL: connect to a shared MemPalace over HTTP (RemoteMcpClient) when
* $MEMPALACE_REMOTE_URL is set — e.g. one palace serving pi + opencode +
* native. Optional bearer auth via $MEMPALACE_REMOTE_TOKEN. No local
* `mempalace-mcp` process is spawned in this mode.
*
* Either way the client performs the MCP `initialize` handshake, lists tools,
* and the extension body below is transport-agnostic (depends only on the
* IMcpClient interface).
*
* 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).
*
* Stall protection: every JSON-RPC request carries a timeout. If
* `mempalace-mcp` wedges (e.g. OrbStack virtiofs cold-open of a large
* chroma.sqlite3 / HNSW load), the awaiting promise would otherwise hang
* forever and freeze the pi TUI (ESC cancels the LLM stream, not a pending
* tool execution). On timeout we reject the request AND kill the wedged
* child, so pi gets an error instead of hanging and later calls fail fast.
* This is a per-REQUEST timeout, not a process-lifetime one — the
* long-lived server is only killed when a request genuinely stalls.
* - MEMPALACE_MCP_TIMEOUT_MS tool-call/request timeout (default 60000)
* - MEMPALACE_MCP_INIT_TIMEOUT_MS initialize+tools/list timeout (default 300000)
* Set either to 0 to disable (legacy unbounded behavior).
*
* Self-heal (respawn): a stall-kill (or any crash) is no longer a permanent
* latch. The next tool call transparently respawns `mempalace-mcp` and
* retries, with capped exponential backoff so a persistently-broken server
* can't hot-loop. The respawn budget resets on ANY successful JSON-RPC
* response (proof the server is actually live), so a recovered server
* regains full patience. The two timeouts above are deliberately split:
* the long INIT timeout lets a genuine first cold-open finish without being
* killed (the original incident), while the short per-call timeout still
* aggressively kills a stuck query. After a server has opened the palace
* once, the OS page cache is warm, so respawn cold-opens are fast — which
* is exactly why a generous INIT timeout + bounded respawn compose well
* instead of overlapping.
* - MEMPALACE_MCP_MAX_RESPAWNS respawn attempts before giving up (default 2; 0 disables self-heal)
* - MEMPALACE_MCP_RESPAWN_BACKOFF_MS base backoff, doubled per attempt (default 1000)
*
* Debug: set MEMPALACE_EXT_DEBUG=1 to surface mempalace-mcp stderr.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import type { ExtensionAPI } from "@earendil-works/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;
}
interface Pending {
resolve: (v: any) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout> | null;
}
// Transport-agnostic client contract. The extension body depends only on this,
// so LOCAL (StdioMcpClient) and EXTERNAL (RemoteMcpClient) are drop-in swaps.
// Each implementation owns its own reliability model — stdio uses timeout +
// kill + respawn; http uses fetch + AbortController timeout (+ 404 re-init if
// a future streamable-HTTP server issues sessions).
interface IMcpClient {
tools: McpTool[];
readonly alive: boolean;
onExit: (() => void) | null;
start(): Promise<void>;
callTool(name: string, args: Record<string, unknown>): Promise<any>;
ensureAlive(): Promise<boolean>;
stop(): void | Promise<void>;
}
const num = (envVal: string | undefined, fallback: number): number => {
const n = envVal !== undefined ? Number(envVal) : Number.NaN;
return Number.isFinite(n) && n >= 0 ? n : fallback;
};
const sleep = (ms: number): Promise<void> =>
new Promise((res) => {
const t = setTimeout(res, ms);
if (typeof t.unref === "function") t.unref();
});
class StdioMcpClient implements IMcpClient {
private proc: ChildProcessWithoutNullStreams | null = null;
private nextId = 1;
private pending = new Map<number, Pending>();
private stdoutBuf = "";
private ready: Promise<void> | null = null;
public tools: McpTool[] = [];
// Per-request timeouts (ms). 0 = disabled (unbounded, legacy behavior).
private requestTimeoutMs = num(process.env.MEMPALACE_MCP_TIMEOUT_MS, 60_000);
private initTimeoutMs = num(process.env.MEMPALACE_MCP_INIT_TIMEOUT_MS, 300_000);
// Self-heal (respawn) controls.
private maxRespawns = num(process.env.MEMPALACE_MCP_MAX_RESPAWNS, 2);
private respawnBackoffMs = num(process.env.MEMPALACE_MCP_RESPAWN_BACKOFF_MS, 1_000);
private respawns = 0; // consecutive respawn attempts; reset on any success
private reviving: Promise<boolean> | null = null;
// Liveness: true only between a completed init and the matching death.
// Inferring from `proc` is racy (non-null during the SIGTERM→exit window).
private healthy = false;
// Spawn generation. Each (re)spawn bumps it; death handlers carry the gen
// they were attached for and no-op if a newer server has since taken over
// (prevents a stale OLD-proc 'exit' from clobbering a freshly respawned one).
private gen = 0;
// Spawn args remembered so a respawn can reuse them (set via constructor).
private command: string;
private args: string[];
// Fired when the child process dies (exit or stall-kill). Lets the
// extension flip `available` so later tool calls fail fast.
public onExit: (() => void) | null = null;
constructor(command = "mempalace-mcp", args: string[] = []) {
this.command = command;
this.args = args;
}
/** True only when a server has completed init and not since died. */
get alive(): boolean {
return this.healthy;
}
async start(): Promise<void> {
if (this.ready) return this.ready;
this.ready = (async () => {
const myGen = ++this.gen;
const child = spawn(this.command, this.args, { stdio: ["pipe", "pipe", "pipe"] });
this.proc = child;
child.on("error", (err) => this.handleDeath(myGen, err));
child.on("exit", (code) =>
this.handleDeath(myGen, 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. Cold-open over virtiofs can be slow, so
// use the (longer) init timeout here rather than the per-call one.
await this.request(
"initialize",
{
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "pi-mempalace-ext", version: "0.1.0" },
},
this.initTimeoutMs,
);
this.notify("notifications/initialized", {});
const listed = await this.request("tools/list", {}, this.initTimeoutMs);
this.tools = (listed?.tools as McpTool[]) ?? [];
// Init complete and the process is still ours — mark live so ensureAlive()
// reports true. Guard against a death that raced in during init.
if (myGen === this.gen) this.healthy = true;
})();
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 p = this.pending.get(msg.id)!;
this.settle(msg.id);
if (msg.error) p.reject(new Error(msg.error.message ?? "MCP error"));
else {
// A successful response proves the server is live — restore the
// full respawn budget for any future (unrelated) stall.
this.respawns = 0;
p.resolve(msg.result);
}
}
// notifications (no id) ignored for now.
}
}
/** Remove a pending request and clear its timeout timer. */
private settle(id: number) {
const p = this.pending.get(id);
if (p?.timer) clearTimeout(p.timer);
this.pending.delete(id);
}
private failAll(err: Error) {
for (const id of [...this.pending.keys()]) {
const p = this.pending.get(id)!;
this.settle(id);
p.reject(err);
}
}
/**
* Child died (exit, spawn error, or stall-kill): reject everything.
* Ignored if a newer generation has already taken over — a late 'exit'
* from a killed old process must not tear down a fresh respawn.
*/
private handleDeath(gen: number, err: Error) {
if (gen !== this.gen) return; // stale handler from a superseded process
this.proc = null;
this.healthy = false;
// Clear the memoized start() promise so a subsequent start()/ensureAlive()
// spawns a fresh server instead of returning the dead one's promise.
this.ready = null;
this.failAll(err);
try {
this.onExit?.();
} catch {}
}
/**
* Ensure a live, initialized server — respawning a dead one with capped
* exponential backoff. Returns whether the server is alive afterwards.
* Concurrent callers share a single in-flight revive. The attempt counter
* is reset by `onStdout` on any successful response, so a server that comes
* back and works regains its full budget; a server that keeps dying hits
* `maxRespawns` and stays down (restart pi) rather than hot-looping.
*/
async ensureAlive(): Promise<boolean> {
if (this.alive) return true;
if (this.reviving) return this.reviving;
this.reviving = (async () => {
while (!this.alive && this.respawns < this.maxRespawns) {
this.respawns++;
await sleep(this.respawnBackoffMs * 2 ** (this.respawns - 1));
// Force a fresh spawn: drop any settled (rejected/dead) start()
// promise so start() doesn't short-circuit on a stale memo. Safe
// because ensureAlive is serialized (single `reviving`) and only
// runs while not healthy — no useful in-flight start() can exist.
this.ready = null;
try {
await this.start();
} catch {
// start() rejected (e.g. respawn cold-open also stalled and was
// killed). Loop until the budget is exhausted.
}
}
return this.alive;
})();
try {
return await this.reviving;
} finally {
this.reviving = null;
}
}
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, timeoutMs = this.requestTimeoutMs): Promise<any> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null;
if (timeoutMs > 0) {
timer = setTimeout(() => {
if (!this.pending.has(id)) return;
this.settle(id);
reject(
new Error(
`mempalace-mcp request '${method}' timed out after ${timeoutMs}ms ` +
`(server wedged — likely cold storage open). Terminating the ` +
`stalled server; it will be respawned on the next call.`,
),
);
// Kill the wedged child so subsequent calls fail fast instead of
// stacking up behind a dead server. The 'exit' handler
// (handleDeath) rejects any other pending requests.
this.kill();
}, timeoutMs);
// Don't let a pending MCP timer keep the event loop alive.
if (typeof timer.unref === "function") timer.unref();
}
this.pending.set(id, { resolve, reject, timer });
try {
this.write({ jsonrpc: "2.0", id, method, params });
} catch (err) {
this.settle(id);
reject(err as Error);
}
});
}
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
return this.request("tools/call", { name, arguments: args });
}
/** SIGTERM then SIGKILL grace, for stall recovery. */
private kill() {
const proc = this.proc;
if (!proc) return;
try {
proc.kill("SIGTERM");
} catch {}
const t = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {}
}, 2_000);
if (typeof t.unref === "function") t.unref();
}
stop() {
if (this.proc) {
try {
this.proc.kill("SIGTERM");
} catch {}
this.proc = null;
}
}
}
// ───────────────────────────────────────────────────────────────────────
// RemoteMcpClient — EXTERNAL transport (streamable-HTTP / sessionless JSON-RPC).
//
// VENDORED from pi-extensions/extensions/mcp-loader.ts (class RemoteMcpClient).
// Kept as a copy rather than a shared import because the two repos ship
// independently; the canonical implementation lives in mcp-loader.ts. Port
// protocol fixes in both directions.
//
// MCP-STREAMABLE-HTTP-CLIENT-SYNC: v1
// This token must match the canonical block's. Bump it in BOTH files whenever
// the streamable-HTTP protocol handling changes; scripts/check-mcp-client-sync.sh
// fails when they diverge (skips gracefully if pi-extensions isn't checked out).
//
// Deltas from the canonical copy:
// • protocolVersion pinned to "2024-11-05" to match mempalace-mcp's stdio
// handshake (the server echoes whatever it is sent, so cosmetic today,
// but keeps both transports consistent).
// • per-request AbortController timeout honouring MEMPALACE_MCP_TIMEOUT_MS /
// MEMPALACE_MCP_INIT_TIMEOUT_MS, mirroring StdioMcpClient's timeout ethos.
// • alive / ensureAlive / onExit to satisfy IMcpClient.
//
// NOTE: mempalace-mcp --transport http is a SESSIONLESS, stateless JSON-RPC
// server (no Mcp-Session-Id, always application/json, Connection: close), so
// the session-id / SSE / 404-reinit branches below are never exercised against
// it today. They are retained so this client also works unchanged if mempalace
// later moves to a full streamable-HTTP (FastMCP) transport.
// ───────────────────────────────────────────────────────────────────────
const REMOTE_PROTOCOL_VERSION = "2024-11-05";
const REMOTE_CLIENT_INFO = { name: "pi-mempalace-ext", version: "0.1.0" };
class RemoteMcpClient implements IMcpClient {
private url: string;
private extraHeaders: Record<string, string>;
private sessionId: string | null = null;
private nextId = 1;
public tools: McpTool[] = [];
// Unused for HTTP (no child process to die) — present to satisfy IMcpClient.
public onExit: (() => void) | null = null;
private requestTimeoutMs = num(process.env.MEMPALACE_MCP_TIMEOUT_MS, 60_000);
private initTimeoutMs = num(process.env.MEMPALACE_MCP_INIT_TIMEOUT_MS, 300_000);
private healthy = false;
private reviving: Promise<boolean> | null = null;
constructor(url: string, headers?: Record<string, string>) {
this.url = url;
this.extraHeaders = headers ?? {};
}
get alive(): boolean {
return this.healthy;
}
async start(): Promise<void> {
await this.request(
"initialize",
{ protocolVersion: REMOTE_PROTOCOL_VERSION, capabilities: {}, clientInfo: REMOTE_CLIENT_INFO },
{ timeoutMs: this.initTimeoutMs },
);
await this.notify("notifications/initialized", {});
const listed = await this.request("tools/list", {}, { timeoutMs: this.initTimeoutMs });
this.tools = (listed?.tools as McpTool[]) ?? [];
this.healthy = true;
}
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
return this.request("tools/call", { name, arguments: args });
}
/**
* Re-establish connectivity (and re-list tools) after a fetch failure.
* For the stateless server this is just a fresh initialize+tools/list;
* concurrent callers share one in-flight attempt.
*/
async ensureAlive(): Promise<boolean> {
if (this.healthy) return true;
if (this.reviving) return this.reviving;
this.reviving = (async () => {
try {
this.sessionId = null;
await this.start();
} catch {
// stays unhealthy
}
return this.healthy;
})();
try {
return await this.reviving;
} finally {
this.reviving = null;
}
}
async stop(): Promise<void> {
this.healthy = false;
if (!this.sessionId) return;
try {
await fetch(this.url, { method: "DELETE", headers: this.buildHeaders() });
} catch {}
}
private buildHeaders(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": REMOTE_PROTOCOL_VERSION,
...this.extraHeaders,
};
if (this.sessionId) h["Mcp-Session-Id"] = this.sessionId;
return h;
}
private signalFor(timeoutMs: number): AbortSignal | undefined {
return timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
}
private async reinitialize(): Promise<void> {
await this.request(
"initialize",
{ protocolVersion: REMOTE_PROTOCOL_VERSION, capabilities: {}, clientInfo: REMOTE_CLIENT_INFO },
{ timeoutMs: this.initTimeoutMs, allowReinitOn404: false },
);
await this.notify("notifications/initialized", {});
}
private async request(
method: string,
params: unknown,
opts: { timeoutMs?: number; allowReinitOn404?: boolean } = {},
): Promise<any> {
const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
const allowReinitOn404 = opts.allowReinitOn404 ?? true;
const id = this.nextId++;
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
const sessionAtStart = this.sessionId;
let res: Response;
try {
res = await fetch(this.url, {
method: "POST",
headers: this.buildHeaders(),
body,
signal: this.signalFor(timeoutMs),
});
} catch (err) {
// Network failure / timeout (AbortError) — mark unreachable so the next
// tool call triggers ensureAlive().
this.healthy = false;
const e = err as Error;
const reason =
e.name === "AbortError" || e.name === "TimeoutError"
? `timed out after ${timeoutMs}ms`
: e.message;
throw new Error(`mempalace remote request '${method}' failed: ${reason}`);
}
const sid = res.headers.get("Mcp-Session-Id") ?? res.headers.get("mcp-session-id");
if (sid) this.sessionId = sid;
// 404 + we sent a session id → server forgot us. Drop id, re-init, retry once.
if (res.status === 404 && sessionAtStart && allowReinitOn404) {
try {
await res.arrayBuffer();
} catch {}
this.sessionId = null;
await this.reinitialize();
return this.request(method, params, { timeoutMs, allowReinitOn404: false });
}
if (!res.ok) {
let detail = "";
try {
detail = (await res.text()).slice(0, 200);
} catch {}
throw new Error(`mempalace remote: HTTP ${res.status} on ${method}${detail ? `: ${detail}` : ""}`);
}
const ct = (res.headers.get("content-type") ?? "").toLowerCase();
if (ct.includes("text/event-stream")) {
return await this.readSseForResponse(res, id);
}
if (ct.includes("application/json")) {
const json: any = await res.json();
if (json.error) throw new Error(json.error.message ?? "MCP error");
return json.result;
}
if (res.status === 202) return undefined;
throw new Error(`mempalace remote: unexpected content-type "${ct}" on ${method}`);
}
private async notify(method: string, params: unknown): Promise<void> {
const body = JSON.stringify({ jsonrpc: "2.0", method, params });
let res: Response;
try {
res = await fetch(this.url, {
method: "POST",
headers: this.buildHeaders(),
body,
signal: this.signalFor(this.requestTimeoutMs),
});
} catch (err) {
throw new Error(`mempalace remote: fetch failed on notify ${method}: ${(err as Error).message}`);
}
try {
await res.arrayBuffer();
} catch {}
if (!res.ok && res.status !== 202) {
throw new Error(`mempalace remote: HTTP ${res.status} on notify ${method}`);
}
}
private async readSseForResponse(res: Response, expectedId: number): Promise<any> {
if (!res.body) throw new Error("mempalace remote: SSE response had no body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let dataLines: string[] = [];
const tryDispatch = (): { matched: boolean; result?: any } => {
if (dataLines.length === 0) return { matched: false };
const data = dataLines.join("\n");
dataLines = [];
let msg: any;
try {
msg = JSON.parse(data);
} catch {
return { matched: false };
}
if (typeof msg.id === "number" && msg.id === expectedId) {
if (msg.error) throw new Error(msg.error.message ?? "MCP error");
return { matched: true, result: msg.result };
}
return { matched: false };
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const rawLine = buf.slice(0, nl);
buf = buf.slice(nl + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (line === "") {
const out = tryDispatch();
if (out.matched) return out.result;
} else if (line.startsWith(":")) {
// SSE comment / keepalive — ignore
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
}
}
}
const out = tryDispatch();
if (out.matched) return out.result;
} finally {
try {
reader.cancel();
} catch {}
}
throw new Error(`mempalace remote: SSE stream closed without response for id=${expectedId}`);
}
}
/**
* Pick the transport from the environment: MEMPALACE_REMOTE_URL selects the
* EXTERNAL (HTTP) client; unset falls back to the LOCAL stdio server.
*/
function createClient(): IMcpClient {
const remoteUrl = process.env.MEMPALACE_REMOTE_URL?.trim();
if (remoteUrl) {
return new RemoteMcpClient(remoteUrl, authHeaders());
}
return new StdioMcpClient("mempalace-mcp");
}
/** Bearer auth header for the remote transport, if MEMPALACE_REMOTE_TOKEN is set. */
function authHeaders(): Record<string, string> | undefined {
const token = process.env.MEMPALACE_REMOTE_TOKEN?.trim();
return token ? { Authorization: `Bearer ${token}` } : undefined;
}
export default async function mempalaceExtension(pi: ExtensionAPI) {
const client = createClient();
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 {
client.onExit = () => {
// Child died (stall-kill or crash): mark unavailable. The next tool
// call will attempt a bounded respawn via client.ensureAlive().
available = false;
};
await client.start();
available = true;
} catch (err) {
// First cold-open stalled/crashed. Give the bounded self-heal a chance
// before giving up entirely — a respawn often succeeds against a now-warm
// page cache. Only fail-soft if even that is exhausted.
process.stderr.write(
`[mempalace ext] mempalace-mcp start failed: ${(err as Error).message} — attempting respawn\n`,
);
available = await client.ensureAlive();
if (!available) {
process.stderr.write(
"[mempalace ext] mempalace-mcp unavailable after retries; continuing without palace tools\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<Record<string, unknown>>(tool.inputSchema as object) as unknown as ReturnType<typeof Type.Object>)
: 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) {
// Stall-kill/crash is not a permanent latch: try a bounded respawn.
available = await client.ensureAlive();
}
if (!available) {
return {
content: [
{
type: "text",
text: "mempalace-mcp not available (respawn budget exhausted) — restart pi to retry palace tools",
},
],
details: {},
isError: true,
};
}
try {
const result = await client.callTool(tool.name, (params ?? {}) as Record<string, unknown>);
// 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");
}