main
1/**
2 * Multi-backend Search Extension for Pi
3 *
4 * Provides web search and GitHub code search tools.
5 * Web search uses multiple backends with automatic fallback:
6 * 1. Brave Search API (primary, requires BRAVE_API_KEY)
7 * 2. SearXNG (self-hosted fallback)
8 * 3. ddgr CLI (DuckDuckGo via CLI)
9 * 4. DuckDuckGo Instant Answer API (always available, limited results)
10 *
11 * Additional backends available via /search-backend:
12 * - bing: Playwright/Bing (headless Chrome browser search)
13 * - brave: Playwright/Brave Search (rate-limited after a few queries)
14 * - mojeek: Playwright/Mojeek (independent engine, no CAPTCHA)
15 * - ecosia: Playwright/Ecosia (Bing-powered, good results)
16 *
17 * Configuration via environment variables:
18 * BRAVE_API_KEY - Brave Search API key (enables brave-api backend)
19 * SEARXNG_URL - SearXNG instance URL (default: https://search.sbr.pm)
20 */
21
22import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
23import { Text } from "@mariozechner/pi-tui";
24import { Type } from "@sinclair/typebox";
25import {
26 BraveAPIBackend,
27 SearXNGBackend,
28 DuckDuckGoBackend,
29 DdgrBackend,
30 PlaywrightBackend,
31 searchWithFallback,
32 formatResults,
33 type SearchBackend,
34} from "./backends";
35
36function truncate(text: string, maxLength: number): string {
37 if (text.length <= maxLength) return text;
38 return text.slice(0, maxLength) + "...";
39}
40
41export default function (pi: ExtensionAPI) {
42 // Configure backends
43 const searxngUrl = process.env.SEARXNG_URL || "https://search.sbr.pm";
44 // __dirname works in jiti (pi's TypeScript loader)
45 const extensionDir = __dirname;
46 const execFn = (cmd: string, args: string[], opts?: any) => pi.exec(cmd, args, opts);
47
48 const braveApiKey = process.env.BRAVE_API_KEY || "";
49
50 const allBackends: Record<string, SearchBackend> = {
51 "brave-api": new BraveAPIBackend(braveApiKey),
52 searxng: new SearXNGBackend(searxngUrl),
53 ddgr: new DdgrBackend(execFn),
54 bing: new PlaywrightBackend("bing", execFn, extensionDir),
55 brave: new PlaywrightBackend("brave", execFn, extensionDir),
56 mojeek: new PlaywrightBackend("mojeek", execFn, extensionDir),
57 ecosia: new PlaywrightBackend("ecosia", execFn, extensionDir),
58 duckduckgo: new DuckDuckGoBackend(),
59 };
60
61 // Active backends in priority order
62 // SearXNG first, then Playwright browsers (Bing most reliable, then Mojeek/Ecosia),
63 // then ddgr CLI, then DDG API as last resort. Brave available via /search-backend.
64 // Brave API first (fast, high quality), then SearXNG fallback, then browser-based fallbacks
65 let activeBackendNames = braveApiKey
66 ? ["brave-api", "searxng", "ddgr", "duckduckgo"]
67 : ["searxng", "bing", "mojeek", "ecosia", "ddgr", "duckduckgo"];
68
69 // Helper to get current active backends
70 const getActiveBackends = (): SearchBackend[] =>
71 activeBackendNames.map((name) => allBackends[name]).filter(Boolean);
72
73 /**
74 * /search-backend command — view or change the active search backends
75 *
76 * Usage:
77 * /search-backend — show current backend order
78 * /search-backend ddgr — use only ddgr
79 * /search-backend searxng ddgr — use searxng with ddgr fallback
80 * /search-backend all — reset to all backends
81 */
82 pi.registerCommand("search-backend", {
83 description: "View or set web search backends (e.g., /search-backend ddgr)",
84 handler: async (args, ctx) => {
85 if (!args || !args.trim()) {
86 const status = activeBackendNames
87 .map((name, i) => ` ${i + 1}. ${name}`)
88 .join("\n");
89 ctx.ui.notify(
90 `Active search backends:\n${status}\n\nAvailable: ${Object.keys(allBackends).join(", ")}, all`,
91 "info",
92 );
93 return;
94 }
95
96 const requested = args.trim().toLowerCase().split(/\s+/);
97
98 if (requested.length === 1 && requested[0] === "all") {
99 activeBackendNames = Object.keys(allBackends);
100 ctx.ui.notify(`Search backends reset to: ${activeBackendNames.join(" → ")}`, "info");
101 return;
102 }
103
104 const invalid = requested.filter((name) => !allBackends[name]);
105 if (invalid.length > 0) {
106 ctx.ui.notify(
107 `Unknown backend(s): ${invalid.join(", ")}\nAvailable: ${Object.keys(allBackends).join(", ")}`,
108 "error",
109 );
110 return;
111 }
112
113 activeBackendNames = requested;
114 ctx.ui.notify(`Search backends set to: ${activeBackendNames.join(" → ")}`, "info");
115 },
116 });
117
118 /**
119 * Web Search Tool - Multi-backend with automatic fallback
120 */
121 pi.registerTool({
122 name: "web_search",
123 label: "Web Search",
124 description:
125 "Performs a web search using the DuckDuckGo API to answer questions or find information.",
126 parameters: Type.Object({
127 query: Type.String({ description: "The search query." }),
128 limit: Type.Optional(Type.Number({ description: "Maximum number of results (default 10)" })),
129 engines: Type.Optional(Type.String({ description: "Comma-separated list of SearXNG engines to use (e.g. 'google,wikipedia'). Only applies to SearXNG backend." })),
130 }),
131 renderCall(args, theme) {
132 let text = theme.fg("toolTitle", theme.bold("web_search "));
133 text += theme.fg("muted", `"${args.query}"`);
134 return new Text(text, 0, 0);
135 },
136
137 renderResult(result, { expanded }, theme) {
138 const backend = result.details?.backend || "unknown";
139 const count = result.details?.resultCount || 0;
140
141 if (result.isError) {
142 return new Text(theme.fg("error", result.content?.[0]?.text || "Search failed"), 0, 0);
143 }
144
145 let text = theme.fg("success", `✓ ${count} results`) + theme.fg("dim", ` via ${backend}`);
146 if (expanded && result.content?.[0]?.text) {
147 text += "\n" + theme.fg("muted", result.content[0].text);
148 }
149 return new Text(text, 0, 0);
150 },
151
152 async execute(_toolCallId, params, signal) {
153 try {
154 const maxResults = params.limit ?? 10;
155 const { results, backend, errors } = await searchWithFallback(
156 getActiveBackends(),
157 params.query,
158 maxResults,
159 signal,
160 params.engines,
161 );
162
163 if (results.length === 0) {
164 const errorInfo = errors.length > 0
165 ? `\nBackend errors:\n${errors.join("\n")}`
166 : "";
167 return {
168 content: [{ type: "text", text: `No results found for "${params.query}".${errorInfo}` }],
169 };
170 }
171
172 const output = formatResults(results, backend);
173 return {
174 content: [{ type: "text", text: truncate(output, 4000) }],
175 details: { backend, resultCount: results.length },
176 };
177 } catch (e: any) {
178 return {
179 content: [{ type: "text", text: `Search error: ${e.message}` }],
180 };
181 }
182 },
183 });
184
185 /**
186 * GitHub Code Search Tool
187 */
188 pi.registerTool({
189 name: "github_search",
190 label: "GitHub Code Search",
191 description:
192 "Searches for code snippets on GitHub. You can specify a repository with 'repo:owner/repo'.",
193 parameters: Type.Object({
194 query: Type.String({
195 description:
196 "The code search query. Examples: 'readFile repo:owner/project', 'my_function language:python'",
197 }),
198 limit: Type.Optional(Type.Number({ description: "Maximum number of results (default 10)" })),
199 }),
200 renderCall(args, theme) {
201 let text = theme.fg("toolTitle", theme.bold("github_search "));
202 text += theme.fg("muted", `"${args.query}"`);
203 return new Text(text, 0, 0);
204 },
205
206 renderResult(result, { expanded }, theme) {
207 if (result.isError) {
208 return new Text(theme.fg("error", result.content?.[0]?.text || "Search failed"), 0, 0);
209 }
210 const content = result.content?.[0]?.text || "";
211 const count = (content.match(/^Path:/gm) || []).length;
212 let text = count > 0
213 ? theme.fg("success", `✓ ${count} results`)
214 : theme.fg("warning", content);
215 if (expanded && content) {
216 text += "\n" + theme.fg("muted", content);
217 }
218 return new Text(text, 0, 0);
219 },
220
221 async execute(_toolCallId, params, signal) {
222 try {
223 const tokenResult = await pi.exec("gh", ["auth", "token"], { signal });
224 const token = tokenResult.stdout.trim();
225
226 if (tokenResult.code !== 0 || !token) {
227 return {
228 content: [{
229 type: "text",
230 text: `Error fetching GitHub token. Is 'gh' installed and authenticated?\nExit Code: ${tokenResult.code}\nStderr: ${tokenResult.stderr || "(empty)"}`,
231 }],
232 };
233 }
234
235 const url = `https://api.github.com/search/code?q=${encodeURIComponent(params.query)}`;
236 const response = await fetch(url, {
237 headers: {
238 Accept: "application/vnd.github.v3+json",
239 Authorization: `Bearer ${token}`,
240 },
241 signal,
242 });
243
244 if (response.status === 401) {
245 return {
246 content: [{
247 type: "text",
248 text: "Error: GitHub API returned 401 (Unauthorized). The token may be invalid or expired.",
249 }],
250 };
251 }
252 if (!response.ok) {
253 return {
254 content: [{
255 type: "text",
256 text: `Error: GitHub API returned status ${response.status}`,
257 }],
258 };
259 }
260
261 const data = await response.json();
262 const items = data.items || [];
263
264 if (items.length === 0) {
265 return {
266 content: [{ type: "text", text: "No code results found on GitHub." }],
267 };
268 }
269
270 const maxResults = params.limit ?? 10;
271 const results = items
272 .slice(0, maxResults)
273 .map(
274 (item: any) =>
275 `Path: ${item.path} (Repo: ${item.repository.full_name})\nURL: ${item.html_url}`,
276 )
277 .join("\n\n");
278
279 return { content: [{ type: "text", text: truncate(results, 4000) }] };
280 } catch (e: any) {
281 return {
282 content: [{ type: "text", text: `GitHub search error: ${e.message}` }],
283 };
284 }
285 },
286 });
287
288}