=== root/.pi/extensions/gortex/index.ts === // Gortex extension for the Pi coding agent (earendil-works/pi). // // Pi has no MCP support by design — the extension API's registerTool // covers it. This extension does two things: // // 1. Exposes Gortex's graph tools as native Pi tools. On every // session_start the daemon is brought up (ensureDaemon) and the tools // are (re)registered — Pi has no MCP, so the extension does what an MCP // client's `gortex mcp` proxy does for it, and Pi resets the session's // tool registry on each /new, so registration must happen per session. // Each registered tool then shells `gortex call `, which // relays to the daemon. This mirrors the daemon's own core/defer tool // surface: the configured eager preset (GORTEX_TOOLS, default `core`) // is discovered from `gortex tools list` and registered up front, and // the deferred remainder of the full (~175-tool) catalogue is reached // on demand via the `gortex_tools_search` meta-tool, which runs the // BM25 catalogue search and dynamically registers the matches — the // analogue of MCP's tools_search + server-side promotion. If the // daemon is unreachable at session_start no graph tools are // registered; discovery re-runs on the next session. // // 2. Re-creates the Claude-Code "prefer graph tools over raw file // reads" enforcement. Rather than re-implementing the deny logic in // TypeScript, every relevant lifecycle event is marshalled into a // normalized envelope and handed to `gortex hook --agent=pi`, whose // decision is applied here. The Go side owns the deny / enrich / // consult-unlock / nudge postures, identical to every other agent. // // This file is written verbatim by the `pi` agent adapter, except for four // templated sentinels the adapter replaces at install time: // // "gortex" -> a JSON string: the resolved `gortex` binary path // ["gortex","hook","--agent=pi"] -> a JSON array: the full hook argv, e.g. // ["/usr/local/bin/gortex","hook","--agent=pi"] // (with a trailing "--mode=" when non-deny) // false -> a JSON boolean: whether to wire the read-discipline // enforcement (false when installed with --no-hooks; // the graph tools + orientation are still wired) // "core" -> a JSON string: the default eager tool preset // (core|edit|nav|readonly|full); GORTEX_TOOLS in the // environment overrides it at runtime. // // Each is emitted as a JSON literal so values containing spaces survive. // // NOTE: Pi is pre-1.0. The ExtensionAPI surface, event names, and the Pi // built-in tool names mapped in normalizeToolCall() below are pinned to // Pi's documented API (packages/coding-agent/docs/extensions.md). If a Pi // upgrade renames an event or a built-in tool, adjust the small maps here // — the Go side (the wire contract) stays unchanged. // Zero runtime dependencies: only Node built-ins. The tool `parameters` // below are plain JSON Schema objects rather than TypeBox schemas — Pi's // tool layer accepts a plain JSON-schema object. This is deliberate: // a single-file auto-discovered extension has no package.json / node_modules, // so importing `typebox` would throw at load and take the whole extension // (tools AND enforcement) down with it. import { execFileSync, spawn } from "node:child_process"; const GORTEX_BIN: string = "gortex"; const HOOK_ARGV: string[] = ["gortex","hook","--agent=pi"]; const ENFORCE: boolean = false; // Which eager preset to expose as native Pi tools, mirroring the Gortex // daemon's own tool surface. The daemon defaults to the `core` preset in // `defer` mode and lets GORTEX_TOOLS override it — so we honour the same // env at runtime, falling back to the value baked at install time. // Recognised: core | edit | nav | readonly | full (full = the whole // catalogue eagerly). The deferred remainder is reachable via // `gortex_tools_search` below. const TOOLS_PRESET: string = ((process.env && process.env.GORTEX_TOOLS) || "core").trim(); // Names (all gortex_-prefixed) of the Gortex tools registered into Pi. const gortexToolNames = new Set(); // Every Gortex tool is registered under a `gortex_` prefix so it can never // silently clobber a Pi built-in or another extension's tool of the same // name. // The execute() closure still calls the daemon with the bare name. const TOOL_PREFIX = "gortex_"; function piToolName(bare: string): string { return bare.startsWith(TOOL_PREFIX) ? bare : TOOL_PREFIX + bare; } // --------------------------------------------------------------------------- // Subprocess helpers // --------------------------------------------------------------------------- function runGortex(args: string[], input?: string): string { return execFileSync(GORTEX_BIN, args, { input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 30_000, }).trim(); } // ensureDaemon brings the shared Gortex daemon up, the way an MCP host's // `gortex mcp` does for other agents. // Idempotent, fire-and-forget (we don't block on the launcher's ≤60s // readiness poll), and never throws — a missing binary or spawn failure // must not take the extension down. The first tool calls either find it // ready or degrade gracefully. function ensureDaemon(): void { try { const child = spawn(GORTEX_BIN, ["daemon", "start", "--detach"], { stdio: "ignore", detached: true, }); child.on("error", () => { }); // binary missing / spawn failure — swallow // No teardown counterpart, by design: the daemon is shared, long-lived // infrastructure that outlives the session — like `gortex mcp`. child.unref(); } catch { } } interface PiDecision { block?: boolean; reason?: string; additional_context?: string; orientation?: string; } // callHook sends a normalized event envelope to `gortex hook --agent=pi` // and parses the PiDecision it writes back. Fail-open: any error returns // an empty decision so the extension never blocks Pi's flow on a hook // hiccup (daemon down, parse error, timeout). function callHook(envelope: Record): PiDecision { try { const out = execFileSync(HOOK_ARGV[0], HOOK_ARGV.slice(1), { input: JSON.stringify(envelope), encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 5_000, }).trim(); if (!out) return {}; return JSON.parse(out) as PiDecision; } catch { return {}; } } // --------------------------------------------------------------------------- // Tool-call normalization (Pi vocabulary -> canonical Claude-Code vocabulary) // --------------------------------------------------------------------------- // // The Go enrich() classifier switches on Claude-Code tool names // ("Read"/"Grep"/"Glob"/"Bash"/"Edit"/"Write") and reads canonical input // keys ("file_path"/"pattern"/"command"). Pi uses its own lowercase names // and input shapes, so we translate here — keeping all Pi-specific // knowledge in this Pi-specific file. const toolNameMap: Record = { read: "Read", grep: "Grep", find: "Glob", ls: "Glob", bash: "Bash", edit: "Edit", write: "Write", }; function firstString(obj: Record, keys: string[]): string | undefined { for (const k of keys) { const v = obj[k]; if (typeof v === "string" && v !== "") return v; } return undefined; } function normalizeToolCall( piName: string, piInput: Record, ): { tool_name: string; tool_input: Record } { const canonical = toolNameMap[piName.toLowerCase()] ?? piName; const out: Record = { ...piInput }; const path = firstString(piInput, ["file_path", "path", "file", "filepath", "absolute_path"]); if (path !== undefined) out.file_path = path; let pattern = firstString(piInput, ["pattern", "glob", "query", "regex", "name"]); if (pattern === undefined && canonical === "Glob") pattern = path; if (pattern !== undefined) out.pattern = pattern; const command = firstString(piInput, ["command", "cmd", "script"]); if (command !== undefined) out.command = command; return { tool_name: canonical, tool_input: out }; } // --------------------------------------------------------------------------- // Dynamic tool registration // --------------------------------------------------------------------------- interface ToolDescriptor { name: string; summary?: string; description?: string; } // presetListArgs maps the configured preset to `gortex tools list` flags. // `full`/`all`/empty list the whole catalogue (no --preset filter); any // other value is passed straight through so a preset added upstream works // with no change here — the CLI is the source of truth for valid names. An // unrecognised name simply matches nothing, and discoverTools falls back to // the static set; the full catalogue stays reachable via search regardless. function presetListArgs(): string[] { const base = ["tools", "list", "--format", "json"]; const p = TOOLS_PRESET.toLowerCase(); if (p === "" || p === "full" || p === "all") return base; return [...base, "--preset", p]; } // discoverTools asks the daemon for the eager tool roster. Returns [] when // the daemon is unreachable. // TODO: poll for the daemon coming online mid-session and register then. function discoverTools(): ToolDescriptor[] { try { const raw = runGortex(presetListArgs()); const parsed = JSON.parse(raw) as ToolDescriptor[]; if (Array.isArray(parsed) && parsed.length > 0) return parsed; } catch { // daemon down / not tracking — no tools registered this session. } return []; } // safeRegister registers a Pi tool, tolerating a redundant registration — // a config reload can re-fire session_start without resetting the session's // tool registry, in which case the name is already live and Pi may throw. function safeRegister(pi: any, def: any): void { try { pi.registerTool(def); } catch { // already live this session — the existing registration stands. } } // registerOneTool registers a single Gortex tool as a native Pi tool named // `gortex_`, whose execute() shells `gortex call `. Idempotent — // a name already registered is skipped. Adds the prefixed name to // gortexToolNames so the read-discipline postures treat a call to it as a // graph query, not a raw file read. Used both for the eager preset and for // tools promoted later by gortex_tools_search. function registerOneTool(pi: any, desc: ToolDescriptor): void { const bare = desc.name; if (!bare) return; const name = piToolName(bare); if (gortexToolNames.has(name)) return; gortexToolNames.add(name); safeRegister(pi, { name, label: name, description: (desc.description || desc.summary || name).trim(), // Pass-through arguments: the model supplies whatever the underlying // Gortex tool accepts; `gortex call` validates names + args server // side and suggests near-matches on a typo. parameters: { type: "object", additionalProperties: true, properties: {} }, async execute(_id: string, params: Record) { try { const out = runGortex([ "call", bare, "--json", JSON.stringify(params ?? {}), "--format", "gcx", ]); return { content: [{ type: "text", text: out }], details: {} }; } catch (err: any) { const msg = err?.stderr?.toString?.() || err?.message || String(err); throw new Error(`gortex call ${bare} failed: ${msg}`); } }, }); } function registerGortexTools(pi: any): void { for (const desc of discoverTools()) registerOneTool(pi, desc); } // --------------------------------------------------------------------------- // On-demand tool discovery (mirrors MCP tools_search + promotion) // --------------------------------------------------------------------------- // // MCP clients on the default core/defer surface see only the eager preset; // the rest of the catalogue is deferred behind the tools_search meta-tool, // which returns a match's schema and PROMOTES it into the live server // (firing notifications/tools/list_changed) so it becomes callable. Pi has // no server-side promotion, so we replicate it: a single // `gortex_tools_search` meta-tool runs the BM25 catalogue search and // dynamically registers each match as a native Pi tool — Pi supports // registering tools after session init. const SEARCH_TOOL_NAME = piToolName("tools_search"); // firstSentence returns the leading sentence of a description, up to and // including the first ". " boundary. function firstSentence(desc: string): string { const t = desc.trim(); if (!t) return ""; const i = t.indexOf(". "); return i >= 0 ? t.slice(0, i + 1).trim() : t; } // parseToolSearchJSON parses `gortex tools search --format json` output — // an array of {name, description}. function parseToolSearchJSON(raw: string): ToolDescriptor[] { let parsed: Array<{ name: string; description?: string }>; try { parsed = JSON.parse(raw); } catch { return []; } if (!Array.isArray(parsed)) return []; return parsed .filter((e) => e && typeof e.name === "string" && e.name) .map((e) => ({ name: e.name, description: e.description ?? "", summary: firstSentence(e.description ?? ""), })); } function searchTools(query: string, limit: number): ToolDescriptor[] { return parseToolSearchJSON( runGortex(["tools", "search", query, "--limit", String(limit), "--format", "json"]), ); } function registerSearchTool(pi: any): void { if (gortexToolNames.has(SEARCH_TOOL_NAME)) return; gortexToolNames.add(SEARCH_TOOL_NAME); safeRegister(pi, { name: SEARCH_TOOL_NAME, label: SEARCH_TOOL_NAME, description: "Search the full Gortex tool catalogue and register the matches as callable tools. " + "Use this when the eagerly-loaded set doesn't cover what you need — e.g. taint_paths, " + "flow_between, contracts, find_clones, pr_risk, overlay/simulation tools. Returns the " + "names + summaries now available; call them directly on the next turn. (Gortex graph tool.)", parameters: { type: "object", additionalProperties: false, properties: { query: { type: "string", description: "What you want to do, e.g. 'trace taint to a sink' or 'detect duplicate code'.", }, limit: { type: "number", description: "Maximum number of tools to register (default 5).", }, }, required: ["query"], }, async execute(_id: string, params: Record) { const query = typeof params?.query === "string" ? params.query.trim() : ""; if (!query) { return { content: [{ type: "text", text: "Provide a 'query' describing the tool you need." }], details: {}, }; } const rawLimit = typeof params?.limit === "number" ? Math.floor(params.limit) : 5; const limit = rawLimit > 0 ? rawLimit : 5; let matches: ToolDescriptor[]; try { matches = searchTools(query, limit); } catch (err: any) { const msg = err?.stderr?.toString?.() || err?.message || String(err); throw new Error(`gortex tools search failed: ${msg}`); } const newlyAvailable: string[] = []; for (const desc of matches) { const name = desc.name ? piToolName(desc.name) : ""; if (!name || name === SEARCH_TOOL_NAME) continue; if (!gortexToolNames.has(name)) newlyAvailable.push(name); registerOneTool(pi, desc); // idempotent } // Report the prefixed names — that's what the model calls them by. const lines = matches .filter((d) => d.name && piToolName(d.name) !== SEARCH_TOOL_NAME) .map((d) => `- ${piToolName(d.name)}${d.summary ? ` — ${d.summary}` : ""}`); const header = matches.length === 0 ? `No tools matched "${query}".` : newlyAvailable.length > 0 ? `Registered ${newlyAvailable.length} tool(s) — now callable: ${newlyAvailable.join(", ")}.` : "Matching tools are already registered and callable."; return { content: [{ type: "text", text: [header, ...lines].join("\n") }], details: {} }; }, }); } // --------------------------------------------------------------------------- // Extension entry point // --------------------------------------------------------------------------- export default function (pi: any) { let orientationInjected = false; // Orientation awaiting injection into the next LLM call. The `context` // hook appends it as a tail user message rather than mutating // systemPrompt: a systemPrompt change sits at messages[0] and invalidates // prefix prompt caching. Computed once per session. let pendingOrientation = ""; // Pi resets the session's tool registry on every session_start, so // (re)register here. Clear the name guard first — it persists across // sessions and would otherwise suppress re-registration. pi.on("session_start", () => { orientationInjected = false; pendingOrientation = ""; ensureDaemon(); gortexToolNames.clear(); registerGortexTools(pi); registerSearchTool(pi); }); // Fires before the agent loop's first LLM call. It can't mutate messages // itself, so it just parks the orientation for the `context` hook. pi.on("before_agent_start", () => { if (orientationInjected) return; const decision = callHook({ event: "session_start", cwd: pi?.cwd ?? process.cwd() }); if (decision.orientation) { pendingOrientation = decision.orientation; orientationInjected = true; } return; }); // Fires before each LLM call with a mutable message array. Append the // parked orientation once, then clear it so it isn't repeated each call. pi.on("context", (event: any) => { if (!pendingOrientation) return; const text = pendingOrientation; pendingOrientation = ""; try { if (event && Array.isArray(event.messages)) { event.messages.push({ role: "user", content: text }); return { messages: event.messages }; } } catch { // best effort — never break context assembly. } return; }); if (!ENFORCE) return; // Enforcement: every non-Gortex tool call is checked against the Go hook. pi.on("tool_call", async (event: any, ctx: any) => { const piName: string = event?.toolName ?? ""; const piInput: Record = event?.input ?? {}; const isGortexTool = gortexToolNames.has(piName); const norm = normalizeToolCall(piName, piInput); const decision = callHook({ event: "tool_call", tool_name: norm.tool_name, tool_input: norm.tool_input, cwd: ctx?.cwd ?? pi?.cwd ?? process.cwd(), session_id: ctx?.sessionManager?.sessionId ?? "", is_gortex_tool: isGortexTool, }); if (decision.block) { return { block: true, reason: decision.reason ?? "[Gortex] blocked — prefer graph tools." }; } if (decision.additional_context) { // Soft guidance: surface it without blocking the call. try { pi.sendMessage( { customType: "gortex", content: decision.additional_context, display: true }, { deliverAs: "steer" }, ); } catch { // sendMessage shape can vary across Pi versions; never fatal. } } return; }); } === root/AGENTS.md === - [example-community](.claude/skills/example/SKILL.md) — example routing block