import type { ResolvedPiWebConfig, SearchCommands, SearchQuery, SearchResponseLength } from "./types.ts"; const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; const EXA_TOTAL_RESULT_LIMITS: Record = { short: 5, medium: 10, long: 15, }; function resultQuota(total: number, queryCount: number, queryIndex: number): number { return Math.floor(total / queryCount) + (queryIndex < total % queryCount ? 1 : 0); } export interface ExaSearchResult { id: string; url: string; title: string | null; publishedDate?: string; author?: string | null; text?: string; highlights?: string[]; } interface ExaSearchResponse { requestId?: string; results: ExaSearchResult[]; } export interface ExaSearchExecution { report: string; rawOutput: string; referenceIds: string[]; resultCount: number; requests: number; } export interface ExaClientOptions { signal?: AbortSignal; timeoutMs: number; fetchImpl?: typeof fetch; now?: () => number; } function abortError(message: string): Error { const error = new Error(message); error.name = "AbortError"; return error; } function combinedSignal(signal: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } { const timeout = new AbortController(); const timer = setTimeout(() => timeout.abort(abortError(`Exa search timed out after ${timeoutMs}ms`)), timeoutMs); return { signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal, dispose: () => clearTimeout(timer), }; } function boundedError(body: string): string { if (!body.trim()) return "empty response body"; try { const parsed = JSON.parse(body) as Record; const error = typeof parsed.error === "string" ? parsed.error : undefined; const tag = typeof parsed.tag === "string" ? parsed.tag : undefined; const requestId = typeof parsed.requestId === "string" ? parsed.requestId : undefined; return [error, tag && `tag=${tag}`, requestId && `requestId=${requestId}`].filter(Boolean).join(" ยท ") || "unknown Exa error"; } catch { return body.replace(/\s+/g, " ").slice(0, 500); } } function parseResponse(body: string): ExaSearchResponse { let parsed: unknown; try { parsed = JSON.parse(body); } catch (error) { throw new Error(`Exa returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`); } if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { results?: unknown }).results)) { throw new Error("Exa response is missing results"); } const value = parsed as { requestId?: unknown; results: unknown[] }; const results: ExaSearchResult[] = value.results.map((item, index) => { if (!item || typeof item !== "object") throw new Error(`Exa result ${index + 1} is invalid`); const result = item as Record; if (typeof result.id !== "string" || typeof result.url !== "string") { throw new Error(`Exa result ${index + 1} is missing id or url`); } let sourceUrl: URL; try { sourceUrl = new URL(result.url); } catch { throw new Error(`Exa result ${index + 1} has an invalid URL`); } if (sourceUrl.protocol !== "http:" && sourceUrl.protocol !== "https:") { throw new Error(`Exa result ${index + 1} has an unsupported URL protocol`); } return { id: result.id, url: sourceUrl.toString(), title: typeof result.title === "string" ? result.title : null, ...(typeof result.publishedDate === "string" ? { publishedDate: result.publishedDate } : {}), ...(typeof result.author === "string" || result.author === null ? { author: result.author as string | null } : {}), ...(typeof result.text === "string" ? { text: result.text } : {}), ...(Array.isArray(result.highlights) ? { highlights: result.highlights.filter((part): part is string => typeof part === "string") } : {}), }; }); return { ...(typeof value.requestId === "string" ? { requestId: value.requestId } : {}), results, }; } function normalizedDomains(values: string[] | undefined): string[] | undefined { if (!values) return undefined; const domains = [...new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))]; return domains.length > 0 ? domains : undefined; } function queryDomains(query: SearchQuery, configured: string[] | undefined): string[] | undefined { const requested = normalizedDomains(query.domains); const allowed = normalizedDomains(configured); if (!requested) return allowed; if (!allowed) return requested; const allowedSet = new Set(allowed); return requested.filter((domain) => allowedSet.has(domain)); } function startPublishedDate(recency: number | undefined, now: () => number): string | undefined { if (recency === undefined) return undefined; return new Date(now() - recency * 24 * 60 * 60 * 1_000).toISOString(); } function assertExaCommands(commands: SearchCommands): SearchQuery[] { const unsupported = Object.keys(commands).filter((key) => key !== "search_query" && key !== "response_length"); if (unsupported.length > 0) { throw new Error(`Exa mode supports search_query only; unsupported operation(s): ${unsupported.join(", ")}. Use web_fetch for direct URLs.`); } const queries = commands.search_query; if (!queries || queries.length === 0) throw new Error("Exa mode requires at least one search_query"); return queries; } function sourceId(result: ExaSearchResult): string { return `exa:${result.id}`; } function cleanText(value: string): string { return value.replace(/\u0000/g, "").trim(); } function formatResult(result: ExaSearchResult, index: number, query: string): string { const title = cleanText(result.title || result.url).replace(/\s+/g, " ") || result.url; const highlights = result.highlights?.map(cleanText).filter(Boolean).join("\n\n"); const excerpt = highlights || (result.text ? cleanText(result.text) : "(No text excerpt returned by Exa.)"); return [ `## Result ${index}`, "", `### ${title}`, "", excerpt, "", `Query: ${query}`, ...(result.publishedDate ? [`Published: ${result.publishedDate}`] : []), ...(result.author ? [`Author: ${result.author}`] : []), `Source ID: \`${sourceId(result).replace(/`/g, "\\`")}\``, `Source URL: <${result.url}>`, ].join("\n"); } export async function executeExaSearch( commands: SearchCommands, config: ResolvedPiWebConfig, options: ExaClientOptions, ): Promise { if (config.provider !== "exa" || !config.exaApiKey) throw new Error("Exa search is not configured with an API key"); const apiKey = config.exaApiKey; const queries = assertExaCommands(commands); const fetchImpl = options.fetchImpl ?? fetch; const now = options.now ?? Date.now; const totalResultLimit = EXA_TOTAL_RESULT_LIMITS[commands.response_length ?? "medium"]; const operation = combinedSignal(options.signal, options.timeoutMs); try { const responses = await Promise.all(queries.map(async (query, queryIndex) => { const domains = queryDomains(query, config.allowedDomains); if (query.domains && config.allowedDomains && domains?.length === 0) { return { query, request: null, response: { results: [] } satisfies ExaSearchResponse }; } const publishedAfter = startPublishedDate(query.recency, now); const request = { query: query.q, type: "auto", numResults: resultQuota(totalResultLimit, queries.length, queryIndex), ...(domains ? { includeDomains: domains } : {}), ...(publishedAfter ? { startPublishedDate: publishedAfter } : {}), contents: { highlights: { query: query.q, maxCharacters: 1_200 }, }, }; const response = await fetchImpl(EXA_SEARCH_ENDPOINT, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", "x-api-key": apiKey, }, body: JSON.stringify(request), signal: operation.signal, }); const body = await response.text(); if (!response.ok) throw new Error(`Exa search failed with HTTP ${response.status}: ${boundedError(body)}`); return { query, request, response: parseResponse(body) }; })); let index = 0; const reportItems: string[] = []; const referenceIds: string[] = []; const seenUrls = new Set(); outer: for (const entry of responses) { for (const result of entry.response.results) { if (index >= totalResultLimit) break outer; if (seenUrls.has(result.url)) continue; seenUrls.add(result.url); index += 1; reportItems.push(formatResult(result, index, entry.query.q)); referenceIds.push(sourceId(result)); } } const report = reportItems.length > 0 ? reportItems.join("\n\n") : "No Exa results matched the configured queries and domain filters."; return { report, rawOutput: JSON.stringify({ searches: responses }, null, 2), referenceIds: [...new Set(referenceIds)], resultCount: index, requests: responses.filter((entry) => entry.request !== null).length, }; } catch (error) { if (operation.signal.aborted) { throw operation.signal.reason instanceof Error ? operation.signal.reason : abortError("Exa search cancelled"); } throw error; } finally { operation.dispose(); } }