diff --git a/.pi/agent/extensions/web-search/src/providers/exa.test.ts b/.pi/agent/extensions/web-search/src/providers/exa.test.ts new file mode 100644 index 0000000..03d9190 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/providers/exa.test.ts @@ -0,0 +1,110 @@ +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", + }, + ]); +}); diff --git a/.pi/agent/extensions/web-search/src/providers/exa.ts b/.pi/agent/extensions/web-search/src/providers/exa.ts new file mode 100644 index 0000000..1f7311d --- /dev/null +++ b/.pi/agent/extensions/web-search/src/providers/exa.ts @@ -0,0 +1,124 @@ +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, + }; + }, + }; +} diff --git a/.pi/agent/extensions/web-search/src/providers/types.ts b/.pi/agent/extensions/web-search/src/providers/types.ts new file mode 100644 index 0000000..5c28515 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/providers/types.ts @@ -0,0 +1,57 @@ +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; +} 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 c9110d5..4330d4f 100644 --- a/docs/superpowers/plans/2026-04-09-web-search-tools.md +++ b/docs/superpowers/plans/2026-04-09-web-search-tools.md @@ -384,7 +384,7 @@ git -C /home/alex/dotfiles commit -m "test: add web search config loader" - 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** +- [x] **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: @@ -501,7 +501,7 @@ test("createExaProvider fetch defaults to text and preserves per-url failures", }); ``` -- [ ] **Step 2: Run the Exa adapter tests to verify they fail** +- [x] **Step 2: Run the Exa adapter tests to verify they fail** Run: @@ -512,7 +512,7 @@ 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** +- [x] **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: @@ -705,7 +705,7 @@ export function createExaProvider( } ``` -- [ ] **Step 4: Run the Exa adapter tests to verify they pass** +- [x] **Step 4: Run the Exa adapter tests to verify they pass** Run: @@ -716,7 +716,7 @@ npx tsx --test src/providers/exa.test.ts Expected: `PASS` for both tests. -- [ ] **Step 5: Commit the provider layer** +- [x] **Step 5: Commit the provider layer** Run: