diff --git a/.pi/agent/extensions/web-search/index.ts b/.pi/agent/extensions/web-search/index.ts new file mode 100644 index 0000000..b2fc5d1 --- /dev/null +++ b/.pi/agent/extensions/web-search/index.ts @@ -0,0 +1,28 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { loadWebSearchConfig } from "./src/config.ts"; +import { createExaProvider } from "./src/providers/exa.ts"; +import type { WebProvider } from "./src/providers/types.ts"; +import { createWebSearchTool } from "./src/tools/web-search.ts"; + +async function resolveProvider(providerName?: string): Promise { + const config = await loadWebSearchConfig(); + const selectedName = providerName ?? config.defaultProviderName; + const providerConfig = config.providersByName.get(selectedName); + + if (!providerConfig) { + throw new Error( + `Unknown web-search provider \"${selectedName}\". Configured providers: ${[...config.providersByName.keys()].join(", ")}`, + ); + } + + switch (providerConfig.type) { + case "exa": + return createExaProvider(providerConfig); + default: + throw new Error(`Unsupported web-search provider type: ${(providerConfig as { type: string }).type}`); + } +} + +export default function webSearch(pi: ExtensionAPI) { + pi.registerTool(createWebSearchTool({ resolveProvider })); +} diff --git a/.pi/agent/extensions/web-search/src/tools/web-search.test.ts b/.pi/agent/extensions/web-search/src/tools/web-search.test.ts new file mode 100644 index 0000000..6fae80c --- /dev/null +++ b/.pi/agent/extensions/web-search/src/tools/web-search.test.ts @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createWebSearchTool } from "./web-search.ts"; + +test("web_search executes metadata-only search through the resolved provider", async () => { + let resolvedProviderName: string | undefined; + + const tool = createWebSearchTool({ + resolveProvider: async (providerName) => { + resolvedProviderName = providerName; + return { + name: "exa-main", + type: "exa", + async search(request) { + assert.equal(request.query, "exa docs"); + return { + providerName: "exa-main", + results: [ + { + title: "Exa Docs", + url: "https://exa.ai/docs", + score: 0.98, + }, + ], + }; + }, + async fetch() { + throw new Error("not used"); + }, + }; + }, + }); + + const result = await tool.execute("tool-1", { query: "exa docs" }, undefined, undefined, undefined); + + assert.equal(resolvedProviderName, undefined); + assert.match((result.content[0] as { text: string }).text, /Exa Docs/); + assert.equal((result.details as { results: Array<{ url: string }> }).results[0]?.url, "https://exa.ai/docs"); +}); + +test("web_search rejects a blank query before resolving a provider", async () => { + const tool = createWebSearchTool({ + resolveProvider: async () => { + throw new Error("should not resolve provider for a blank query"); + }, + }); + + await assert.rejects( + () => tool.execute("tool-1", { query: " " }, undefined, undefined, undefined), + /non-empty query/, + ); +}); diff --git a/.pi/agent/extensions/web-search/src/tools/web-search.ts b/.pi/agent/extensions/web-search/src/tools/web-search.ts new file mode 100644 index 0000000..8a2f133 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/tools/web-search.ts @@ -0,0 +1,68 @@ +import { Text } from "@mariozechner/pi-tui"; +import { formatSearchOutput } from "../format.ts"; +import type { NormalizedSearchResponse, WebProvider } from "../providers/types.ts"; +import { WebSearchParamsSchema, type WebSearchParams } from "../schema.ts"; + +interface SearchToolDeps { + resolveProvider(providerName?: string): Promise; +} + +function normalizeSearchQuery(query: string) { + const trimmed = query.trim(); + if (!trimmed) { + throw new Error("web_search requires a non-empty query."); + } + return trimmed; +} + +export function createWebSearchTool({ resolveProvider }: SearchToolDeps) { + return { + name: "web_search", + label: "Web Search", + description: "Search the web through the configured provider. Returns result metadata by default.", + parameters: WebSearchParamsSchema, + + async execute(_toolCallId: string, params: WebSearchParams) { + const query = normalizeSearchQuery(params.query); + const provider = await resolveProvider(params.provider); + const response = await provider.search({ + query, + limit: params.limit, + includeDomains: params.includeDomains, + excludeDomains: params.excludeDomains, + startPublishedDate: params.startPublishedDate, + endPublishedDate: params.endPublishedDate, + category: params.category, + provider: params.provider, + }); + + return { + content: [{ type: "text" as const, text: formatSearchOutput(response) }], + details: response, + }; + }, + + renderCall(args: Partial, theme: any) { + let text = theme.fg("toolTitle", theme.bold("web_search ")); + text += theme.fg("muted", args.query ?? ""); + return new Text(text, 0, 0); + }, + + renderResult(result: { details?: NormalizedSearchResponse }, _options: unknown, theme: any) { + const details = result.details; + if (!details) { + return new Text("", 0, 0); + } + + const lines = [ + `${theme.fg("success", "✓ ")}${details.results.length} result${details.results.length === 1 ? "" : "s"} via ${details.providerName}`, + ]; + + for (const [index, item] of details.results.slice(0, 5).entries()) { + lines.push(` ${theme.fg("muted", `${index + 1}.`)} ${item.title ?? "(untitled)"} ${theme.fg("dim", item.url)}`); + } + + return new Text(lines.join("\n"), 0, 0); + }, + }; +} diff --git a/docs/superpowers/plans/2026-04-09-web-search-tools.md b/docs/superpowers/plans/2026-04-09-web-search-tools.md index ec2730f..907e32b 100644 --- a/docs/superpowers/plans/2026-04-09-web-search-tools.md +++ b/docs/superpowers/plans/2026-04-09-web-search-tools.md @@ -922,7 +922,7 @@ git -C /home/alex/dotfiles commit -m "test: add web tool output formatting" - Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts` - Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts` -- [ ] **Step 1: Write the failing `web_search` tool tests** +- [x] **Step 1: Write the failing `web_search` tool tests** Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts` with this content: @@ -981,7 +981,7 @@ test("web_search rejects a blank query before resolving a provider", async () => }); ``` -- [ ] **Step 2: Run the `web_search` tests to verify they fail** +- [x] **Step 2: Run the `web_search` tests to verify they fail** Run: @@ -992,7 +992,7 @@ npx tsx --test src/tools/web-search.test.ts Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./web-search.ts`. -- [ ] **Step 3: Write the minimal `web_search` tool implementation** +- [x] **Step 3: Write the minimal `web_search` tool implementation** Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts` with this content: @@ -1066,7 +1066,7 @@ export function createWebSearchTool({ resolveProvider }: SearchToolDeps) { } ``` -- [ ] **Step 4: Run the `web_search` tests to verify they pass** +- [x] **Step 4: Run the `web_search` tests to verify they pass** Run: @@ -1077,7 +1077,7 @@ npx tsx --test src/tools/web-search.test.ts Expected: `PASS` for both tests. -- [ ] **Step 5: Create a real Exa config file for the live smoke test** +- [x] **Step 5: Create a real Exa config file for the live smoke test** Run: @@ -1106,7 +1106,7 @@ unset EXA_KEY Expected: `/home/alex/.pi/agent/web-search.json` exists with mode `600`. -- [ ] **Step 6: Add a temporary entrypoint that registers `web_search` and manually verify it live** +- [x] **Step 6: Add a temporary entrypoint that registers `web_search` and manually verify it live** Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/index.ts` with this content: @@ -1162,7 +1162,7 @@ Expected manual checks: - the result contains metadata-only output, not fetched page text - at least one result includes `https://exa.ai/docs` -- [ ] **Step 7: Commit the `web_search` tool** +- [x] **Step 7: Commit the `web_search` tool** Run: