/** * semantic-search extension * * Provides the `semantic-search` tool for conceptual/semantic search over * the Vera-indexed codebase (hybrid BM25 + vector search). * * Vera must always run from the target directory (cd first), so all vera * invocations are wrapped in `bash -c 'cd && vera ...'`. */ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve, isAbsolute } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { StringEnum } from "@earendil-works/pi-ai"; // ─── Shell helpers ────────────────────────────────────────────────────── /** Single-quote shell-escape: `'text'` with embedded single quotes escaped. */ function sq(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; } /** Build a `cd && vera ` bash command string. */ function veraCommand(targetDir: string, args: string[]): string { const shellCmd = ["vera", ...args].map(sq).join(" "); return `cd ${sq(targetDir)} && ${shellCmd}`; } /** * Run vera in the target directory and return the result. * Always wraps in `bash -c 'cd && vera ...'` because vera resolves * .vera, .veraignore, and .gitignore from its literal CWD. */ function runVera( targetDir: string, args: string[], signal?: AbortSignal, timeoutMs?: number, ): Promise<{ stdout: string; stderr: string; code: number | null }> { return new Promise((resolvePromise, reject) => { const bashCmd = veraCommand(targetDir, args); const child = spawn("bash", ["-c", bashCmd], { stdio: ["ignore", "pipe", "pipe"], signal, }); let stdout = ""; let stderr = ""; let settled = false; child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); }); child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); child.on("error", (err: Error) => { if (settled) return; settled = true; reject(err); }); child.on("close", (code) => { if (settled) return; settled = true; resolvePromise({ stdout, stderr, code }); }); if (timeoutMs && timeoutMs > 0) { const timer = setTimeout(() => { if (settled) return; settled = true; child.kill("SIGTERM"); resolvePromise({ stdout, stderr, code: null }); }, timeoutMs); // Clean up timer if process finishes first child.on("close", () => clearTimeout(timer)); } }); } // ─── Preflight check ─────────────────────────────────────────────────── /** * Fire-and-forget check that vera is available on PATH. * Notifies the user via TUI if missing. */ function preflightVeraAvailable( notify: (msg: string, level: "info" | "warning" | "error") => void, ): void { const child = spawn("bash", ["-c", "command -v vera"], { stdio: ["ignore", "ignore", "pipe"], }); const warn = () => notify("⚠ Vera binary not found on PATH. Install vera to enable semantic search.", "warning"); child.on("error", warn); child.on("close", (code) => { if (code !== 0) warn(); }); } // ─── Tool parameter schema ────────────────────────────────────────────── const SemanticSearchParams = Type.Object({ query: Type.String({ description: 'Search intent, e.g. "dashboard controller", "routing config", "error handling".', }), cwd: Type.Optional( Type.String({ description: "Target repository root directory. Relative paths resolve against current working directory. Defaults to current workspace if omitted. The target directory must have its own .vera index; to narrow within the current repo, use path instead.", }), ), lang: Type.Optional( Type.String({ description: "Optional language filter (examples: \"typescript\", \"rust\", \"python\", \"markdown\"); omitted searches all languages.", }), ), path: Type.Optional( Type.String({ description: 'Optional path glob filter (examples: "src/**", "docs/**"); omitted searches all paths. Supports * and ** patterns.', }), ), type: Type.Optional( StringEnum( [ "function", "method", "class", "struct", "enum", "trait", "interface", "type_alias", "constant", "variable", "module", "block", ] as const, { description: "Optional symbol-type filter; omitted searches all types." }, ) as any, ), scope: Type.Optional( StringEnum( ["source", "docs", "runtime", "all"] as const, { description: "Corpus scope filter: source (code), docs (prose), runtime, or all.", }, ) as any, ), limit: Type.Optional( Type.Integer({ description: "Max number of results to return (1..100)", minimum: 1, maximum: 100, default: 5, }), ), }); // ─── Extension entry ──────────────────────────────────────────────────── export default function semanticSearchExtension(pi: ExtensionAPI): void { // ── Lifecycle: verify Vera availability only; never index/watch automatically. pi.on("session_start", (_event, ctx) => { const notify = ctx.hasUI ? ctx.ui.notify.bind(ctx.ui) : () => {}; preflightVeraAvailable(notify); }); // ── Tool registration ─────────────────────────────────────────────── pi.registerTool({ name: "semantic-search", label: "Semantic Search", description: "Semantic and conceptual search over the indexed codebase using Vera (hybrid BM25 + vector search).\n\n" + "Use this to discover code, docs, or runtime behavior by intent/concept when you don't know exact file names, " + "symbol names, or locations. For exact symbol lookup, references, callers, renaming, or filename search, " + "use the dedicated IDE tools or grep instead.\n\n" + "Supports filtering by language, file path glob, symbol type, and corpus scope. " + "Use `path` to narrow within the current indexed repo. Use `cwd` only to target a different repo root, which must have its own `.vera` index.\n\n", promptSnippet: "Semantic/conceptual search across the repo. Use to find code, docs, or runtime behavior by intent — not for exact symbol/file/reference lookup.", promptGuidelines: [ "Use semantic-search for conceptual discovery when you don't know exact names or locations.", "Do NOT use semantic-search for exact symbol lookup, finding references, finding callers/callees, renaming, or filename search — use IDE tools (ide_find_symbol, ide_find_references, ide_call_hierarchy, ide_rename_symbol, ide_find_file) or grep for those.", "After semantic-search finds candidate files, switch to read, ide_file_structure, or ide_find_references for exact follow-up.", ], parameters: SemanticSearchParams as any, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const p = params as Record; const query = p.query as string; if (!query || typeof query !== "string" || !query.trim()) { return { content: [{ type: "text" as const, text: "Error: query is required." }], isError: true, details: {}, }; } // ── Resolve target directory ──────────────────────────────── const cwd = ctx.cwd; let targetDir = cwd; if (typeof p.cwd === "string" && p.cwd.trim()) { targetDir = isAbsolute(p.cwd) ? p.cwd : resolve(cwd, p.cwd); } // ── Check target directory exists ────────────────────────── if (!existsSync(targetDir)) { return { content: [{ type: "text" as const, text: `Directory does not exist: ${targetDir}` }], isError: true, details: {}, }; } // ── Require explicit/manual index for every target ────────── if (!existsSync(resolve(targetDir, ".vera"))) { return { content: [ { type: "text" as const, text: `No Vera index found at ${targetDir}/.vera. ` + `Run \`cd ${targetDir} && vera index .\` manually first, then retry semantic-search.`, }, ], isError: true, details: {}, }; } // ── Build search args ─────────────────────────────────────── const args: string[] = ["search", query]; if (typeof p.lang === "string" && p.lang.trim()) args.push("--lang", p.lang.trim()); if (typeof p.path === "string" && p.path.trim()) args.push("--path", p.path.trim()); if (typeof p.type === "string" && p.type.trim()) args.push("--type", p.type.trim()); if (typeof p.scope === "string" && p.scope.trim()) args.push("--scope", p.scope.trim()); const limit = typeof p.limit === "number" ? Math.max(1, Math.min(100, p.limit)) : 5; args.push("--limit", String(limit)); // ── Run search ────────────────────────────────────────────── let result: Awaited>; try { result = await runVera(targetDir, args, signal, 60_000); } catch (err: unknown) { return { content: [{ type: "text" as const, text: `Search failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true, details: {}, }; } if (result.code !== 0) { const msg = result.stderr || `exit code ${result.code}`; return { content: [{ type: "text" as const, text: `Vera search failed: ${msg}` }], isError: true, details: {}, }; } const output = result.stdout.trim(); if (!output) { return { content: [{ type: "text" as const, text: "No results found." }], details: {}, }; } return { content: [{ type: "text" as const, text: output }], details: {}, }; }, }); }