Commit 454227a820ec

Vincent Demeester <vincent@sbr.pm>
2026-02-10 15:43:48
feat: multi-backend search extension with ddgr
Replaced single-file search extension with a multi-backend directory extension supporting SearXNG, ddgr CLI, and DuckDuckGo API with automatic fallback. Added /search-backend command for backend selection. Included 24 unit tests and added ddgr to dev packages.
1 parent 5c51964
Changed files (5)
dots
home
common
dots/pi/agent/extensions/search/backends.test.ts
@@ -0,0 +1,414 @@
+/**
+ * Tests for search backends.
+ *
+ * Run with: npx tsx --test backends.test.ts
+ *
+ * Tests cover:
+ * - SearXNG backend (mocked HTTP)
+ * - DuckDuckGo backend (mocked HTTP)
+ * - ddgr backend (mocked exec)
+ * - Fallback logic (searchWithFallback)
+ * - Result formatting
+ */
+
+import { describe, it, mock, beforeEach, afterEach } from "node:test";
+import assert from "node:assert/strict";
+import {
+  SearXNGBackend,
+  DuckDuckGoBackend,
+  DdgrBackend,
+  searchWithFallback,
+  formatResults,
+  type SearchBackend,
+  type SearchResult,
+} from "./backends";
+
+// ─── Helpers ──────────────────────────────────────────────────────────────
+
+/** Create a mock Response object */
+function mockResponse(body: any, status = 200): Response {
+  return {
+    ok: status >= 200 && status < 300,
+    status,
+    json: async () => body,
+    text: async () => JSON.stringify(body),
+  } as Response;
+}
+
+/** Create a failing backend for testing fallback */
+class FailingBackend implements SearchBackend {
+  name = "failing";
+  error: string;
+  constructor(error = "Backend unavailable") { this.error = error; }
+  async isAvailable() { return false; }
+  async search(): Promise<SearchResult[]> { throw new Error(this.error); }
+}
+
+/** Create a succeeding backend for testing fallback */
+class MockBackend implements SearchBackend {
+  name: string;
+  results: SearchResult[];
+  constructor(name: string, results: SearchResult[]) {
+    this.name = name;
+    this.results = results;
+  }
+  async isAvailable() { return true; }
+  async search() { return this.results; }
+}
+
+// ─── SearXNG Backend ──────────────────────────────────────────────────────
+
+describe("SearXNGBackend", () => {
+  let originalFetch: typeof globalThis.fetch;
+
+  beforeEach(() => {
+    originalFetch = globalThis.fetch;
+  });
+
+  afterEach(() => {
+    globalThis.fetch = originalFetch;
+  });
+
+  it("parses SearXNG JSON response correctly", async () => {
+    globalThis.fetch = mock.fn(async () =>
+      mockResponse({
+        results: [
+          { title: "NixOS Wiki", url: "https://wiki.nixos.org", content: "The NixOS wiki" },
+          { title: "Nix Manual", url: "https://nix.dev", content: "Official docs" },
+        ],
+      }),
+    ) as any;
+
+    const backend = new SearXNGBackend("https://search.sbr.pm");
+    const results = await backend.search("nixos", 5);
+
+    assert.equal(results.length, 2);
+    assert.equal(results[0].title, "NixOS Wiki");
+    assert.equal(results[0].url, "https://wiki.nixos.org");
+    assert.equal(results[0].snippet, "The NixOS wiki");
+  });
+
+  it("respects maxResults parameter", async () => {
+    globalThis.fetch = mock.fn(async () =>
+      mockResponse({
+        results: [
+          { title: "Result 1", url: "https://example.com/1", content: "One" },
+          { title: "Result 2", url: "https://example.com/2", content: "Two" },
+          { title: "Result 3", url: "https://example.com/3", content: "Three" },
+        ],
+      }),
+    ) as any;
+
+    const backend = new SearXNGBackend("https://search.sbr.pm");
+    const results = await backend.search("test", 2);
+
+    assert.equal(results.length, 2);
+  });
+
+  it("throws on HTTP error", async () => {
+    globalThis.fetch = mock.fn(async () => mockResponse({}, 500)) as any;
+
+    const backend = new SearXNGBackend("https://search.sbr.pm");
+    await assert.rejects(
+      () => backend.search("test", 5),
+      { message: "SearXNG request failed with status 500" },
+    );
+  });
+
+  it("returns empty array when no results", async () => {
+    globalThis.fetch = mock.fn(async () => mockResponse({ results: [] })) as any;
+
+    const backend = new SearXNGBackend("https://search.sbr.pm");
+    const results = await backend.search("obscurequery", 5);
+
+    assert.equal(results.length, 0);
+  });
+
+  it("handles missing fields gracefully", async () => {
+    globalThis.fetch = mock.fn(async () =>
+      mockResponse({
+        results: [
+          { title: null, url: "https://example.com", content: null },
+          { url: "https://example.com/2" },
+        ],
+      }),
+    ) as any;
+
+    const backend = new SearXNGBackend("https://search.sbr.pm");
+    const results = await backend.search("test", 5);
+
+    assert.equal(results.length, 2);
+    assert.equal(results[0].title, "(no title)");
+    assert.equal(results[0].snippet, "");
+  });
+
+  it("strips trailing slash from base URL", async () => {
+    const fetchMock = mock.fn(async () => mockResponse({ results: [] }));
+    globalThis.fetch = fetchMock as any;
+
+    const backend = new SearXNGBackend("https://search.sbr.pm/");
+    await backend.search("test", 5);
+
+    const calledUrl = (fetchMock as any).mock.calls[0].arguments[0];
+    assert.ok(
+      calledUrl.startsWith("https://search.sbr.pm/search?"),
+      `URL should not have double slash before /search: ${calledUrl}`,
+    );
+  });
+
+  it("isAvailable returns true for healthy instance", async () => {
+    globalThis.fetch = mock.fn(async () => mockResponse({ results: [] })) as any;
+    const backend = new SearXNGBackend("https://search.sbr.pm");
+    assert.equal(await backend.isAvailable(), true);
+  });
+
+  it("isAvailable returns false for unreachable instance", async () => {
+    globalThis.fetch = mock.fn(async () => { throw new Error("ECONNREFUSED"); }) as any;
+    const backend = new SearXNGBackend("https://nonexistent.example.com");
+    assert.equal(await backend.isAvailable(), false);
+  });
+});
+
+// ─── DuckDuckGo Backend ───────────────────────────────────────────────────
+
+describe("DuckDuckGoBackend", () => {
+  let originalFetch: typeof globalThis.fetch;
+
+  beforeEach(() => {
+    originalFetch = globalThis.fetch;
+  });
+
+  afterEach(() => {
+    globalThis.fetch = originalFetch;
+  });
+
+  it("parses abstract and related topics", async () => {
+    globalThis.fetch = mock.fn(async () =>
+      mockResponse({
+        AbstractText: "NixOS is a Linux distribution",
+        AbstractURL: "https://nixos.org",
+        Heading: "NixOS",
+        RelatedTopics: [
+          { Text: "Nix package manager - A package manager", FirstURL: "https://nixos.org/nix" },
+          { Text: "NixOps - Deployment tool", FirstURL: "https://nixos.org/nixops" },
+        ],
+      }),
+    ) as any;
+
+    const backend = new DuckDuckGoBackend();
+    const results = await backend.search("nixos", 5);
+
+    assert.equal(results.length, 3);
+    assert.equal(results[0].title, "NixOS");
+    assert.equal(results[0].url, "https://nixos.org");
+    assert.equal(results[0].snippet, "NixOS is a Linux distribution");
+    assert.equal(results[1].title, "Nix package manager");
+    assert.equal(results[1].url, "https://nixos.org/nix");
+  });
+
+  it("handles response with no abstract", async () => {
+    globalThis.fetch = mock.fn(async () =>
+      mockResponse({
+        AbstractText: "",
+        RelatedTopics: [
+          { Text: "Some topic", FirstURL: "https://example.com" },
+        ],
+      }),
+    ) as any;
+
+    const backend = new DuckDuckGoBackend();
+    const results = await backend.search("query", 5);
+
+    assert.equal(results.length, 1);
+    assert.equal(results[0].url, "https://example.com");
+  });
+
+  it("handles empty response", async () => {
+    globalThis.fetch = mock.fn(async () =>
+      mockResponse({ AbstractText: "", RelatedTopics: [] }),
+    ) as any;
+
+    const backend = new DuckDuckGoBackend();
+    const results = await backend.search("obscurequery", 5);
+
+    assert.equal(results.length, 0);
+  });
+
+  it("respects maxResults", async () => {
+    globalThis.fetch = mock.fn(async () =>
+      mockResponse({
+        AbstractText: "Abstract",
+        AbstractURL: "https://example.com",
+        Heading: "Test",
+        RelatedTopics: [
+          { Text: "Topic 1", FirstURL: "https://example.com/1" },
+          { Text: "Topic 2", FirstURL: "https://example.com/2" },
+          { Text: "Topic 3", FirstURL: "https://example.com/3" },
+        ],
+      }),
+    ) as any;
+
+    const backend = new DuckDuckGoBackend();
+    const results = await backend.search("test", 2);
+
+    assert.equal(results.length, 2);
+  });
+
+  it("isAvailable always returns true", async () => {
+    const backend = new DuckDuckGoBackend();
+    assert.equal(await backend.isAvailable(), true);
+  });
+});
+
+// ─── ddgr Backend ─────────────────────────────────────────────────────────
+
+describe("DdgrBackend", () => {
+  it("parses ddgr JSON output", async () => {
+    const execFn = mock.fn(async () => ({
+      stdout: JSON.stringify([
+        { title: "NixOS", url: "https://nixos.org", abstract: "A Linux distribution" },
+        { title: "Nix", url: "https://nix.dev", abstract: "Package manager" },
+      ]),
+      stderr: "",
+      code: 0,
+    }));
+
+    const backend = new DdgrBackend(execFn as any);
+    const results = await backend.search("nixos", 5);
+
+    assert.equal(results.length, 2);
+    assert.equal(results[0].title, "NixOS");
+    assert.equal(results[0].url, "https://nixos.org");
+    assert.equal(results[0].snippet, "A Linux distribution");
+
+    // Verify ddgr was called with correct args
+    const call = (execFn as any).mock.calls[0];
+    assert.equal(call.arguments[0], "ddgr");
+    assert.deepEqual(call.arguments[1], ["--json", "-n", "5", "nixos"]);
+  });
+
+  it("throws on non-zero exit code", async () => {
+    const execFn = mock.fn(async () => ({
+      stdout: "",
+      stderr: "ddgr: command not found",
+      code: 127,
+    }));
+
+    const backend = new DdgrBackend(execFn as any);
+    await assert.rejects(() => backend.search("test", 5), /ddgr failed/);
+  });
+
+  it("isAvailable checks for ddgr binary", async () => {
+    const execFn = mock.fn(async () => ({ stdout: "/usr/bin/ddgr", stderr: "", code: 0 }));
+    const backend = new DdgrBackend(execFn as any);
+    assert.equal(await backend.isAvailable(), true);
+
+    const execFnMissing = mock.fn(async () => ({ stdout: "", stderr: "", code: 1 }));
+    const backendMissing = new DdgrBackend(execFnMissing as any);
+    assert.equal(await backendMissing.isAvailable(), false);
+  });
+});
+
+// ─── Fallback Logic ───────────────────────────────────────────────────────
+
+describe("searchWithFallback", () => {
+  const sampleResults: SearchResult[] = [
+    { title: "Result", url: "https://example.com", snippet: "A result" },
+  ];
+
+  it("uses first successful backend", async () => {
+    const backends = [
+      new MockBackend("primary", sampleResults),
+      new MockBackend("secondary", []),
+    ];
+
+    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
+
+    assert.equal(backend, "primary");
+    assert.equal(results.length, 1);
+    assert.equal(errors.length, 0);
+  });
+
+  it("falls back to second backend on failure", async () => {
+    const backends = [
+      new FailingBackend("SearXNG is down"),
+      new MockBackend("fallback", sampleResults),
+    ];
+
+    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
+
+    assert.equal(backend, "fallback");
+    assert.equal(results.length, 1);
+    assert.equal(errors.length, 1);
+    assert.ok(errors[0].includes("SearXNG is down"));
+  });
+
+  it("falls back through multiple failures", async () => {
+    const backends = [
+      new FailingBackend("Error 1"),
+      new FailingBackend("Error 2"),
+      new MockBackend("third", sampleResults),
+    ];
+
+    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
+
+    assert.equal(backend, "third");
+    assert.equal(results.length, 1);
+    assert.equal(errors.length, 2);
+  });
+
+  it("returns empty results when all backends fail", async () => {
+    const backends = [
+      new FailingBackend("Error 1"),
+      new FailingBackend("Error 2"),
+    ];
+
+    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
+
+    assert.equal(backend, "none");
+    assert.equal(results.length, 0);
+    assert.equal(errors.length, 2);
+  });
+
+  it("handles empty backends array", async () => {
+    const { results, backend, errors } = await searchWithFallback([], "test", 5);
+
+    assert.equal(backend, "none");
+    assert.equal(results.length, 0);
+    assert.equal(errors.length, 0);
+  });
+});
+
+// ─── Result Formatting ───────────────────────────────────────────────────
+
+describe("formatResults", () => {
+  it("formats results with all fields", () => {
+    const results: SearchResult[] = [
+      { title: "NixOS", url: "https://nixos.org", snippet: "A Linux distro" },
+      { title: "Nix", url: "https://nix.dev", snippet: "Package manager" },
+    ];
+
+    const output = formatResults(results, "SearXNG");
+
+    assert.ok(output.includes("Results from SearXNG:"));
+    assert.ok(output.includes("1. NixOS"));
+    assert.ok(output.includes("URL: https://nixos.org"));
+    assert.ok(output.includes("A Linux distro"));
+    assert.ok(output.includes("2. Nix"));
+  });
+
+  it("handles empty results", () => {
+    const output = formatResults([], "SearXNG");
+    assert.equal(output, "No results found.");
+  });
+
+  it("handles results with empty fields", () => {
+    const results: SearchResult[] = [
+      { title: "Title Only", url: "", snippet: "" },
+    ];
+
+    const output = formatResults(results, "DuckDuckGo");
+    assert.ok(output.includes("1. Title Only"));
+    assert.ok(!output.includes("URL:"));
+  });
+});
dots/pi/agent/extensions/search/backends.ts
@@ -0,0 +1,193 @@
+/**
+ * Search backends for multi-backend web search.
+ *
+ * Each backend implements the SearchBackend interface.
+ * Backends are tried in priority order with automatic fallback.
+ */
+
+export interface SearchResult {
+  title: string;
+  url: string;
+  snippet: string;
+}
+
+export interface SearchBackend {
+  name: string;
+  search(query: string, maxResults: number, signal?: AbortSignal): Promise<SearchResult[]>;
+  isAvailable(): Promise<boolean>;
+}
+
+/**
+ * SearXNG backend - queries a self-hosted SearXNG instance via JSON API.
+ * Primary backend for unlimited private searches.
+ */
+export class SearXNGBackend implements SearchBackend {
+  name = "SearXNG";
+  private baseUrl: string;
+
+  constructor(baseUrl: string) {
+    this.baseUrl = baseUrl.replace(/\/$/, "");
+  }
+
+  async isAvailable(): Promise<boolean> {
+    try {
+      const response = await fetch(`${this.baseUrl}/search?q=test&format=json`, {
+        signal: AbortSignal.timeout(5000),
+      });
+      return response.ok;
+    } catch {
+      return false;
+    }
+  }
+
+  async search(query: string, maxResults: number, signal?: AbortSignal): Promise<SearchResult[]> {
+    const url = `${this.baseUrl}/search?q=${encodeURIComponent(query)}&format=json`;
+    const response = await fetch(url, { signal });
+
+    if (!response.ok) {
+      throw new Error(`SearXNG request failed with status ${response.status}`);
+    }
+
+    const data = await response.json();
+    const results: SearchResult[] = (data.results || []).slice(0, maxResults).map((r: any) => ({
+      title: r.title || "(no title)",
+      url: r.url || "",
+      snippet: r.content || "",
+    }));
+
+    return results;
+  }
+}
+
+/**
+ * DuckDuckGo backend - uses the DuckDuckGo Instant Answer API.
+ * Fallback backend, no API key required.
+ *
+ * Note: The DDG Instant Answer API returns limited results (abstracts + related topics).
+ * For better results, consider using ddgr CLI if available.
+ */
+export class DuckDuckGoBackend implements SearchBackend {
+  name = "DuckDuckGo";
+
+  async isAvailable(): Promise<boolean> {
+    return true; // Always available, no API key needed
+  }
+
+  async search(query: string, maxResults: number, signal?: AbortSignal): Promise<SearchResult[]> {
+    const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1`;
+    const response = await fetch(url, { signal });
+
+    if (!response.ok) {
+      throw new Error(`DuckDuckGo API request failed with status ${response.status}`);
+    }
+
+    const data = await response.json();
+    const results: SearchResult[] = [];
+
+    // Add abstract if available
+    if (data.AbstractText && data.AbstractURL) {
+      results.push({
+        title: data.Heading || "Abstract",
+        url: data.AbstractURL,
+        snippet: data.AbstractText,
+      });
+    }
+
+    // Add related topics
+    for (const topic of (data.RelatedTopics || [])) {
+      if (results.length >= maxResults) break;
+      if (topic.Text && topic.FirstURL) {
+        results.push({
+          title: topic.Text.split(" - ")[0] || topic.Text,
+          url: topic.FirstURL,
+          snippet: topic.Text,
+        });
+      }
+    }
+
+    return results;
+  }
+}
+
+/**
+ * ddgr CLI backend - uses the ddgr command-line tool for DuckDuckGo searches.
+ * Provides much better results than the DDG Instant Answer API.
+ * Requires ddgr to be installed.
+ */
+export class DdgrBackend implements SearchBackend {
+  name = "ddgr";
+  private execFn: (cmd: string, args: string[], opts?: any) => Promise<{ stdout: string; stderr: string; code: number }>;
+
+  constructor(execFn: (cmd: string, args: string[], opts?: any) => Promise<{ stdout: string; stderr: string; code: number }>) {
+    this.execFn = execFn;
+  }
+
+  async isAvailable(): Promise<boolean> {
+    try {
+      const result = await this.execFn("which", ["ddgr"]);
+      return result.code === 0;
+    } catch {
+      return false;
+    }
+  }
+
+  async search(query: string, maxResults: number, signal?: AbortSignal): Promise<SearchResult[]> {
+    const result = await this.execFn("ddgr", ["--json", "-n", String(maxResults), query], { signal });
+
+    if (result.code !== 0) {
+      throw new Error(`ddgr failed: ${result.stderr}`);
+    }
+
+    const data = JSON.parse(result.stdout);
+    return (data || []).map((r: any) => ({
+      title: r.title || "(no title)",
+      url: r.url || "",
+      snippet: r.abstract || "",
+    }));
+  }
+}
+
+/**
+ * Multi-backend search with automatic fallback.
+ *
+ * Tries backends in order, falling back to the next one on failure.
+ * Returns results from the first backend that succeeds.
+ */
+export async function searchWithFallback(
+  backends: SearchBackend[],
+  query: string,
+  maxResults: number,
+  signal?: AbortSignal,
+): Promise<{ results: SearchResult[]; backend: string; errors: string[] }> {
+  const errors: string[] = [];
+
+  for (const backend of backends) {
+    try {
+      const results = await backend.search(query, maxResults, signal);
+      return { results, backend: backend.name, errors };
+    } catch (e: any) {
+      errors.push(`${backend.name}: ${e.message}`);
+    }
+  }
+
+  return { results: [], backend: "none", errors };
+}
+
+/**
+ * Format search results into a readable string for LLM consumption.
+ */
+export function formatResults(results: SearchResult[], backend: string): string {
+  if (results.length === 0) {
+    return "No results found.";
+  }
+
+  const header = `Results from ${backend}:`;
+  const formatted = results.map((r, i) => {
+    const parts = [`${i + 1}. ${r.title}`];
+    if (r.url) parts.push(`   URL: ${r.url}`);
+    if (r.snippet) parts.push(`   ${r.snippet}`);
+    return parts.join("\n");
+  }).join("\n\n");
+
+  return `${header}\n\n${formatted}`;
+}
dots/pi/agent/extensions/search/index.ts
@@ -0,0 +1,265 @@
+/**
+ * Multi-backend Search Extension for Pi
+ *
+ * Provides web search, GitHub code search, and Stack Overflow search tools.
+ * Web search uses multiple backends with automatic fallback:
+ *   1. SearXNG (self-hosted, primary)
+ *   2. ddgr CLI (DuckDuckGo via CLI, if installed)
+ *   3. DuckDuckGo Instant Answer API (always available fallback)
+ *
+ * Configuration via environment variables:
+ *   SEARXNG_URL - SearXNG instance URL (default: https://search.sbr.pm)
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+import {
+  SearXNGBackend,
+  DuckDuckGoBackend,
+  DdgrBackend,
+  searchWithFallback,
+  formatResults,
+  type SearchBackend,
+} from "./backends";
+
+function truncate(text: string, maxLength: number): string {
+  if (text.length <= maxLength) return text;
+  return text.slice(0, maxLength) + "...";
+}
+
+export default function (pi: ExtensionAPI) {
+  // Configure backends
+  const searxngUrl = process.env.SEARXNG_URL || "https://search.sbr.pm";
+
+  const allBackends: Record<string, SearchBackend> = {
+    searxng: new SearXNGBackend(searxngUrl),
+    ddgr: new DdgrBackend((cmd, args, opts) => pi.exec(cmd, args, opts)),
+    duckduckgo: new DuckDuckGoBackend(),
+  };
+
+  // Active backends in priority order (all enabled by default)
+  let activeBackendNames = ["searxng", "ddgr", "duckduckgo"];
+
+  // Helper to get current active backends
+  const getActiveBackends = (): SearchBackend[] =>
+    activeBackendNames.map((name) => allBackends[name]).filter(Boolean);
+
+  /**
+   * /search-backend command — view or change the active search backends
+   *
+   * Usage:
+   *   /search-backend              — show current backend order
+   *   /search-backend ddgr         — use only ddgr
+   *   /search-backend searxng ddgr — use searxng with ddgr fallback
+   *   /search-backend all          — reset to all backends
+   */
+  pi.registerCommand("search-backend", {
+    description: "View or set web search backends (e.g., /search-backend ddgr)",
+    handler: async (args, ctx) => {
+      if (!args || !args.trim()) {
+        const status = activeBackendNames
+          .map((name, i) => `  ${i + 1}. ${name}`)
+          .join("\n");
+        ctx.ui.notify(
+          `Active search backends:\n${status}\n\nAvailable: ${Object.keys(allBackends).join(", ")}, all`,
+          "info",
+        );
+        return;
+      }
+
+      const requested = args.trim().toLowerCase().split(/\s+/);
+
+      if (requested.length === 1 && requested[0] === "all") {
+        activeBackendNames = Object.keys(allBackends);
+        ctx.ui.notify(`Search backends reset to: ${activeBackendNames.join(" → ")}`, "info");
+        return;
+      }
+
+      const invalid = requested.filter((name) => !allBackends[name]);
+      if (invalid.length > 0) {
+        ctx.ui.notify(
+          `Unknown backend(s): ${invalid.join(", ")}\nAvailable: ${Object.keys(allBackends).join(", ")}`,
+          "error",
+        );
+        return;
+      }
+
+      activeBackendNames = requested;
+      ctx.ui.notify(`Search backends set to: ${activeBackendNames.join(" → ")}`, "info");
+    },
+  });
+
+  /**
+   * Web Search Tool - Multi-backend with automatic fallback
+   */
+  pi.registerTool({
+    name: "web_search",
+    label: "Web Search",
+    description:
+      "Performs a web search using the DuckDuckGo API to answer questions or find information.",
+    parameters: Type.Object({
+      query: Type.String({ description: "The search query." }),
+    }),
+    async execute(_toolCallId, params, signal) {
+      try {
+        const { results, backend, errors } = await searchWithFallback(
+          getActiveBackends(),
+          params.query,
+          5,
+          signal,
+        );
+
+        if (results.length === 0) {
+          const errorInfo = errors.length > 0
+            ? `\nBackend errors:\n${errors.join("\n")}`
+            : "";
+          return {
+            content: [{ type: "text", text: `No results found for "${params.query}".${errorInfo}` }],
+          };
+        }
+
+        const output = formatResults(results, backend);
+        return {
+          content: [{ type: "text", text: truncate(output, 4000) }],
+          details: { backend, resultCount: results.length },
+        };
+      } catch (e: any) {
+        return {
+          content: [{ type: "text", text: `Search error: ${e.message}` }],
+        };
+      }
+    },
+  });
+
+  /**
+   * GitHub Code Search Tool
+   */
+  pi.registerTool({
+    name: "github_search",
+    label: "GitHub Code Search",
+    description:
+      "Searches for code snippets on GitHub. You can specify a repository with 'repo:owner/repo'.",
+    parameters: Type.Object({
+      query: Type.String({
+        description:
+          "The code search query. Examples: 'readFile repo:owner/project', 'my_function language:python'",
+      }),
+    }),
+    async execute(_toolCallId, params, signal) {
+      try {
+        const tokenResult = await pi.exec("gh", ["auth", "token"], { signal });
+        const token = tokenResult.stdout.trim();
+
+        if (tokenResult.code !== 0 || !token) {
+          return {
+            content: [{
+              type: "text",
+              text: `Error fetching GitHub token. Is 'gh' installed and authenticated?\nExit Code: ${tokenResult.code}\nStderr: ${tokenResult.stderr || "(empty)"}`,
+            }],
+          };
+        }
+
+        const url = `https://api.github.com/search/code?q=${encodeURIComponent(params.query)}`;
+        const response = await fetch(url, {
+          headers: {
+            Accept: "application/vnd.github.v3+json",
+            Authorization: `Bearer ${token}`,
+          },
+          signal,
+        });
+
+        if (response.status === 401) {
+          return {
+            content: [{
+              type: "text",
+              text: "Error: GitHub API returned 401 (Unauthorized). The token may be invalid or expired.",
+            }],
+          };
+        }
+        if (!response.ok) {
+          return {
+            content: [{
+              type: "text",
+              text: `Error: GitHub API returned status ${response.status}`,
+            }],
+          };
+        }
+
+        const data = await response.json();
+        const items = data.items || [];
+
+        if (items.length === 0) {
+          return {
+            content: [{ type: "text", text: "No code results found on GitHub." }],
+          };
+        }
+
+        const results = items
+          .slice(0, 5)
+          .map(
+            (item: any) =>
+              `Path: ${item.path} (Repo: ${item.repository.full_name})\nURL: ${item.html_url}`,
+          )
+          .join("\n\n");
+
+        return { content: [{ type: "text", text: truncate(results, 4000) }] };
+      } catch (e: any) {
+        return {
+          content: [{ type: "text", text: `GitHub search error: ${e.message}` }],
+        };
+      }
+    },
+  });
+
+  /**
+   * Stack Overflow Search Tool
+   */
+  pi.registerTool({
+    name: "stack_overflow_search",
+    label: "Stack Overflow Search",
+    description: "Searches for questions on Stack Overflow.",
+    parameters: Type.Object({
+      query: Type.String({
+        description: "The question to search for.",
+      }),
+    }),
+    async execute(_toolCallId, params, signal) {
+      try {
+        const url = `https://api.stackexchange.com/2.3/search?order=desc&sort=relevance&site=stackoverflow&intitle=${encodeURIComponent(params.query)}`;
+        const response = await fetch(url, { signal });
+
+        if (!response.ok) {
+          return {
+            content: [{
+              type: "text",
+              text: `Error: Stack Exchange API returned status ${response.status}`,
+            }],
+          };
+        }
+
+        const data = await response.json();
+        const items = data.items || [];
+
+        if (items.length === 0) {
+          return {
+            content: [{ type: "text", text: "No questions found on Stack Overflow." }],
+          };
+        }
+
+        const results = items
+          .slice(0, 5)
+          .map(
+            (item: any) =>
+              `[Score: ${item.score}] ${item.is_answered ? "(Answered)" : ""} ${item.title}\nURL: ${item.link}`,
+          )
+          .join("\n---\n");
+
+        return { content: [{ type: "text", text: truncate(results, 4000) }] };
+      } catch (e: any) {
+        return {
+          content: [{ type: "text", text: `Stack Overflow search error: ${e.message}` }],
+        };
+      }
+    },
+  });
+}
dots/pi/agent/extensions/search.ts
@@ -1,169 +0,0 @@
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { Type } from "@sinclair/typebox";
-
-// Helper function to truncate text to avoid flooding the context
-function truncate(text: string, maxLength: number): string {
-  if (text.length <= maxLength) {
-    return text;
-  }
-  return text.slice(0, maxLength) + "...";
-}
-
-export default function (pi: ExtensionAPI) {
-  /**
-   * Web Search Tool (DuckDuckGo)
-   *
-   * This tool performs a web search using the DuckDuckGo Instant Answer API.
-   * It's a simple, no-API-key-required way to get quick summaries.
-   */
-  pi.registerTool({
-    name: "web_search",
-    label: "Web Search",
-    description: "Performs a web search using the DuckDuckGo API to answer questions or find information.",
-    parameters: Type.Object({
-      query: Type.String({ description: "The search query." }),
-    }),
-    async execute(toolCallId, params, signal) {
-      try {
-        const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(params.query)}&format=json&no_html=1`;
-        
-        const response = await fetch(url, { signal });
-        if (!response.ok) {
-          return { content: [{ type: "text", text: `Error: API request failed with status ${response.status}` }] };
-        }
-
-        const data = await response.json();
-        
-        // Extract the most useful information: Abstract, and first 3 related topics
-        const abstract = data.AbstractText ? `Abstract: ${data.AbstractText}` : "";
-        const results = (data.RelatedTopics || [])
-          .slice(0, 3)
-          .map((topic: any) => `- ${topic.Text}: ${topic.FirstURL}`)
-          .join("\n");
-        
-        const combined = `${abstract}\n\nRelated Results:\n${results}`;
-        
-        if (!combined.trim()) {
-           return { content: [{ type: "text", text: "No results found." }] };
-        }
-
-        return { content: [{ type: "text", text: truncate(combined, 4000) }] };
-
-      } catch (e: any) {
-        return { content: [{ type: "text", text: `An unexpected error occurred: ${e.message}` }] };
-      }
-    },
-  });
-
-  /**
-   * GitHub Code Search Tool
-   *
-   * This tool uses the GitHub API to search for code within repositories.
-   */
-  pi.registerTool({
-    name: "github_search",
-    label: "GitHub Code Search",
-    description: "Searches for code snippets on GitHub. You can specify a repository with 'repo:owner/repo'.",
-    parameters: Type.Object({
-      query: Type.String({ description: "The code search query. Examples: 'readFile repo:owner/project', 'my_function language:python'" }),
-    }),
-    async execute(toolCallId, params, signal) {
-      try {
-        // Delegate token retrieval to an external command.
-        const tokenResult = await pi.exec("gh", ["auth", "token"], { signal });
-        const token = tokenResult.stdout.trim();
-
-        if (tokenResult.code !== 0 || !token) {
-          return {
-            content: [{
-              type: "text",
-              text: `Error fetching GitHub token. Is 'passage' installed and is 'github.com/token' set?\nExit Code: ${tokenResult.code}\nStderr: ${tokenResult.stderr || '(empty)'}`
-            }]
-          };
-        }
-
-        const url = `https://api.github.com/search/code?q=${encodeURIComponent(params.query)}`;
-        
-        const response = await fetch(url, {
-          headers: {
-            "Accept": "application/vnd.github.v3+json",
-            "Authorization": `Bearer ${token}`,
-          },
-          signal,
-        });
-
-        if (response.status === 401) {
-            return { content: [{ type: "text", text: "Error: GitHub API request failed with status 401 (Unauthorized). The token provided by 'passage' may be invalid or expired." }] };
-        }
-        if (!response.ok) {
-          return { content: [{ type: "text", text: `Error: GitHub API request failed with status ${response.status}` }] };
-        }
-
-        const data = await response.json();
-        const items = data.items || [];
-
-        if (items.length === 0) {
-          return { content: [{ type: "text", text: "No code results found on GitHub." }] };
-        }
-
-        // Format the first 5 results
-        const results = items
-          .slice(0, 5)
-          .map((item: any) => `Path: ${item.path} (Repo: ${item.repository.full_name})\nURL: ${item.html_url}`)
-          .join("\n---\n");
-        
-        return { content: [{ type: "text", text: truncate(results, 4000) }] };
-
-      } catch (e: any) {
-        // Handle errors from pi.exec (e.g., command not found)
-        if (e.name === 'ExecError') {
-            return { content: [{ type: 'text', text: `Failed to execute token command: ${e.message}` }] };
-        }
-        return { content: [{ type: "text", text: `An unexpected error occurred: ${e.message}` }] };
-      }
-    },
-  });
-
-  /**
-   * Stack Overflow Search Tool
-   *
-   * This tool searches for questions on Stack Overflow.
-   */
-  pi.registerTool({
-    name: "stack_overflow_search",
-    label: "Stack Overflow Search",
-    description: "Searches for questions on Stack Overflow.",
-    parameters: Type.Object({
-      query: Type.String({ description: "The question to search for." }),
-    }),
-    async execute(toolCallId, params, signal) {
-      try {
-        const url = `https://api.stackexchange.com/2.3/search?order=desc&sort=relevance&site=stackoverflow&intitle=${encodeURIComponent(params.query)}`;
-        
-        const response = await fetch(url, { signal });
-        if (!response.ok) {
-          return { content: [{ type: "text", text: `Error: Stack Exchange API request failed with status ${response.status}` }] };
-        }
-
-        const data = await response.json();
-        const items = data.items || [];
-
-        if (items.length === 0) {
-          return { content: [{ type: "text", text: "No questions found on Stack Overflow." }] };
-        }
-
-        // Format the first 5 results
-        const results = items
-          .slice(0, 5)
-          .map((item: any) => `[Score: ${item.score}] ${item.is_answered ? "(Answered)" : ""} ${item.title}\nURL: ${item.link}`)
-          .join("\n---\n");
-        
-        return { content: [{ type: "text", text: truncate(results, 4000) }] };
-
-      } catch (e: any) {
-        return { content: [{ type: "text", text: `An unexpected error occurred: ${e.message}` }] };
-      }
-    },
-  });
-}
home/common/dev/ai.nix
@@ -95,6 +95,7 @@ in
     # whisper-cpp
     # python312Packages.google-generativeai
     # python313Packages.google-generativeai
+    ddgr # DuckDuckGo CLI for Pi search extension fallback
     repomix
     # editors
     master.code-cursor