pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
111 lines (110 loc) • 5.38 kB
JavaScript
/**
* symbol_search pi tool (#348) — the entry point of the discovery funnel:
* symbol_search finds ranked candidate files by identifier, module_report
* explains one, read_symbol reads the exact body. Thin wrapper over the
* existing symbolSearch() engine seam (clients/lens-engine.ts), mirroring the
* MCP pilens_symbol_search tool with the same #517-slimmed payload.
*/
import * as path from "node:path";
import { Type } from "../clients/deps/typebox.js";
import { symbolSearch } from "../clients/lens-engine.js";
import { baseName, compactRenderResult } from "./render-compact.js";
export function createSymbolSearchTool(getProjectRoot) {
return {
name: "symbol_search",
label: "Symbol Search",
description: "Ranked identifier search over the persisted word index (BM25 + priors demoting tests/vendor/docs) — answers 'which files are most relevant to <query>' by identifier. First step of the discovery funnel: symbol_search finds candidates, module_report explains the file, read_symbol reads the body. Complements grep (raw substrings) and lsp_navigation (exact references). Each hit's startLine/endLine mark its best-matching line (offset=startLine, limit=endLine-startLine+1 for a one-line peek); use module_report on `file` for the real outline. Returns available:false with a retry hint if the index isn't built yet — it self-builds in the background (never blocks this call).",
promptSnippet: "Ranked identifier search — find relevant files by name/usage",
renderResult: compactRenderResult(({ details, isError }) => {
if (isError || details?.available === false) {
return `symbol_search "${details?.query ?? ""}" — unavailable${details?.hint ? `: ${details.hint}` : ""}`;
}
return `symbol_search "${details?.query ?? ""}" ${details?.count ?? 0} file(s)`;
}),
parameters: Type.Object({
query: Type.String({
description: "Identifier-ish query, e.g. 'authenticate user'.",
}),
limit: Type.Optional(Type.Number({
description: "Max files to return (default 20).",
})),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const cwd = getProjectRoot() || ctx.cwd || ".";
let result;
try {
result = symbolSearch(params.query, cwd, params.limit);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [
{
type: "text",
text: `Symbol search failed for "${params.query}": ${message}`,
},
],
isError: true,
details: { available: false, query: params.query },
};
}
if (!result.available) {
return {
content: [
{
type: "text",
text: result.hint ??
"No word index for this workspace yet — retry shortly.",
},
],
isError: true,
details: {
available: false,
query: result.query,
hint: result.hint,
},
};
}
if (result.results.length === 0) {
return {
content: [
{ type: "text", text: `No files matched "${params.query}".` },
],
details: { available: true, query: result.query, count: 0 },
};
}
const lines = [
`Top ${result.results.length} file(s) for "${result.query}":`,
...result.results.map((hit, i) => ` ${i + 1}. ${path.relative(cwd, hit.file)} ` +
`(score ${hit.score.toFixed(2)}, ${hit.hits} hit(s), line ${hit.startLine})`),
];
// Compact (unindented) JSON payload, matching module_report/read_symbol
// (#517): path relative-to-cwd once per hit, no per-hit `read` block —
// startLine/endLine already derive offset/limit for a one-line peek.
const payload = {
available: true,
query: result.query,
results: result.results.map((hit) => ({
file: path.relative(cwd, hit.file),
score: hit.score,
hits: hit.hits,
startLine: hit.startLine,
endLine: hit.endLine,
})),
};
return {
content: [
{ type: "text", text: `${lines.join("\n")}\n\n${JSON.stringify(payload)}` },
],
details: {
available: true,
query: result.query,
count: result.results.length,
},
};
},
};
}
// Re-exported so tests importing from this module can reach baseName without
// a second import path.
export { baseName };