/**
* 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: {},
};
},
});
}