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

255 lines
7.8 KiB
TypeScript

// 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<string, ToolEntry> = 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<string>();
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);
});
}