// ABOUTME: Tool Registry — in-memory index of all available tools with categorization and search. // ABOUTME: Provides the foundation for tool_search and call_tool extensions. import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; // ── Types ──────────────────────────────────────── export interface ToolEntry { name: string; label: string; description: string; category: string; tags: string[]; source: "builtin" | "extension" | "skill" | "commander"; parameterSummary: string; } // ── Category Detection ─────────────────────────── const CATEGORY_RULES: { category: string; names: string[]; keywords: string[] }[] = [ { category: "filesystem", names: ["read", "write", "edit", "ls", "find", "grep"], keywords: ["file", "directory", "path", "read", "write", "edit"], }, { category: "shell", names: ["bash"], keywords: ["command", "terminal", "shell", "execute"], }, { category: "commander", names: [], keywords: ["commander"], }, { category: "testing", names: ["web_remote", "debug_capture"], keywords: ["test", "screenshot", "capture", "audit"], }, { category: "ui", names: ["ask_user", "show_plan", "show_file", "show_report", "show_spec"], keywords: ["viewer", "interactive", "user", "display", "plan", "report"], }, { category: "agents", names: ["dispatch_agent", "subagent_create", "subagent_create_batch", "subagent_continue", "subagent_remove", "subagent_list"], keywords: ["agent", "subagent", "dispatch", "spawn"], }, { category: "workflow", names: ["tasks", "set_mode", "advance_phase", "dispatch_agents", "pipeline_status", "run_chain", "cycle_memory"], keywords: ["task", "mode", "pipeline", "phase", "workflow", "chain"], }, ]; function detectCategory(name: string, description: string): string { const lowerName = name.toLowerCase(); const lowerDesc = description.toLowerCase(); // Commander tools — name-based match if (lowerName.startsWith("commander_")) return "commander"; for (const rule of CATEGORY_RULES) { if (rule.names.includes(lowerName)) return rule.category; for (const kw of rule.keywords) { if (lowerDesc.includes(kw) && !lowerName.startsWith("commander_")) { // Only match if not already caught by a name rule above } } } // Keyword-based fallback for (const rule of CATEGORY_RULES) { for (const kw of rule.keywords) { if (lowerDesc.includes(kw)) return rule.category; } } return "general"; } // ── Tag Extraction ─────────────────────────────── const TAG_KEYWORDS = [ "file", "read", "write", "edit", "delete", "create", "search", "find", "bash", "command", "shell", "terminal", "execute", "run", "task", "project", "workflow", "pipeline", "plan", "mode", "agent", "subagent", "dispatch", "spawn", "parallel", "test", "debug", "screenshot", "capture", "audit", "accessibility", "browser", "web", "url", "page", "navigate", "click", "image", "generate", "visual", "session", "terminal", "cleanup", "message", "mailbox", "send", "inbox", "dependency", "graph", "block", "spec", "requirement", "feature", "jira", "issue", "ticket", "orchestration", "hierarchy", "registry", "git", "commit", "branch", "viewer", "interactive", "ui", "display", "memory", "compact", "context", ]; function extractTags(name: string, description: string): string[] { const text = `${name} ${description}`.toLowerCase(); const tags: string[] = []; for (const kw of TAG_KEYWORDS) { if (text.includes(kw) && !tags.includes(kw)) { tags.push(kw); } } return tags.slice(0, 10); // Cap at 10 tags } // ── Source Detection ───────────────────────────── const BUILTIN_TOOLS = ["read", "write", "edit", "bash", "ls", "find", "grep"]; function detectSource(name: string): ToolEntry["source"] { if (BUILTIN_TOOLS.includes(name)) return "builtin"; if (name.startsWith("commander_")) return "commander"; return "extension"; } // ── Parameter Summary ──────────────────────────── function summarizeParameters(description: string): string { // Extract parameter info from description — look for common patterns const lines = description.split("\n"); const paramLines: string[] = []; for (const line of lines) { const trimmed = line.trim(); // Match lines like: - "operation": description // or: requires field_name if (trimmed.match(/^-\s*"?\w+"?\s*[:—-]/)) { paramLines.push(trimmed.replace(/^-\s*/, "").trim()); } } if (paramLines.length > 0) { return paramLines.slice(0, 5).join("; "); } // Fallback: first sentence of description const firstSentence = description.split(/[.\n]/)[0]?.trim() || ""; return firstSentence.length > 100 ? firstSentence.slice(0, 100) + "..." : firstSentence; } // ── Registry Class ─────────────────────────────── export class ToolRegistry { private tools: Map = new Map(); buildIndex(allTools: { name: string; description?: string }[]): void { this.tools.clear(); for (const tool of allTools) { const desc = tool.description || ""; const entry: ToolEntry = { name: tool.name, label: tool.name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), description: desc, category: detectCategory(tool.name, desc), tags: extractTags(tool.name, desc), source: detectSource(tool.name), parameterSummary: summarizeParameters(desc), }; this.tools.set(tool.name, entry); } } getAll(): ToolEntry[] { return [...this.tools.values()]; } getByName(name: string): ToolEntry | undefined { return this.tools.get(name); } getByCategory(category: string): ToolEntry[] { return this.getAll().filter((t) => t.category === category); } getCategories(): string[] { const cats = new Set(); for (const t of this.tools.values()) cats.add(t.category); return [...cats].sort(); } search(query: string): ToolEntry[] { const terms = query.toLowerCase().split(/\s+/).filter(Boolean); if (terms.length === 0) return this.getAll(); const scored: { entry: ToolEntry; score: number }[] = []; for (const entry of this.tools.values()) { let score = 0; const searchText = `${entry.name} ${entry.label} ${entry.description} ${entry.tags.join(" ")} ${entry.category}`.toLowerCase(); for (const term of terms) { // Exact name match — highest if (entry.name.toLowerCase() === term) score += 100; // Name contains term else if (entry.name.toLowerCase().includes(term)) score += 50; // Category match else if (entry.category.toLowerCase() === term) score += 40; // Tag match else if (entry.tags.includes(term)) score += 30; // Description contains term else if (entry.description.toLowerCase().includes(term)) score += 10; // Fuzzy: any field contains else if (searchText.includes(term)) score += 5; } if (score > 0) { scored.push({ entry, score }); } } return scored .sort((a, b) => b.score - a.score) .map((s) => s.entry); } get size(): number { return this.tools.size; } } // ── Singleton & Extension ──────────────────────── // Shared registry instance accessible by other extensions via globalThis const g = globalThis as any; export function getToolRegistry(): ToolRegistry { if (!g.__piToolRegistry) { g.__piToolRegistry = new ToolRegistry(); } return g.__piToolRegistry; } export default function (pi: ExtensionAPI) { const registry = getToolRegistry(); pi.on("session_start", async (_event, _ctx) => { // Build index from all registered tools const allTools = pi.getAllTools(); registry.buildIndex(allTools); }); }