From 5e1315a20a4ccaa7a9c9dc044754a2e63b48ab34 Mon Sep 17 00:00:00 2001 From: alex wiesner Date: Thu, 9 Apr 2026 11:07:12 +0100 Subject: [PATCH] test: add web search config loader --- .pi/agent/extensions/web-search/package.json | 25 + .../extensions/web-search/src/config.test.ts | 71 + .pi/agent/extensions/web-search/src/config.ts | 100 ++ .pi/agent/extensions/web-search/src/schema.ts | 45 + .../plans/2026-04-09-web-search-tools.md | 1498 +++++++++++++++++ 5 files changed, 1739 insertions(+) create mode 100644 .pi/agent/extensions/web-search/package.json create mode 100644 .pi/agent/extensions/web-search/src/config.test.ts create mode 100644 .pi/agent/extensions/web-search/src/config.ts create mode 100644 .pi/agent/extensions/web-search/src/schema.ts create mode 100644 docs/superpowers/plans/2026-04-09-web-search-tools.md diff --git a/.pi/agent/extensions/web-search/package.json b/.pi/agent/extensions/web-search/package.json new file mode 100644 index 0000000..95fa5c3 --- /dev/null +++ b/.pi/agent/extensions/web-search/package.json @@ -0,0 +1,25 @@ +{ + "name": "pi-web-search-extension", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "tsx --test src/**/*.test.ts" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@sinclair/typebox": "^0.34.49", + "exa-js": "^2.11.0" + }, + "devDependencies": { + "@mariozechner/pi-coding-agent": "^0.66.1", + "@mariozechner/pi-tui": "^0.66.1", + "@types/node": "^25.5.2", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + } +} diff --git a/.pi/agent/extensions/web-search/src/config.test.ts b/.pi/agent/extensions/web-search/src/config.test.ts new file mode 100644 index 0000000..be0addd --- /dev/null +++ b/.pi/agent/extensions/web-search/src/config.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadWebSearchConfig, WebSearchConfigError } from "./config.ts"; + +async function writeTempConfig(contents: unknown) { + const dir = await mkdtemp(join(tmpdir(), "pi-web-search-config-")); + const file = join(dir, "web-search.json"); + const body = typeof contents === "string" ? contents : JSON.stringify(contents, null, 2); + await writeFile(file, body, "utf8"); + return file; +} + +test("loadWebSearchConfig returns a normalized default provider and provider lookup", async () => { + const file = await writeTempConfig({ + defaultProvider: "exa-main", + providers: [ + { + name: "exa-main", + type: "exa", + apiKey: "exa-test-key", + options: { + defaultSearchLimit: 7, + defaultFetchTextMaxCharacters: 9000, + }, + }, + ], + }); + + const config = await loadWebSearchConfig(file); + + assert.equal(config.defaultProviderName, "exa-main"); + assert.equal(config.defaultProvider.name, "exa-main"); + assert.equal(config.providersByName.get("exa-main")?.apiKey, "exa-test-key"); + assert.equal(config.providers[0]?.options?.defaultSearchLimit, 7); +}); + +test("loadWebSearchConfig rejects a missing default provider target", async () => { + const file = await writeTempConfig({ + defaultProvider: "missing", + providers: [ + { + name: "exa-main", + type: "exa", + apiKey: "exa-test-key", + }, + ], + }); + + await assert.rejects( + () => loadWebSearchConfig(file), + (error) => + error instanceof WebSearchConfigError && + /defaultProvider \"missing\"/.test(error.message), + ); +}); + +test("loadWebSearchConfig rejects a missing file with a helpful example message", async () => { + const file = join(tmpdir(), "pi-web-search-does-not-exist.json"); + + await assert.rejects( + () => loadWebSearchConfig(file), + (error) => + error instanceof WebSearchConfigError && + error.message.includes(file) && + error.message.includes('"defaultProvider"') && + error.message.includes('"providers"'), + ); +}); diff --git a/.pi/agent/extensions/web-search/src/config.ts b/.pi/agent/extensions/web-search/src/config.ts new file mode 100644 index 0000000..56895d3 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/config.ts @@ -0,0 +1,100 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { Value } from "@sinclair/typebox/value"; +import { WebSearchConfigSchema, type ExaProviderConfig, type WebSearchConfig } from "./schema.ts"; + +export interface ResolvedWebSearchConfig { + path: string; + defaultProviderName: string; + defaultProvider: ExaProviderConfig; + providers: ExaProviderConfig[]; + providersByName: Map; +} + +export class WebSearchConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "WebSearchConfigError"; + } +} + +export function getDefaultWebSearchConfigPath() { + return join(homedir(), ".pi", "agent", "web-search.json"); +} + +function exampleConfigSnippet() { + return JSON.stringify( + { + defaultProvider: "exa-main", + providers: [ + { + name: "exa-main", + type: "exa", + apiKey: "exa_...", + }, + ], + }, + null, + 2, + ); +} + +export function normalizeWebSearchConfig(config: WebSearchConfig, path: string): ResolvedWebSearchConfig { + const providersByName = new Map(); + + for (const provider of config.providers) { + if (!provider.apiKey.trim()) { + throw new WebSearchConfigError(`Provider \"${provider.name}\" in ${path} is missing a literal apiKey.`); + } + if (providersByName.has(provider.name)) { + throw new WebSearchConfigError(`Duplicate provider name \"${provider.name}\" in ${path}.`); + } + providersByName.set(provider.name, provider); + } + + const defaultProvider = providersByName.get(config.defaultProvider); + if (!defaultProvider) { + throw new WebSearchConfigError( + `defaultProvider \"${config.defaultProvider}\" does not match any configured provider in ${path}.`, + ); + } + + return { + path, + defaultProviderName: config.defaultProvider, + defaultProvider, + providers: [...providersByName.values()], + providersByName, + }; +} + +export async function loadWebSearchConfig(path = getDefaultWebSearchConfigPath()) { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new WebSearchConfigError( + `Missing web-search config at ${path}.\nCreate ${path} with contents like:\n${exampleConfigSnippet()}`, + ); + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new WebSearchConfigError(`Invalid JSON in ${path}: ${(error as Error).message}`); + } + + if (!Value.Check(WebSearchConfigSchema, parsed)) { + const [firstError] = [...Value.Errors(WebSearchConfigSchema, parsed)]; + throw new WebSearchConfigError( + `Invalid web-search config at ${path}: ${firstError?.path ?? "/"} ${firstError?.message ?? "failed validation"}`, + ); + } + + return normalizeWebSearchConfig(parsed as WebSearchConfig, path); +} diff --git a/.pi/agent/extensions/web-search/src/schema.ts b/.pi/agent/extensions/web-search/src/schema.ts new file mode 100644 index 0000000..2930be2 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/schema.ts @@ -0,0 +1,45 @@ +import { Type, type Static } from "@sinclair/typebox"; + +export const ProviderOptionsSchema = Type.Object({ + defaultSearchLimit: Type.Optional(Type.Integer({ minimum: 1 })), + defaultFetchTextMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })), + defaultFetchHighlightsMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })), +}); + +export const ExaProviderConfigSchema = Type.Object({ + name: Type.String({ minLength: 1 }), + type: Type.Literal("exa"), + apiKey: Type.String({ minLength: 1 }), + options: Type.Optional(ProviderOptionsSchema), +}); + +export const WebSearchConfigSchema = Type.Object({ + defaultProvider: Type.String({ minLength: 1 }), + providers: Type.Array(ExaProviderConfigSchema, { minItems: 1 }), +}); + +export const WebSearchParamsSchema = Type.Object({ + query: Type.String({ minLength: 1, description: "Search query" }), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 25 })), + includeDomains: Type.Optional(Type.Array(Type.String())), + excludeDomains: Type.Optional(Type.Array(Type.String())), + startPublishedDate: Type.Optional(Type.String()), + endPublishedDate: Type.Optional(Type.String()), + category: Type.Optional(Type.String()), + provider: Type.Optional(Type.String()), +}); + +export const WebFetchParamsSchema = Type.Object({ + urls: Type.Array(Type.String(), { minItems: 1 }), + text: Type.Optional(Type.Boolean()), + highlights: Type.Optional(Type.Boolean()), + summary: Type.Optional(Type.Boolean()), + textMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })), + provider: Type.Optional(Type.String()), +}); + +export type ProviderOptions = Static; +export type ExaProviderConfig = Static; +export type WebSearchConfig = Static; +export type WebSearchParams = Static; +export type WebFetchParams = Static; diff --git a/docs/superpowers/plans/2026-04-09-web-search-tools.md b/docs/superpowers/plans/2026-04-09-web-search-tools.md new file mode 100644 index 0000000..c9110d5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-web-search-tools.md @@ -0,0 +1,1498 @@ +# Web Search Tools Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add modular `web_search` and `web_fetch` pi tools backed by Exa, with runtime config loaded from `~/.pi/agent/web-search.json` and an internal provider adapter layer for future providers. + +**Architecture:** Build a package-style extension in `.pi/agent/extensions/web-search/` so it can carry `exa-js` and local tests cleanly. Keep config parsing, Exa request mapping, and output formatting in pure modules covered by `node:test` via `tsx`, then layer pi-specific tool registration and renderers on top. + +**Tech Stack:** Pi extensions API, `exa-js`, `@sinclair/typebox`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, Node `node:test`, `tsx`, TypeScript + +--- + +## File Structure + +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/package.json` + - Local extension package manifest, npm scripts, and dependencies. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/index.ts` + - Extension entrypoint that registers `web_search` and `web_fetch`. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/schema.ts` + - TypeBox schemas for config and tool parameter validation. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/config.ts` + - Loads and validates `~/.pi/agent/web-search.json`, normalizes the provider list, and resolves the default provider. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/config.test.ts` + - Tests config parsing, normalization, and helpful error messages. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/types.ts` + - Provider-agnostic request/response interfaces used by both tools. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.ts` + - Exa-backed implementation of the provider interface. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts` + - Tests generic request → Exa call mapping and per-URL fetch failure handling. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/format.ts` + - Shared compact text formatting and truncation helpers. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/format.test.ts` + - Tests metadata-only search formatting, text truncation, and batch failure formatting. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts` + - `web_search` tool factory, execute path, and tool renderers. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts` + - Tests non-empty query validation and happy-path execution with a fake provider. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.ts` + - `web_fetch` tool factory, input normalization, execute path, and tool renderers. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts` + - Tests URL normalization, default text mode, and invalid URL rejection. +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/extension.test.ts` + - Smoke test that the extension entrypoint registers both tool names. +- Modify: `/home/alex/dotfiles/docs/superpowers/plans/2026-04-09-web-search-tools.md` + - Check boxes as work progresses. + +## Environment Notes + +- `/home/alex/.pi/agent/extensions` resolves to `/home/alex/dotfiles/.pi/agent/extensions`, so editing files in the repo updates the live global pi extension path. +- The config file for this feature lives outside the repo at `~/.pi/agent/web-search.json`. +- Use `/reload` inside pi after file edits to reload the extension. +- Run package-local tests from `/home/alex/dotfiles/.pi/agent/extensions/web-search`. + +### Task 1: Scaffold the extension package and build the config loader first + +**Files:** +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/package.json` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/config.test.ts` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/schema.ts` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/config.ts` +- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/config.test.ts` + +- [x] **Step 1: Create the extension package scaffold and install dependencies** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/package.json` with this content: + +```json +{ + "name": "pi-web-search-extension", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "tsx --test src/**/*.test.ts" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@sinclair/typebox": "^0.34.49", + "exa-js": "^2.11.0" + }, + "devDependencies": { + "@mariozechner/pi-coding-agent": "^0.66.1", + "@mariozechner/pi-tui": "^0.66.1", + "@types/node": "^25.5.2", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + } +} +``` + +Then run: + +```bash +mkdir -p /home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers +mkdir -p /home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npm install +``` + +Expected: `node_modules/` is created and `npm install` exits with code `0`. + +- [x] **Step 2: Write the failing config tests** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/config.test.ts` with this content: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadWebSearchConfig, WebSearchConfigError } from "./config.ts"; + +async function writeTempConfig(contents: unknown) { + const dir = await mkdtemp(join(tmpdir(), "pi-web-search-config-")); + const file = join(dir, "web-search.json"); + const body = typeof contents === "string" ? contents : JSON.stringify(contents, null, 2); + await writeFile(file, body, "utf8"); + return file; +} + +test("loadWebSearchConfig returns a normalized default provider and provider lookup", async () => { + const file = await writeTempConfig({ + defaultProvider: "exa-main", + providers: [ + { + name: "exa-main", + type: "exa", + apiKey: "exa-test-key", + options: { + defaultSearchLimit: 7, + defaultFetchTextMaxCharacters: 9000 + } + } + ] + }); + + const config = await loadWebSearchConfig(file); + + assert.equal(config.defaultProviderName, "exa-main"); + assert.equal(config.defaultProvider.name, "exa-main"); + assert.equal(config.providersByName.get("exa-main")?.apiKey, "exa-test-key"); + assert.equal(config.providers[0]?.options?.defaultSearchLimit, 7); +}); + +test("loadWebSearchConfig rejects a missing default provider target", async () => { + const file = await writeTempConfig({ + defaultProvider: "missing", + providers: [ + { + name: "exa-main", + type: "exa", + apiKey: "exa-test-key" + } + ] + }); + + await assert.rejects( + () => loadWebSearchConfig(file), + (error) => + error instanceof WebSearchConfigError && + /defaultProvider \"missing\"/.test(error.message), + ); +}); + +test("loadWebSearchConfig rejects a missing file with a helpful example message", async () => { + const file = join(tmpdir(), "pi-web-search-does-not-exist.json"); + + await assert.rejects( + () => loadWebSearchConfig(file), + (error) => + error instanceof WebSearchConfigError && + error.message.includes(file) && + error.message.includes('"defaultProvider"') && + error.message.includes('"providers"'), + ); +}); +``` + +- [x] **Step 3: Run the config tests to verify they fail** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/config.test.ts +``` + +Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./config.ts`. + +- [x] **Step 4: Write the minimal schema and config loader implementation** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/schema.ts` with this content: + +```ts +import { Type, type Static } from "@sinclair/typebox"; + +export const ProviderOptionsSchema = Type.Object({ + defaultSearchLimit: Type.Optional(Type.Integer({ minimum: 1 })), + defaultFetchTextMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })), + defaultFetchHighlightsMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })), +}); + +export const ExaProviderConfigSchema = Type.Object({ + name: Type.String({ minLength: 1 }), + type: Type.Literal("exa"), + apiKey: Type.String({ minLength: 1 }), + options: Type.Optional(ProviderOptionsSchema), +}); + +export const WebSearchConfigSchema = Type.Object({ + defaultProvider: Type.String({ minLength: 1 }), + providers: Type.Array(ExaProviderConfigSchema, { minItems: 1 }), +}); + +export const WebSearchParamsSchema = Type.Object({ + query: Type.String({ minLength: 1, description: "Search query" }), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 25 })), + includeDomains: Type.Optional(Type.Array(Type.String())), + excludeDomains: Type.Optional(Type.Array(Type.String())), + startPublishedDate: Type.Optional(Type.String()), + endPublishedDate: Type.Optional(Type.String()), + category: Type.Optional(Type.String()), + provider: Type.Optional(Type.String()), +}); + +export const WebFetchParamsSchema = Type.Object({ + urls: Type.Array(Type.String(), { minItems: 1 }), + text: Type.Optional(Type.Boolean()), + highlights: Type.Optional(Type.Boolean()), + summary: Type.Optional(Type.Boolean()), + textMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })), + provider: Type.Optional(Type.String()), +}); + +export type ProviderOptions = Static; +export type ExaProviderConfig = Static; +export type WebSearchConfig = Static; +export type WebSearchParams = Static; +export type WebFetchParams = Static; +``` + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/config.ts` with this content: + +```ts +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { Value } from "@sinclair/typebox/value"; +import { WebSearchConfigSchema, type ExaProviderConfig, type WebSearchConfig } from "./schema.ts"; + +export interface ResolvedWebSearchConfig { + path: string; + defaultProviderName: string; + defaultProvider: ExaProviderConfig; + providers: ExaProviderConfig[]; + providersByName: Map; +} + +export class WebSearchConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "WebSearchConfigError"; + } +} + +export function getDefaultWebSearchConfigPath() { + return join(homedir(), ".pi", "agent", "web-search.json"); +} + +function exampleConfigSnippet() { + return JSON.stringify( + { + defaultProvider: "exa-main", + providers: [ + { + name: "exa-main", + type: "exa", + apiKey: "exa_..." + } + ] + }, + null, + 2, + ); +} + +export function normalizeWebSearchConfig(config: WebSearchConfig, path: string): ResolvedWebSearchConfig { + const providersByName = new Map(); + + for (const provider of config.providers) { + if (!provider.apiKey.trim()) { + throw new WebSearchConfigError(`Provider \"${provider.name}\" in ${path} is missing a literal apiKey.`); + } + if (providersByName.has(provider.name)) { + throw new WebSearchConfigError(`Duplicate provider name \"${provider.name}\" in ${path}.`); + } + providersByName.set(provider.name, provider); + } + + const defaultProvider = providersByName.get(config.defaultProvider); + if (!defaultProvider) { + throw new WebSearchConfigError( + `defaultProvider \"${config.defaultProvider}\" does not match any configured provider in ${path}.`, + ); + } + + return { + path, + defaultProviderName: config.defaultProvider, + defaultProvider, + providers: [...providersByName.values()], + providersByName, + }; +} + +export async function loadWebSearchConfig(path = getDefaultWebSearchConfigPath()) { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new WebSearchConfigError( + `Missing web-search config at ${path}.\nCreate ${path} with contents like:\n${exampleConfigSnippet()}`, + ); + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new WebSearchConfigError(`Invalid JSON in ${path}: ${(error as Error).message}`); + } + + if (!Value.Check(WebSearchConfigSchema, parsed)) { + const [firstError] = [...Value.Errors(WebSearchConfigSchema, parsed)]; + throw new WebSearchConfigError( + `Invalid web-search config at ${path}: ${firstError?.path ?? "/"} ${firstError?.message ?? "failed validation"}`, + ); + } + + return normalizeWebSearchConfig(parsed as WebSearchConfig, path); +} +``` + +- [x] **Step 5: Run the config tests to verify they pass** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/config.test.ts +``` + +Expected: `PASS` for all three tests. + +- [x] **Step 6: Commit the config layer** + +Run: + +```bash +git -C /home/alex/dotfiles add \ + .pi/agent/extensions/web-search/package.json \ + .pi/agent/extensions/web-search/src/schema.ts \ + .pi/agent/extensions/web-search/src/config.ts \ + .pi/agent/extensions/web-search/src/config.test.ts \ + docs/superpowers/plans/2026-04-09-web-search-tools.md + +git -C /home/alex/dotfiles commit -m "test: add web search config loader" +``` + +### Task 2: Add the provider-agnostic types and the Exa adapter + +**Files:** +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/types.ts` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.ts` +- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts` + +- [ ] **Step 1: Write the failing Exa adapter tests** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts` with this content: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { createExaProvider } from "./exa.ts"; + +const baseConfig = { + name: "exa-main", + type: "exa" as const, + apiKey: "exa-test-key", + options: { + defaultSearchLimit: 7, + defaultFetchTextMaxCharacters: 9000, + defaultFetchHighlightsMaxCharacters: 1200, + }, +}; + +test("createExaProvider maps generic search requests to Exa search with contents disabled", async () => { + let captured: { query: string; options: Record } | undefined; + + const provider = createExaProvider(baseConfig, () => ({ + async search(query, options) { + captured = { query, options }; + return { + requestId: "req-search-1", + searchTime: 123, + results: [ + { + id: "doc-1", + title: "Exa Docs", + url: "https://exa.ai/docs", + publishedDate: "2026-04-09", + author: "Exa", + score: 0.98, + }, + ], + }; + }, + async getContents() { + throw new Error("not used"); + }, + })); + + const result = await provider.search({ + query: "exa docs", + includeDomains: ["exa.ai"], + }); + + assert.deepEqual(captured, { + query: "exa docs", + options: { + contents: false, + numResults: 7, + includeDomains: ["exa.ai"], + excludeDomains: undefined, + startPublishedDate: undefined, + endPublishedDate: undefined, + category: undefined, + }, + }); + assert.equal(result.providerName, "exa-main"); + assert.equal(result.results[0]?.url, "https://exa.ai/docs"); +}); + +test("createExaProvider fetch defaults to text and preserves per-url failures", async () => { + const calls: Array<{ urls: string[]; options: Record }> = []; + + const provider = createExaProvider(baseConfig, () => ({ + async search() { + throw new Error("not used"); + }, + async getContents(urls, options) { + const requestUrls = Array.isArray(urls) ? urls : [urls]; + calls.push({ urls: requestUrls, options }); + + if (requestUrls[0] === "https://bad.example") { + throw new Error("429 rate limited"); + } + + return { + requestId: `req-${calls.length}`, + results: [ + { + url: requestUrls[0], + title: "Fetched page", + text: "Fetched body", + }, + ], + }; + }, + })); + + const result = await provider.fetch({ + urls: ["https://good.example", "https://bad.example"], + }); + + assert.equal((calls[0]?.options.text as { maxCharacters: number }).maxCharacters, 9000); + assert.deepEqual(result.results, [ + { + url: "https://good.example", + title: "Fetched page", + text: "Fetched body", + highlights: undefined, + summary: undefined, + }, + { + url: "https://bad.example", + title: null, + error: "429 rate limited", + }, + ]); +}); +``` + +- [ ] **Step 2: Run the Exa adapter tests to verify they fail** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/providers/exa.test.ts +``` + +Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./exa.ts`. + +- [ ] **Step 3: Write the provider interface and Exa adapter implementation** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/types.ts` with this content: + +```ts +export interface NormalizedSearchRequest { + query: string; + limit?: number; + includeDomains?: string[]; + excludeDomains?: string[]; + startPublishedDate?: string; + endPublishedDate?: string; + category?: string; + provider?: string; +} + +export interface NormalizedSearchResult { + id?: string; + title: string | null; + url: string; + publishedDate?: string; + author?: string; + score?: number; +} + +export interface NormalizedSearchResponse { + providerName: string; + requestId?: string; + searchTime?: number; + results: NormalizedSearchResult[]; +} + +export interface NormalizedFetchRequest { + urls: string[]; + text?: boolean; + highlights?: boolean; + summary?: boolean; + textMaxCharacters?: number; + provider?: string; +} + +export interface NormalizedFetchResult { + url: string; + title: string | null; + text?: string; + highlights?: string[]; + summary?: string; + error?: string; +} + +export interface NormalizedFetchResponse { + providerName: string; + requestIds?: string[]; + results: NormalizedFetchResult[]; +} + +export interface WebProvider { + name: string; + type: string; + search(request: NormalizedSearchRequest): Promise; + fetch(request: NormalizedFetchRequest): Promise; +} +``` + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.ts` with this content: + +```ts +import Exa from "exa-js"; +import type { ExaProviderConfig } from "../schema.ts"; +import type { + NormalizedFetchRequest, + NormalizedFetchResponse, + NormalizedSearchRequest, + NormalizedSearchResponse, + WebProvider, +} from "./types.ts"; + +export interface ExaClientLike { + search(query: string, options?: Record): Promise; + getContents(urls: string[] | string, options?: Record): Promise; +} + +export type ExaClientFactory = (apiKey: string) => ExaClientLike; + +export function buildSearchOptions(config: ExaProviderConfig, request: NormalizedSearchRequest) { + return { + contents: false, + numResults: request.limit ?? config.options?.defaultSearchLimit ?? 5, + includeDomains: request.includeDomains, + excludeDomains: request.excludeDomains, + startPublishedDate: request.startPublishedDate, + endPublishedDate: request.endPublishedDate, + category: request.category, + }; +} + +export function buildFetchOptions(config: ExaProviderConfig, request: NormalizedFetchRequest) { + const text = request.text ?? (!request.highlights && !request.summary); + + return { + ...(text + ? { + text: { + maxCharacters: request.textMaxCharacters ?? config.options?.defaultFetchTextMaxCharacters ?? 12000, + }, + } + : {}), + ...(request.highlights + ? { + highlights: { + maxCharacters: config.options?.defaultFetchHighlightsMaxCharacters ?? 1000, + }, + } + : {}), + ...(request.summary ? { summary: true } : {}), + }; +} + +export function createExaProvider( + config: ExaProviderConfig, + createClient: ExaClientFactory = (apiKey) => new Exa(apiKey) as unknown as ExaClientLike, +): WebProvider { + const client = createClient(config.apiKey); + + return { + name: config.name, + type: config.type, + + async search(request: NormalizedSearchRequest): Promise { + const response = await client.search(request.query, buildSearchOptions(config, request)); + return { + providerName: config.name, + requestId: response.requestId, + searchTime: response.searchTime, + results: (response.results ?? []).map((item: any) => ({ + id: item.id, + title: item.title ?? null, + url: item.url, + publishedDate: item.publishedDate, + author: item.author, + score: item.score, + })), + }; + }, + + async fetch(request: NormalizedFetchRequest): Promise { + const requestIds: string[] = []; + const options = buildFetchOptions(config, request); + + const results = await Promise.all( + request.urls.map(async (url) => { + try { + const response = await client.getContents([url], options); + if (response.requestId) { + requestIds.push(response.requestId); + } + + const item = response.results?.[0]; + if (!item) { + return { + url, + title: null, + error: "No content returned", + }; + } + + return { + url: item.url ?? url, + title: item.title ?? null, + text: typeof item.text === "string" ? item.text : undefined, + highlights: Array.isArray(item.highlights) ? item.highlights : undefined, + summary: typeof item.summary === "string" ? item.summary : undefined, + }; + } catch (error) { + return { + url, + title: null, + error: (error as Error).message, + }; + } + }), + ); + + return { + providerName: config.name, + requestIds, + results, + }; + }, + }; +} +``` + +- [ ] **Step 4: Run the Exa adapter tests to verify they pass** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/providers/exa.test.ts +``` + +Expected: `PASS` for both tests. + +- [ ] **Step 5: Commit the provider layer** + +Run: + +```bash +git -C /home/alex/dotfiles add \ + .pi/agent/extensions/web-search/src/providers/types.ts \ + .pi/agent/extensions/web-search/src/providers/exa.ts \ + .pi/agent/extensions/web-search/src/providers/exa.test.ts \ + docs/superpowers/plans/2026-04-09-web-search-tools.md + +git -C /home/alex/dotfiles commit -m "test: add exa web provider adapter" +``` + +### Task 3: Add the shared formatter and truncation helpers + +**Files:** +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/format.test.ts` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/format.ts` +- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/format.test.ts` + +- [ ] **Step 1: Write the failing formatter tests** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/format.test.ts` with this content: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { formatFetchOutput, formatSearchOutput, truncateText } from "./format.ts"; + +test("formatSearchOutput renders a compact metadata-only list", () => { + const output = formatSearchOutput({ + providerName: "exa-main", + results: [ + { + title: "Exa Docs", + url: "https://exa.ai/docs", + publishedDate: "2026-04-09", + author: "Exa", + score: 0.98, + }, + ], + }); + + assert.match(output, /Found 1 web result via exa-main:/); + assert.match(output, /Exa Docs/); + assert.match(output, /https:\/\/exa.ai\/docs/); +}); + +test("truncateText shortens long fetch bodies with an ellipsis", () => { + assert.equal(truncateText("abcdef", 4), "abc…"); + assert.equal(truncateText("abc", 10), "abc"); +}); + +test("formatFetchOutput includes both successful and failed URLs", () => { + const output = formatFetchOutput( + { + providerName: "exa-main", + results: [ + { + url: "https://good.example", + title: "Good", + text: "This is a very long body that should be truncated in the final output.", + }, + { + url: "https://bad.example", + title: null, + error: "429 rate limited", + }, + ], + }, + { maxCharactersPerResult: 20 }, + ); + + assert.match(output, /Status: ok/); + assert.match(output, /Status: failed/); + assert.match(output, /429 rate limited/); + assert.match(output, /This is a very long…/); +}); +``` + +- [ ] **Step 2: Run the formatter tests to verify they fail** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/format.test.ts +``` + +Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./format.ts`. + +- [ ] **Step 3: Write the minimal formatter implementation** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/format.ts` with this content: + +```ts +import type { NormalizedFetchResponse, NormalizedSearchResponse } from "./providers/types.ts"; + +export function truncateText(text: string, maxCharacters = 4000) { + if (text.length <= maxCharacters) { + return text; + } + return `${text.slice(0, Math.max(0, maxCharacters - 1))}…`; +} + +export function formatSearchOutput(response: NormalizedSearchResponse) { + if (response.results.length === 0) { + return `No web results via ${response.providerName}.`; + } + + const lines = [ + `Found ${response.results.length} web result${response.results.length === 1 ? "" : "s"} via ${response.providerName}:`, + ]; + + for (const [index, result] of response.results.entries()) { + lines.push(`${index + 1}. ${result.title ?? "(untitled)"}`); + lines.push(` URL: ${result.url}`); + + const meta = [result.publishedDate, result.author].filter(Boolean); + if (meta.length > 0) { + lines.push(` Meta: ${meta.join(" • ")}`); + } + + if (typeof result.score === "number") { + lines.push(` Score: ${result.score}`); + } + } + + return lines.join("\n"); +} + +export interface FetchFormatOptions { + maxCharactersPerResult?: number; +} + +export function formatFetchOutput(response: NormalizedFetchResponse, options: FetchFormatOptions = {}) { + const maxCharactersPerResult = options.maxCharactersPerResult ?? 4000; + const lines = [ + `Fetched ${response.results.length} URL${response.results.length === 1 ? "" : "s"} via ${response.providerName}:`, + ]; + + for (const result of response.results) { + lines.push(""); + lines.push(`URL: ${result.url}`); + + if (result.error) { + lines.push("Status: failed"); + lines.push(`Error: ${result.error}`); + continue; + } + + lines.push("Status: ok"); + if (result.title) { + lines.push(`Title: ${result.title}`); + } + if (result.summary) { + lines.push(`Summary: ${result.summary}`); + } + if (result.highlights?.length) { + lines.push("Highlights:"); + for (const highlight of result.highlights) { + lines.push(`- ${highlight}`); + } + } + if (result.text) { + lines.push("Text:"); + lines.push(truncateText(result.text, maxCharactersPerResult)); + } + } + + return lines.join("\n"); +} +``` + +- [ ] **Step 4: Run the formatter tests to verify they pass** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/format.test.ts +``` + +Expected: `PASS` for all three tests. + +- [ ] **Step 5: Commit the formatter layer** + +Run: + +```bash +git -C /home/alex/dotfiles add \ + .pi/agent/extensions/web-search/src/format.ts \ + .pi/agent/extensions/web-search/src/format.test.ts \ + docs/superpowers/plans/2026-04-09-web-search-tools.md + +git -C /home/alex/dotfiles commit -m "test: add web tool output formatting" +``` + +### Task 4: Add the `web_search` tool and verify live search + +**Files:** +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts` +- 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** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts` with this content: + +```ts +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/, + ); +}); +``` + +- [ ] **Step 2: Run the `web_search` tests to verify they fail** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +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** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts` with this content: + +```ts +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 provider = await resolveProvider(params.provider); + const response = await provider.search({ + query: normalizeSearchQuery(params.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); + }, + }; +} +``` + +- [ ] **Step 4: Run the `web_search` tests to verify they pass** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +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** + +Run: + +```bash +read -rsp "Exa API key: " EXA_KEY && printf '\n' +mkdir -p /home/alex/.pi/agent +cat > /home/alex/.pi/agent/web-search.json < { + 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 })); +} +``` + +Then run: + +```bash +cd /home/alex/dotfiles +pi +``` + +Inside pi, run this sequence: + +```text +/reload +Search the web for Exa docs using the web_search tool. +``` + +Expected manual checks: + +- the agent has a `web_search` tool available +- the tool call row shows `web_search` and the query text +- 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** + +Run: + +```bash +git -C /home/alex/dotfiles add \ + .pi/agent/extensions/web-search/index.ts \ + .pi/agent/extensions/web-search/src/tools/web-search.ts \ + .pi/agent/extensions/web-search/src/tools/web-search.test.ts \ + docs/superpowers/plans/2026-04-09-web-search-tools.md + +git -C /home/alex/dotfiles commit -m "feat: add web_search tool" +``` + +### Task 5: Add `web_fetch`, register both tools, and verify batch fetch behavior + +**Files:** +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.ts` +- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/extension.test.ts` +- Modify: `/home/alex/dotfiles/.pi/agent/extensions/web-search/index.ts` +- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts` +- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/extension.test.ts` + +- [ ] **Step 1: Write the failing `web_fetch` and extension registration tests** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts` with this content: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { createWebFetchTool } from "./web-fetch.ts"; + +test("web_fetch prepareArguments folds a single url into urls", () => { + const tool = createWebFetchTool({ + resolveProvider: async () => { + throw new Error("not used"); + }, + }); + + assert.deepEqual(tool.prepareArguments?.({ url: "https://exa.ai/docs" }), { + url: "https://exa.ai/docs", + urls: ["https://exa.ai/docs"], + }); +}); + +test("web_fetch defaults to text and returns formatted fetch results", async () => { + let capturedRequest: Record | undefined; + + const tool = createWebFetchTool({ + resolveProvider: async () => ({ + name: "exa-main", + type: "exa", + async search() { + throw new Error("not used"); + }, + async fetch(request) { + capturedRequest = request as unknown as Record; + return { + providerName: "exa-main", + results: [ + { + url: "https://exa.ai/docs", + title: "Docs", + text: "Body", + }, + ], + }; + }, + }), + }); + + const result = await tool.execute("tool-1", { urls: ["https://exa.ai/docs"] }, undefined, undefined, undefined); + + assert.equal(capturedRequest?.text, true); + assert.match((result.content[0] as { text: string }).text, /Body/); + assert.equal((result.details as { results: Array<{ title: string }> }).results[0]?.title, "Docs"); +}); + +test("web_fetch rejects malformed URLs", async () => { + const tool = createWebFetchTool({ + resolveProvider: async () => { + throw new Error("should not resolve provider for invalid URLs"); + }, + }); + + await assert.rejects( + () => tool.execute("tool-1", { urls: ["not-a-url"] }, undefined, undefined, undefined), + /Invalid URL/, + ); +}); +``` + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/extension.test.ts` with this content: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import webSearchExtension from "../index.ts"; + +test("the extension entrypoint registers both web_search and web_fetch", () => { + const registeredTools: string[] = []; + + webSearchExtension({ + registerTool(tool: { name: string }) { + registeredTools.push(tool.name); + }, + } as any); + + assert.deepEqual(registeredTools, ["web_search", "web_fetch"]); +}); +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/tools/web-fetch.test.ts src/extension.test.ts +``` + +Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./web-fetch.ts`. + +- [ ] **Step 3: Write the minimal `web_fetch` tool implementation** + +Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.ts` with this content: + +```ts +import { Text } from "@mariozechner/pi-tui"; +import { formatFetchOutput } from "../format.ts"; +import type { NormalizedFetchResponse, WebProvider } from "../providers/types.ts"; +import { WebFetchParamsSchema, type WebFetchParams } from "../schema.ts"; + +interface FetchToolDeps { + resolveProvider(providerName?: string): Promise; +} + +function normalizeUrl(value: string) { + try { + return new URL(value).toString(); + } catch { + throw new Error(`Invalid URL: ${value}`); + } +} + +function normalizeFetchParams(params: WebFetchParams & { url?: string }) { + const urls = (Array.isArray(params.urls) ? params.urls : []).map(normalizeUrl); + if (urls.length === 0) { + throw new Error("web_fetch requires at least one URL."); + } + + return { + urls, + text: params.text ?? (!params.highlights && !params.summary), + highlights: params.highlights ?? false, + summary: params.summary ?? false, + textMaxCharacters: params.textMaxCharacters, + provider: params.provider, + }; +} + +export function createWebFetchTool({ resolveProvider }: FetchToolDeps) { + return { + name: "web_fetch", + label: "Web Fetch", + description: "Fetch page contents through the configured provider. Returns text by default.", + parameters: WebFetchParamsSchema, + + prepareArguments(args: unknown) { + if (!args || typeof args !== "object") { + return args; + } + + const input = args as { url?: unknown; urls?: unknown }; + if (typeof input.url === "string" && !Array.isArray(input.urls)) { + return { + ...input, + urls: [input.url], + }; + } + + return args; + }, + + async execute(_toolCallId: string, params: WebFetchParams) { + const normalized = normalizeFetchParams(params as WebFetchParams & { url?: string }); + const provider = await resolveProvider(normalized.provider); + const response = await provider.fetch(normalized); + + return { + content: [{ type: "text" as const, text: formatFetchOutput(response) }], + details: response, + }; + }, + + renderCall(args: Partial & { url?: string }, theme: any) { + const urls = Array.isArray(args.urls) ? args.urls : typeof args.url === "string" ? [args.url] : []; + let text = theme.fg("toolTitle", theme.bold("web_fetch ")); + text += theme.fg("muted", `${urls.length} url${urls.length === 1 ? "" : "s"}`); + return new Text(text, 0, 0); + }, + + renderResult(result: { details?: NormalizedFetchResponse }, _options: unknown, theme: any) { + const details = result.details; + if (!details) { + return new Text("", 0, 0); + } + + const failed = details.results.filter((item) => item.error).length; + const succeeded = details.results.length - failed; + return new Text( + `${theme.fg("success", "✓ ")}${succeeded} ok${failed ? ` • ${theme.fg("warning", `${failed} failed`)}` : ""}`, + 0, + 0, + ); + }, + }; +} +``` + +- [ ] **Step 4: Replace the temporary entrypoint with the final entrypoint that registers both tools** + +Replace `/home/alex/dotfiles/.pi/agent/extensions/web-search/index.ts` with this content: + +```ts +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 { createWebFetchTool } from "./src/tools/web-fetch.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 })); + pi.registerTool(createWebFetchTool({ resolveProvider })); +} +``` + +- [ ] **Step 5: Run the `web_fetch` and entrypoint smoke tests to verify they pass** + +Run: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npx tsx --test src/tools/web-fetch.test.ts src/extension.test.ts +``` + +Expected: `PASS` for all four tests. + +- [ ] **Step 6: Reload pi and manually verify search + single fetch + batch fetch** + +Run: + +```bash +cd /home/alex/dotfiles +pi +``` + +Inside pi, run this sequence: + +```text +/reload +Search the web for Exa docs using the web_search tool. +Then fetch https://exa.ai/docs using the web_fetch tool. +Then fetch https://exa.ai/docs and https://exa.ai using the web_fetch tool. +``` + +Expected manual checks: + +- both `web_search` and `web_fetch` are available +- `web_search` still returns metadata only by default +- `web_fetch` returns page text by default +- `web_fetch` accepts multiple URLs in one call +- batch fetch output is clearly separated per URL +- if one URL fails, the result still includes the successful URL output and a failure section for the bad URL + +- [ ] **Step 7: Commit the completed web tools extension** + +Run: + +```bash +git -C /home/alex/dotfiles add \ + .pi/agent/extensions/web-search/index.ts \ + .pi/agent/extensions/web-search/src/tools/web-fetch.ts \ + .pi/agent/extensions/web-search/src/tools/web-fetch.test.ts \ + .pi/agent/extensions/web-search/src/extension.test.ts \ + docs/superpowers/plans/2026-04-09-web-search-tools.md + +git -C /home/alex/dotfiles commit -m "feat: add web search tools extension" +``` + +## Final Verification Checklist + +Run these before claiming the work is complete: + +```bash +cd /home/alex/dotfiles/.pi/agent/extensions/web-search +npm test +``` + +Expected: `PASS` for all config, provider, formatter, tool, and entrypoint tests. + +Then run pi from `/home/alex/dotfiles`, use `/reload`, and manually verify: + +1. `web_search` returns metadata-only results +2. `web_search` handles a configured Exa provider successfully +3. `web_fetch` returns text by default +4. `web_fetch` accepts a single URL +5. `web_fetch` accepts multiple URLs +6. invalid URLs fail before the provider is called +7. missing or invalid `~/.pi/agent/web-search.json` produces a helpful error +8. both tools remain available after `/reload` + +If any of those checks fail, do not mark the work complete.