Files
pi-skill/extensions/tool-search.ts
2026-05-25 16:41:08 +07:00

247 lines
8.9 KiB
TypeScript

// ABOUTME: Tool Search — meta-tool that lets the agent discover and inspect available tools at runtime.
// ABOUTME: Provides search, list, and inspect operations against the tool registry.
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { StringEnum } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { Text } from "@mariozechner/pi-tui";
import { getToolRegistry, type ToolEntry } from "./tool-registry.ts";
import { applyExtensionDefaults } from "./lib/themeMap.ts";
// ── Tool Parameters ────────────────────────────────────────────────────
const ToolSearchParams = Type.Object({
operation: StringEnum(["search", "list", "inspect"] as const),
query: Type.Optional(Type.String({ description: "Search query — matches tool names, descriptions, tags, and categories" })),
category: Type.Optional(Type.String({ description: "Filter by category (for 'list' operation). Use 'list' without category to see all categories." })),
tool_name: Type.Optional(Type.String({ description: "Tool name to inspect (for 'inspect' operation)" })),
});
// ── Formatting Helpers ─────────────────────────────────────────────────
function formatToolCompact(entry: ToolEntry): string {
return `${entry.name} [${entry.category}] — ${entry.parameterSummary}`;
}
function formatToolDetailed(entry: ToolEntry): string {
const lines: string[] = [
`## ${entry.name}`,
``,
`**Label:** ${entry.label}`,
`**Category:** ${entry.category}`,
`**Source:** ${entry.source}`,
`**Tags:** ${entry.tags.join(", ") || "none"}`,
``,
`### Description`,
entry.description,
];
return lines.join("\n");
}
function formatCategoryList(categories: { name: string; count: number }[]): string {
const lines = ["**Available Tool Categories:**", ""];
for (const cat of categories) {
lines.push(`${cat.name} (${cat.count} tools)`);
}
return lines.join("\n");
}
// ── Extension ──────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
const registry = getToolRegistry();
pi.registerTool({
name: "tool_search",
label: "Tool Search",
description:
"Search, list, and inspect available tools. Use this to discover what tools are available " +
"before calling them. Three operations:\n" +
"- 'search': Find tools by query (matches names, descriptions, tags, categories)\n" +
"- 'list': List all tools or filter by category. Omit category to see all categories.\n" +
"- 'inspect': Get full details and parameter schema for a specific tool by name.\n\n" +
"Examples:\n" +
'{ "operation": "search", "query": "file management" }\n' +
'{ "operation": "list", "category": "commander" }\n' +
'{ "operation": "inspect", "tool_name": "commander_task" }',
parameters: ToolSearchParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { operation, query, category, tool_name } = params;
// Ensure registry is populated
if (registry.size === 0) {
const allTools = pi.getAllTools();
registry.buildIndex(allTools);
}
if (operation === "search") {
if (!query) {
return {
content: [{ type: "text" as const, text: "Error: 'query' is required for search operation" }],
};
}
const results = registry.search(query);
if (results.length === 0) {
return {
content: [{ type: "text" as const, text: `No tools found matching "${query}"` }],
details: { operation, query, resultCount: 0 },
};
}
const formatted = results.map(formatToolCompact).join("\n");
return {
content: [{
type: "text" as const,
text: `Found ${results.length} tool(s) matching "${query}":\n\n${formatted}`,
}],
details: { operation, query, resultCount: results.length, results: results.map((r) => r.name) },
};
}
if (operation === "list") {
if (category) {
const tools = registry.getByCategory(category);
if (tools.length === 0) {
return {
content: [{ type: "text" as const, text: `No tools in category "${category}". Use list without category to see available categories.` }],
details: { operation, category, resultCount: 0 },
};
}
const formatted = tools.map(formatToolCompact).join("\n");
return {
content: [{
type: "text" as const,
text: `**${category}** tools (${tools.length}):\n\n${formatted}`,
}],
details: { operation, category, resultCount: tools.length },
};
}
// No category — show categories overview
const categories = registry.getCategories().map((name) => ({
name,
count: registry.getByCategory(name).length,
}));
const totalTools = registry.size;
const formatted = formatCategoryList(categories);
return {
content: [{
type: "text" as const,
text: `${formatted}\n\n**Total:** ${totalTools} tools across ${categories.length} categories`,
}],
details: { operation, categories: categories.map((c) => c.name), totalTools },
};
}
if (operation === "inspect") {
if (!tool_name) {
return {
content: [{ type: "text" as const, text: "Error: 'tool_name' is required for inspect operation" }],
};
}
const entry = registry.getByName(tool_name);
if (!entry) {
// Try fuzzy search as fallback
const similar = registry.search(tool_name).slice(0, 5);
const suggestion = similar.length > 0
? `\n\nDid you mean: ${similar.map((s) => s.name).join(", ")}?`
: "";
return {
content: [{ type: "text" as const, text: `Tool "${tool_name}" not found.${suggestion}` }],
details: { operation, tool_name, found: false },
};
}
return {
content: [{ type: "text" as const, text: formatToolDetailed(entry) }],
details: { operation, tool_name, found: true, category: entry.category },
};
}
return {
content: [{ type: "text" as const, text: `Unknown operation: ${operation}. Use 'search', 'list', or 'inspect'.` }],
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("tool_search "));
text += theme.fg("accent", args.operation || "");
if (args.query) text += theme.fg("dim", ` "${args.query}"`);
if (args.category) text += theme.fg("dim", ` category:${args.category}`);
if (args.tool_name) text += theme.fg("dim", ` ${args.tool_name}`);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.operation === "search" || details.operation === "list") {
const count = details.resultCount ?? details.totalTools ?? 0;
let summary = theme.fg("success", `${count} result(s)`);
if (details.query) summary += theme.fg("dim", ` for "${details.query}"`);
if (details.category) summary += theme.fg("dim", ` in ${details.category}`);
if (expanded) {
const text = result.content[0];
const body = text?.type === "text" ? text.text : "";
return new Text(summary + "\n" + theme.fg("muted", body), 0, 0);
}
return new Text(summary, 0, 0);
}
if (details.operation === "inspect") {
if (details.found) {
const label = theme.fg("success", `${details.tool_name}`);
const cat = theme.fg("dim", ` [${details.category}]`);
if (expanded) {
const text = result.content[0];
const body = text?.type === "text" ? text.text : "";
return new Text(label + cat + "\n" + theme.fg("muted", body), 0, 0);
}
return new Text(label + cat, 0, 0);
}
return new Text(theme.fg("error", `✗ Tool not found: ${details.tool_name}`), 0, 0);
}
return new Text(theme.fg("dim", "tool_search completed"), 0, 0);
},
});
// Register /tool-search command as a shortcut
pi.registerCommand("tool-search", {
description: "Search for available tools by query",
handler: async (args, ctx) => {
const query = (args ?? "").trim();
if (!query) {
// Show all categories
const categories = registry.getCategories().map((name) => ({
name,
count: registry.getByCategory(name).length,
}));
const formatted = formatCategoryList(categories);
ctx.ui.notify(`${formatted}\n\nTotal: ${registry.size} tools`, "info");
} else {
const results = registry.search(query);
if (results.length === 0) {
ctx.ui.notify(`No tools found matching "${query}"`, "warning");
} else {
const formatted = results.slice(0, 10).map(formatToolCompact).join("\n");
ctx.ui.notify(`Found ${results.length} tool(s):\n${formatted}`, "info");
}
}
},
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
});
}