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}