flake-update-20260505
  1/**
  2 * Pi Extension: Conditional Tool Loading
  3 *
  4 * Reduces per-request token cost by disabling tools not relevant
  5 * to the current workspace. Tools are filtered at session start
  6 * based on cwd context (git remotes, project markers, path patterns).
  7 *
  8 * Commands:
  9 *   /load-tool [name]  — Enable a specific tool (no args = list inactive)
 10 *   /tools-all         — Enable all registered tools
 11 *   /tools-reset       — Re-apply conditional filtering
 12 *
 13 * Override:
 14 *   PI_ALL_TOOLS=1     — Disable filtering entirely
 15 */
 16
 17import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 18import { existsSync } from "fs";
 19import { join } from "path";
 20
 21// Tools that are always active regardless of context
 22const ALWAYS_ACTIVE = new Set([
 23	// built-ins
 24	"read", "bash", "edit", "write",
 25	// personal workflow
 26	"org_todo", "get_current_time", "web_search",
 27	// session management
 28	"find_threads", "search_thread",
 29	"save_session_to_history", "list_saved_sessions", "read_saved_session",
 30	"save_learning", "save_research", "save_plan",
 31	// delegation & worktrees
 32	"subagent", "git_worktree",
 33]);
 34
 35// Conditional tools: name → async (cwd, exec) => should_enable
 36type ExecFn = (cmd: string, args: string[], opts?: any) => Promise<{ code: number; stdout: string; stderr: string }>;
 37
 38const CONDITIONAL: Record<string, (cwd: string, exec: ExecFn) => Promise<boolean>> = {
 39	github: async (cwd, exec) => {
 40		const result = await exec("git", ["remote", "-v"], { cwd, timeout: 3000 });
 41		return result.code === 0 && result.stdout.includes("github.com");
 42	},
 43
 44	github_search: async (cwd, exec) => {
 45		const result = await exec("git", ["remote", "-v"], { cwd, timeout: 3000 });
 46		return result.code === 0 && result.stdout.includes("github.com");
 47	},
 48
 49	jira: async (cwd) => {
 50		return /\b(tektoncd|osp|redhat|chapeau-rouge)\b/.test(cwd) ||
 51			existsSync(join(cwd, ".jira"));
 52	},
 53
 54	lsp: async (cwd) => {
 55		// Match root markers from all LSP server configs in lsp-core.ts
 56		return [
 57			"flake.nix", "default.nix", "shell.nix",               // nix (nil/nixd)
 58			"go.mod", "go.work",                                     // go (gopls)
 59			"tsconfig.json", "jsconfig.json", "package.json",       // typescript
 60			"Cargo.toml",                                            // rust
 61			"pyproject.toml", "pyrightconfig.json", "setup.py",     // python
 62			"pubspec.yaml",                                          // dart
 63			"settings.gradle", "settings.gradle.kts",               // kotlin
 64		].some(f => existsSync(join(cwd, f)));
 65	},
 66
 67	// Pending removal — always disabled
 68	kitty_control: async () => false,
 69};
 70
 71export default function (pi: ExtensionAPI) {
 72	async function applyFilter(cwd: string): Promise<{ all: string[]; kept: string[]; dropped: string[] }> {
 73		const all = pi.getActiveTools();
 74		const kept: string[] = [];
 75
 76		for (const name of all) {
 77			if (ALWAYS_ACTIVE.has(name)) {
 78				kept.push(name);
 79				continue;
 80			}
 81			const rule = CONDITIONAL[name];
 82			if (rule) {
 83				try {
 84					const enabled = await rule(cwd, pi.exec.bind(pi));
 85					if (enabled) kept.push(name);
 86				} catch {
 87					// On error, keep the tool (safe default)
 88					kept.push(name);
 89				}
 90				continue;
 91			}
 92			// Unknown tools (from other extensions): keep by default
 93			kept.push(name);
 94		}
 95
 96		const dropped = all.filter(n => !kept.includes(n));
 97		if (dropped.length > 0) {
 98			pi.setActiveTools(kept);
 99		}
100
101		return { all, kept, dropped };
102	}
103
104	pi.on("session_start", async (_event, ctx) => {
105		if (process.env.PI_ALL_TOOLS === "1") return;
106		await applyFilter(ctx.cwd);
107	});
108
109	pi.registerCommand("load-tool", {
110		description: "Enable a specific tool for this session (no args = list inactive)",
111		getArgumentCompletions: (prefix) => {
112			const all = pi.getAllTools().map(t => t.name);
113			const active = new Set(pi.getActiveTools());
114			return all
115				.filter(n => !active.has(n) && n.startsWith(prefix))
116				.map(n => ({ value: n, description: "Enable tool" }));
117		},
118		handler: async (args, ctx) => {
119			const name = args.trim();
120			if (!name) {
121				const all = pi.getAllTools().map(t => t.name);
122				const active = new Set(pi.getActiveTools());
123				const inactive = all.filter(n => !active.has(n));
124				ctx.ui.notify(
125					inactive.length > 0
126						? `Inactive tools: ${inactive.join(", ")}`
127						: "All tools are active",
128					"info",
129				);
130				return;
131			}
132			const allNames = pi.getAllTools().map(t => t.name);
133			if (!allNames.includes(name)) {
134				ctx.ui.notify(`Unknown tool: ${name}`, "error");
135				return;
136			}
137			const active = pi.getActiveTools();
138			if (active.includes(name)) {
139				ctx.ui.notify(`${name} is already active`, "info");
140				return;
141			}
142			pi.setActiveTools([...active, name]);
143			ctx.ui.notify(`Enabled: ${name}`, "info");
144		},
145	});
146
147	pi.registerCommand("tools-all", {
148		description: "Enable all registered tools for this session",
149		handler: async (_args, ctx) => {
150			const all = pi.getAllTools().map(t => t.name);
151			pi.setActiveTools(all);
152			ctx.ui.notify(`All ${all.length} tools enabled`, "info");
153		},
154	});
155
156	pi.registerCommand("tools-reset", {
157		description: "Re-apply conditional tool filtering",
158		handler: async (_args, ctx) => {
159			// Restore all first, then filter
160			const all = pi.getAllTools().map(t => t.name);
161			pi.setActiveTools(all);
162			const { kept, dropped } = await applyFilter(ctx.cwd);
163			const msg = dropped.length > 0
164				? `Tools: ${kept.length}/${all.length} active (dropped: ${dropped.join(", ")})`
165				: `All ${all.length} tools active (nothing filtered)`;
166			ctx.ui.notify(msg, "info");
167		},
168	});
169}