Commit 454227a820ec
Changed files (5)
dots
pi
agent
extensions
home
common
dev
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