type JsonSchema = Record; export const BRIDGE_VERSION = "0.3.0"; export interface BridgeToolDefinition { name: string; label: string; description: string; parameters: JsonSchema; lazy?: boolean; } function stringSchema(description?: string): JsonSchema { return description ? { type: "string", description } : { type: "string" }; } function integerSchema(description?: string, minimum?: number, maximum?: number): JsonSchema { return { type: "integer", ...(minimum === undefined ? {} : { minimum }), ...(maximum === undefined ? {} : { maximum }), ...(description ? { description } : {}), }; } function booleanSchema(description?: string): JsonSchema { return description ? { type: "boolean", description } : { type: "boolean" }; } function enumSchema(values: readonly string[], description?: string): JsonSchema { return { type: "string", enum: values, ...(description ? { description } : {}) }; } function arraySchema(items: JsonSchema, description?: string, maxItems?: number): JsonSchema { return { type: "array", items, ...(description ? { description } : {}), ...(maxItems === undefined ? {} : { maxItems }), }; } function objectSchema(properties: Record, required: readonly string[] = []): JsonSchema { return { type: "object", properties, ...(required.length ? { required: [...required] } : {}), }; } /** Pi-adapter controls. They are stripped before arguments reach the official CLI. */ const outputProperties: Record = { full_output: booleanSchema("Return complete output instead of the compact default."), max_output_chars: integerSchema("Maximum response characters before saving the rest to a temporary file.", 2_000, 200_000), timeout_ms: integerSchema("CLI timeout in milliseconds.", 1_000, 86_400_000), }; const project = stringSchema("Indexed project name."); const projectAndOutput = (properties: Record, required: readonly string[]): JsonSchema => objectSchema({ project, ...properties, ...outputProperties }, required); export const BRIDGE_TOOLS: readonly BridgeToolDefinition[] = [ { name: "search_graph", label: "CBM Search Graph", description: "Search the code knowledge graph for symbols, routes, and relationships.", parameters: projectAndOutput( { query: stringSchema("Natural-language or keyword BM25 search."), label: stringSchema(), name_pattern: stringSchema("Regex over node names."), qn_pattern: stringSchema("Regex over qualified names."), file_pattern: stringSchema(), relationship: stringSchema(), min_degree: integerSchema(), max_degree: integerSchema(), exclude_entry_points: booleanSchema(), include_connected: booleanSchema(), semantic_query: arraySchema(stringSchema(), "Array of semantic search keywords."), limit: integerSchema("Maximum results.", 1), offset: integerSchema("Results to skip.", 0), format: enumSchema(["tree", "json"]), fields: arraySchema(stringSchema()), detail: enumSchema(["ids", "default"]), }, ["project"], ), }, { name: "search_code", label: "CBM Search Code", description: "Search indexed files for literal text or regular expressions.", parameters: projectAndOutput( { pattern: stringSchema("Literal or regular-expression pattern."), file_pattern: stringSchema(), path_filter: stringSchema(), mode: enumSchema(["compact", "full", "files"]), context: integerSchema("Context lines in compact mode.", 0), regex: booleanSchema(), limit: integerSchema("Maximum enriched results.", 1), debug: booleanSchema(), }, ["project", "pattern"], ), }, { name: "get_code_snippet", label: "CBM Code Snippet", description: "Read source for one known function, method, class, or symbol.", parameters: projectAndOutput( { qualified_name: stringSchema("Exact qualified name from search_graph."), include_neighbors: booleanSchema(), }, ["project", "qualified_name"], ), }, { name: "get_code_snippets", label: "CBM Code Snippets", description: "Read multiple known symbols in one call to reduce tool round trips.", parameters: projectAndOutput( { qualified_names: arraySchema(stringSchema("Exact qualified name from search_graph."), "Symbols to read.", 20), include_neighbors: booleanSchema(), }, ["project", "qualified_names"], ), lazy: true, }, { name: "trace_path", label: "CBM Trace Path", description: "Trace callers, callees, data flow, or cross-service paths.", parameters: projectAndOutput( { function_name: stringSchema("Function or method to trace."), direction: enumSchema(["inbound", "outbound", "both"]), depth: integerSchema("Maximum traversal depth.", 1), mode: enumSchema(["calls", "data_flow", "cross_service"]), parameter_name: stringSchema(), edge_types: arraySchema(stringSchema()), risk_labels: booleanSchema(), include_tests: booleanSchema(), limit: integerSchema("Rows per page.", 1), cursor: stringSchema(), format: enumSchema(["tree", "json"]), include_evidence: booleanSchema(), }, ["project", "function_name"], ), }, { name: "search_tools", label: "CBM Search Tools", description: "Find and enable advanced CBM tools that are inactive by default.", parameters: objectSchema({ query: stringSchema("Capability to search for, such as cypher, tracing, or impact."), limit: integerSchema("Maximum matching tools.", 1, 8), }, ["query"]), }, { name: "list_projects", label: "CBM List Projects", description: "List indexed codebase-memory projects.", parameters: objectSchema({ include_details: booleanSchema(), metadata_only: booleanSchema("Deprecated alias for include_details=false."), limit: integerSchema("Maximum projects per call.", 1, 100), offset: integerSchema("Projects to skip.", 0), ...outputProperties, }), }, { name: "index_status", label: "CBM Index Status", description: "Inspect indexed project and coverage status.", parameters: projectAndOutput({}, ["project"]), }, { name: "index_repository", label: "CBM Index Repository", description: "Index a repository into the code knowledge graph.", parameters: objectSchema( { repo_path: stringSchema("Repository path."), mode: enumSchema(["full", "moderate", "fast", "cross-repo-intelligence"]), name: stringSchema("Optional project name override."), target_projects: arraySchema(stringSchema()), persistence: booleanSchema(), ...outputProperties, }, ["repo_path"], ), lazy: true, }, { name: "query_graph", label: "CBM Query Graph", description: "Run a bounded read-only Cypher graph query.", parameters: projectAndOutput( { query: stringSchema("Cypher query."), graph: enumSchema(["code", "missed"]), max_rows: integerSchema("Maximum returned rows.", 1), }, ["project", "query"], ), lazy: true, }, { name: "get_graph_schema", label: "CBM Graph Schema", description: "Inspect graph node labels, relationships, and properties.", parameters: projectAndOutput({}, ["project"]), lazy: true, }, { name: "get_architecture", label: "CBM Architecture", description: "Get a compact architecture overview of an indexed project.", parameters: projectAndOutput( { path: stringSchema("Optional directory prefix."), aspects: arraySchema(enumSchema([ "all", "overview", "structure", "dependencies", "routes", "languages", "packages", "entry_points", "hotspots", "boundaries", "layers", "file_tree", "clusters", "cycles", ], "Architecture aspect.")), }, ["project"], ), lazy: true, }, { name: "check_index_coverage", label: "CBM Index Coverage", description: "Check whether files or path scopes are covered by the index.", parameters: { ...objectSchema( { project, paths: arraySchema(stringSchema(), undefined, 128), scopes: arraySchema(stringSchema(), undefined, 32), scope_limit: integerSchema("Maximum scoped paths.", 1, 1_000), scope_offset: integerSchema("Scoped paths to skip.", 0), ...outputProperties, }, ["project"], ), anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], }, lazy: true, }, { name: "detect_changes", label: "CBM Detect Changes", description: "Map local git changes to affected symbols and callers.", parameters: projectAndOutput( { scope: enumSchema(["files", "impact"]), direction: enumSchema(["inbound", "outbound", "both"]), depth: integerSchema("Maximum impact traversal depth.", 1), limit: integerSchema("Maximum impacted rows.", 1, 5_000), base_branch: stringSchema(), since: stringSchema(), format: enumSchema(["tree", "json"]), }, ["project"], ), lazy: true, }, { name: "manage_adr", label: "CBM Manage ADR", description: "Read or update the project's Architecture Decision Record.", parameters: objectSchema( { project, mode: enumSchema(["get", "update", "sections"]), content: stringSchema("Complete replacement document for update."), ...outputProperties, }, ["project"], ), lazy: true, }, { name: "ingest_traces", label: "CBM Ingest Traces", description: "Ingest runtime call traces into the knowledge graph.", parameters: objectSchema( { project, traces: arraySchema( objectSchema({ caller: stringSchema(), callee: stringSchema(), count: integerSchema(), }), ), ...outputProperties, }, ["project", "traces"], ), lazy: true, }, ]; const lazyTools = BRIDGE_TOOLS.filter((tool) => tool.lazy).map((tool) => `cbm_${tool.name}`); const lazySearchData = BRIDGE_TOOLS.filter((tool) => tool.lazy).map((tool) => ({ name: `cbm_${tool.name}`, description: tool.description, })); function renderRegistration(tool: BridgeToolDefinition): string { const name = `cbm_${tool.name}`; const execute = tool.name === "search_tools" ? "async (_toolCallId, params) => searchTools(pi, params)" : `async (_toolCallId, params, signal, _onUpdate, ctx) => { const result = await callTool(${JSON.stringify(tool.name)}, params, signal || (ctx && ctx.signal)); return resultToPi(result, ${JSON.stringify(tool.name)}, params); }`; return `\tpi.registerTool({ \t\tname: ${JSON.stringify(name)}, \t\tlabel: ${JSON.stringify(tool.label)}, \t\tdescription: ${JSON.stringify(tool.description)}, \t\tparameters: ${JSON.stringify(tool.parameters)}, \t\texecute: ${execute}, \t});`; } export function buildBridgeSource(binary: string): string { const registrations = BRIDGE_TOOLS.map(renderRegistration).join("\n\n"); return `/** * Generated by pi-codebase-memory-hooks from the CBM CLI contract. * Do not edit manually. Regenerate with /cbm-install. * pi-cbm-generated-bridge v${BRIDGE_VERSION} */ import { spawn } from "node:child_process"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; const BIN = ${JSON.stringify(binary)}; const BRIDGE_VERSION = ${JSON.stringify(BRIDGE_VERSION)}; const BRIDGE_TOOL_COUNT = ${BRIDGE_TOOLS.length}; const DEFAULT_TIMEOUT_MS = 120_000; const INDEX_TIMEOUT_MS = 1_200_000; const MAX_STDOUT_BYTES = 8 * 1024 * 1024; const MAX_STDERR_BYTES = 1 * 1024 * 1024; const DEFAULT_OUTPUT_CHARS = 40_000; const MAX_OUTPUT_CHARS = 200_000; const LAZY_TOOLS = new Set(${JSON.stringify(lazyTools)}); const LAZY_SEARCH_DATA = ${JSON.stringify(lazySearchData)}; function abortError() { \tconst error = new Error("codebase-memory-mcp call cancelled"); \terror.name = "AbortError"; \treturn error; } function outputLimit(params) { \tconst value = params && typeof params.max_output_chars === "number" ? Math.floor(params.max_output_chars) : DEFAULT_OUTPUT_CHARS; \treturn Math.max(2_000, Math.min(value, MAX_OUTPUT_CHARS)); } function timeoutFor(tool, params) { \tconst fallback = tool === "index_repository" ? INDEX_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; \tconst value = params && typeof params.timeout_ms === "number" ? Math.floor(params.timeout_ms) : fallback; \treturn Math.max(1_000, Math.min(value, 86_400_000)); } function upstreamArgs(tool, params) { \tconst args = params && typeof params === "object" ? { ...params } : {}; \tdelete args.full_output; \tdelete args.max_output_chars; \tdelete args.timeout_ms; \tif (tool === "search_graph" && args.limit === undefined) args.limit = 12; \tif (tool === "search_code") { \t\tif (args.mode === undefined) args.mode = "compact"; \t\tif (args.context === undefined) args.context = 2; \t\tif (args.limit === undefined) args.limit = 10; \t} \tif (tool === "query_graph" && args.max_rows === undefined) args.max_rows = 100; \tif (tool === "get_architecture" && args.aspects === undefined) args.aspects = ["overview"]; \treturn args; } function terminate(child, reason) { \tif (child.exitCode !== null || child.signalCode !== null) return; \ttry { child.kill("SIGTERM"); } catch { /* process already exited */ } \tsetTimeout(() => { \t\tif (child.exitCode === null && child.signalCode === null) { \t\t\ttry { child.kill("SIGKILL"); } catch { /* process already exited */ } \t\t} \t}, reason === "timeout" ? 2_000 : 500).unref?.(); } function parseEnvelope(stdout) { \tconst trimmed = stdout.trim(); \tif (!trimmed) throw new Error("codebase-memory-mcp produced no JSON output"); \tconst candidates = [trimmed, ...trimmed.split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean).reverse()]; \tfor (const candidate of candidates) { \t\ttry { \t\t\tconst value = JSON.parse(candidate); \t\t\tif (value && typeof value === "object") return value; \t\t} catch { /* diagnostics may surround the JSON response */ } \t} \tthrow new Error("Could not parse codebase-memory-mcp JSON output: " + trimmed.slice(0, 500)); } function errorText(value) { \tif (typeof value === "string") return value; \tif (value && typeof value === "object") { \t\tif (typeof value.error === "string") return value.error; \t\tif (Array.isArray(value.content)) { \t\t\tconst item = value.content.find((entry) => entry && entry.type === "text" && typeof entry.text === "string"); \t\t\tif (item) return item.text; \t\t} \t} \treturn JSON.stringify(value); } function appendBounded(previous, chunk, limit) { \tconst next = String(chunk); \tconst remaining = limit - Buffer.byteLength(previous, "utf8"); \tif (remaining <= 0) return previous; \tif (Buffer.byteLength(next, "utf8") <= remaining) return previous + next; \treturn previous + Buffer.from(next, "utf8").subarray(0, remaining).toString("utf8"); } async function readSnippets(params, signal) { \tconst project = typeof params?.project === "string" ? params.project : ""; \tconst names = Array.isArray(params?.qualified_names) \t\t? params.qualified_names.filter((name) => typeof name === "string").slice(0, 20) \t\t: []; \tconst results = []; \tfor (const qualified_name of names) { \t\tconst result = await callTool("get_code_snippet", { \t\t\tproject, \t\t\tqualified_name, \t\t\t...(params?.include_neighbors === undefined ? {} : { include_neighbors: params.include_neighbors }), \t\t\t...(typeof params?.timeout_ms === "number" ? { timeout_ms: params.timeout_ms } : {}), \t\t}, signal); \t\tif (result && result.isError === true) throw new Error(errorText(result)); \t\tresults.push({ qualified_name, content: result?.content ?? result }); \t} \treturn resultToPi( \t\t{ content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }], isError: false }, \t\t"get_code_snippets", \t\tparams, \t); } function callTool(tool, params, signal) { \tif (tool === "get_code_snippets") return readSnippets(params, signal); \tif (signal && signal.aborted) return Promise.reject(abortError()); \tconst args = upstreamArgs(tool, params); \tconst timeoutMs = timeoutFor(tool, params); \treturn new Promise((resolve, reject) => { \t\tlet child; \t\ttry { \t\t\tchild = spawn(BIN, ["cli", "--json", tool], { \t\t\t\tstdio: ["pipe", "pipe", "pipe"], \t\t\t\tenv: { ...process.env, CBM_LOG_LEVEL: "error" }, \t\t\t}); \t\t} catch (error) { \t\t\treject(error); \t\t\treturn; \t\t} \t\tlet stdout = ""; \t\tlet stderr = ""; \t\tlet stdinError; \t\tlet terminationReason = ""; \t\tlet timeout; \t\tlet abortHandler; \t\tconst cleanup = () => { \t\t\tclearTimeout(timeout); \t\t\tif (abortHandler) signal?.removeEventListener("abort", abortHandler); \t\t}; \t\tabortHandler = () => { \t\t\tterminationReason = "cancelled"; \t\t\tterminate(child, "cancelled"); \t\t}; \t\tsignal?.addEventListener("abort", abortHandler, { once: true }); \t\ttimeout = setTimeout(() => { \t\t\tterminationReason = "timeout"; \t\t\tterminate(child, "timeout"); \t\t}, timeoutMs); \t\tchild.stdout.on("data", (chunk) => { \t\t\tstdout = appendBounded(stdout, chunk, MAX_STDOUT_BYTES); \t\t\tif (Buffer.byteLength(stdout, "utf8") >= MAX_STDOUT_BYTES) { \t\t\t\tterminationReason = "output-limit"; \t\t\t\tterminate(child, "output-limit"); \t\t\t} \t\t}); \t\tchild.stderr.on("data", (chunk) => { \t\t\tstderr = appendBounded(stderr, chunk, MAX_STDERR_BYTES); \t\t}); \t\tchild.stdin.on("error", (error) => { stdinError = error; }); \t\tchild.once("error", (error) => { \t\t\tcleanup(); \t\t\treject(error); \t\t}); \t\tchild.once("close", (code, signalCode) => { \t\t\tcleanup(); \t\t\tif (terminationReason === "cancelled") return reject(abortError()); \t\t\tif (terminationReason === "timeout") return reject(new Error("codebase-memory-mcp " + tool + " timed out after " + timeoutMs + "ms")); \t\t\tif (terminationReason === "output-limit") return reject(new Error("codebase-memory-mcp " + tool + " exceeded the output limit")); \t\t\tif (stdinError) return reject(new Error("codebase-memory-mcp stdin failed: " + stdinError.message)); \t\t\tif (code !== 0) return reject(new Error("codebase-memory-mcp " + tool + " exited " + (code ?? signalCode ?? "unknown") + ": " + (stderr.trim() || stdout.trim()))); \t\t\ttry { resolve(parseEnvelope(stdout)); } catch (error) { reject(error); } \t\t}); \t\tchild.stdin.end(JSON.stringify(args)); \t}); } async function saveFullOutput(tool, text) { \ttry { \t\tconst directory = await mkdtemp(join(tmpdir(), "cbm-pi-")); \t\tconst path = join(directory, tool + ".full.txt"); \t\tawait writeFile(path, text, "utf8"); \t\treturn path; \t} catch { \t\treturn undefined; \t} } async function resultToPi(result, tool, params) { \tif (result && result.isError === true) throw new Error(errorText(result)); \tconst rawContent = result && Array.isArray(result.content) && result.content.length \t\t? result.content \t\t: [{ type: "text", text: JSON.stringify(result ?? null, null, 2) }]; \tconst content = []; \tconst fullOutputPaths = []; \tconst limit = outputLimit(params); \tfor (const item of rawContent) { \t\tif (!item || item.type !== "text" || typeof item.text !== "string" || params?.full_output === true || item.text.length <= limit) { \t\t\tcontent.push(item); \t\t\tcontinue; \t\t} \t\tconst path = await saveFullOutput(tool, item.text); \t\tif (path) fullOutputPaths.push(path); \t\tconst notice = "\\n\\n[Output truncated. Full output: " + (path || "unavailable") + ". Retry with full_output:true if omitted content is needed.]\\n"; \t\tconst available = Math.max(1, limit - notice.length); \t\tconst headLength = Math.floor(available * 0.65); \t\tconst tailLength = Math.max(1, available - headLength); \t\tconst head = item.text.slice(0, headLength); \t\tconst tail = item.text.slice(-tailLength); \t\tcontent.push({ type: "text", text: head + notice + tail }); \t} \treturn { \t\tcontent, \t\tdetails: { \t\t\ttool, \t\t\tbridgeVersion: BRIDGE_VERSION, \t\t\tbridgeToolCount: BRIDGE_TOOL_COUNT, \t\t\t...(fullOutputPaths.length ? { fullOutputPaths, truncated: true } : {}), \t\t}, \t}; } function searchTools(pi, params) { \tconst query = String(params?.query ?? "").toLowerCase(); \tconst terms = query.split(/[^a-z0-9]+/).filter(Boolean); \tconst requestedLimit = typeof params?.limit === "number" ? Math.floor(params.limit) : 3; \tconst limit = Math.max(1, Math.min(requestedLimit, 8)); \tconst scored = LAZY_SEARCH_DATA.map((tool) => ({ \t\tname: tool.name, \t\tscore: terms.reduce((score, term) => score + (tool.name.toLowerCase().includes(term) || tool.description.toLowerCase().includes(term) ? 1 : 0), 0), \t})).filter((tool) => tool.score > 0).sort((a, b) => b.score - a.score).slice(0, limit).map((tool) => tool.name); \tconst matches = scored.length \t\t? scored \t\t: terms.includes("all") || terms.includes("advanced") \t\t\t? [...LAZY_TOOLS] \t\t\t: []; \tif (!matches.length) { \t\treturn { \t\t\tcontent: [{ type: "text", text: "No advanced CBM tool matched that capability. Try cypher, architecture, coverage, impact, or all." }], \t\t\tdetails: { matches: [], added: [] }, \t\t}; \t} \tconst active = pi.getActiveTools(); \tconst added = matches.filter((name) => !active.includes(name)); \tif (added.length) pi.setActiveTools([...new Set([...active, ...added])]); \treturn { \t\tcontent: [{ type: "text", text: added.length ? "Loaded tools: " + added.join(", ") : "Matching tools already active: " + matches.join(", ") }], \t\tdetails: { matches, added }, \t}; } export default function (pi) { ${registrations} \tpi.on("session_start", () => { \t\tconst active = pi.getActiveTools(); \t\tpi.setActiveTools([...new Set(active.filter((name) => !LAZY_TOOLS.has(name)))]); \t}); } `; }