// pi-codebase-memory helpers — testable pure functions + CLI/stdio runners. import { execFile, spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); export interface McpTool { name: string; description?: string; inputSchema?: Record; } /** Resolve the codebase-memory-mcp binary: env override → ~/.local/bin → PATH. */ export function resolveBin(): string { const env = process.env.CODEBASE_MEMORY_MCP_BIN; if (env && existsSync(env)) return env; const local = join(homedir(), ".local", "bin", "codebase-memory-mcp"); if (existsSync(local)) return local; return "codebase-memory-mcp"; // rely on PATH } /** Derive the codebase-memory project id from an absolute path: strip leading slashes, '/' → '-'. */ export function defaultProject(cwd: string): string { return cwd.replace(/^\/+/, "").replace(/\/+/g, "-"); } /** Ensure a usable object JSON-schema for registerTool parameters. */ export function normalizeSchema(inputSchema?: Record): Record { if (inputSchema && typeof inputSchema === "object" && "type" in inputSchema) return inputSchema; const props = (inputSchema as { properties?: unknown } | undefined)?.properties; return { type: "object", properties: props ?? {} }; } /** True if the tool's schema declares a `project` property. */ export function hasProjectParam(inputSchema?: Record): boolean { const props = (inputSchema as { properties?: Record } | undefined)?.properties; return !!props && Object.prototype.hasOwnProperty.call(props, "project"); } /** * Inject a cwd-derived project id when the tool wants one and the caller omitted it. * ponytail: project id is guessed from cwd (abs path, '/'->'-'), matching codebase-memory-mcp's * default naming. If the guess is wrong (renamed project, multiple repos), the LLM passes * `project` explicitly or calls list_projects — no upgrade needed unless that friction shows up. */ export function injectProject( args: Record, inputSchema: Record | undefined, cwd: string, ): Record { if (hasProjectParam(inputSchema) && args.project == null) { return { ...args, project: defaultProject(cwd) }; } return args; } /** * One-time MCP handshake over stdio to fetch the tool list (with input schemas). * The server logs to stderr and speaks newline-delimited JSON-RPC on stdout, so we * read stdout line by line and resolve on the tools/list response (id 2). */ export function fetchTools(bin: string, timeoutMs = 15_000): Promise { return new Promise((resolve, reject) => { const child = spawn(bin, [], { stdio: ["pipe", "pipe", "ignore"] }); let buf = ""; let settled = false; const settle = (err?: Error, tools?: McpTool[]) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill(); } catch { /* already exited */ } if (err) reject(err); else resolve(tools ?? []); }; const timer = setTimeout(() => settle(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); child.on("error", (e) => settle(e as Error)); child.stdout.on("data", (chunk: Buffer) => { buf += chunk.toString(); let nl: number; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; try { const msg = JSON.parse(line); if (msg.id === 2 && msg.result?.tools) { settle(undefined, msg.result.tools as McpTool[]); return; } } catch { /* non-JSON log line landed on stdout, skip it */ } } }); const send = (o: unknown) => child.stdin.write(`${JSON.stringify(o)}\n`); send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "pi-codebase-memory", version: "1.0.0" }, }, }); send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); }); } /** Run a single tool via `codebase-memory-mcp cli `. Returns stdout text or throws. */ export async function runTool( bin: string, tool: string, args: Record, ): Promise { const { stdout } = await execFileAsync(bin, ["cli", tool, JSON.stringify(args)], { maxBuffer: 64 * 1024 * 1024, timeout: 120_000, }); return stdout.trim(); }