import { truncateForModelWithTempFile } from "./tool-output.ts"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Text } from "@earendil-works/pi-tui"; import { Type, type Static } from "typebox"; import { loadWebfetchConfig } from "./config.ts"; import { readResponseBytes, transformContent, validateAndNormalizeUrl } from "./core.ts"; import { formatSearchResponse, searchWithFailover } from "./search.ts"; import { narrowMarkdown } from "./narrow.ts"; const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB const DEFAULT_WEBFETCH_TIMEOUT_SECONDS = 30; const MAX_WEBFETCH_TIMEOUT_SECONDS = 120; const WEBSEARCH_TIMEOUT_MS = 60_000; const DEFAULT_WEBSEARCH_RESULTS = 8; const MAX_WEBSEARCH_RESULTS = 100; const WEBSEARCH_BASE_URL = "https://mcp.exa.ai"; const WEBSEARCH_ENDPOINT = "/mcp"; const WEBFETCH_PARAMS = Type.Object({ url: Type.String({ description: "The URL to fetch content from" }), format: Type.Optional( StringEnum(["markdown", "raw"] as const, { description: 'The format to return content in. "markdown" is the default. Use "raw" only when you need the original response body (HTML, JSON, or plain text).', default: "markdown", }), ), timeout: Type.Optional( Type.Number({ description: `Optional timeout in seconds (max ${MAX_WEBFETCH_TIMEOUT_SECONDS})`, minimum: 1, maximum: MAX_WEBFETCH_TIMEOUT_SECONDS, }), ), objective: Type.Optional( Type.String({ description: "Narrow the extracted markdown to content relevant to this objective. " + "The full page is fetched and converted to markdown first, then a local LLM pass " + "filters to only the relevant sections. Only applies when format=markdown. " + "Increases latency. Falls back to full markdown if the model is unavailable.", }), ), }); type WebFetchParams = Static; const WEBSEARCH_PARAMS = Type.Object({ query: Type.String({ description: "Natural-language web search query" }), numResults: Type.Optional( Type.Integer({ description: `Number of search results to return (default: ${DEFAULT_WEBSEARCH_RESULTS}, max: ${MAX_WEBSEARCH_RESULTS})`, minimum: 1, maximum: MAX_WEBSEARCH_RESULTS, }), ), type: Type.Optional( StringEnum(["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"] as const, { description: "Search mode. Use auto by default, fast/instant for latency-sensitive lookups, and deep variants for multi-step research.", default: "auto", }), ), category: Type.Optional( StringEnum( ["company", "people", "research paper", "news", "personal site", "financial report"] as const, { description: "Optional specialized search category" }, ), ), includeDomains: Type.Optional( Type.Array(Type.String(), { description: "Only return results from these domains", maxItems: 1200, }), ), excludeDomains: Type.Optional( Type.Array(Type.String(), { description: "Exclude results from these domains", maxItems: 1200, }), ), startPublishedDate: Type.Optional( Type.String({ description: "Only return results published after this ISO 8601 date" }), ), endPublishedDate: Type.Optional( Type.String({ description: "Only return results published before this ISO 8601 date" }), ), content: Type.Optional( StringEnum(["highlights", "text", "none"] as const, { description: "Content returned per result. Highlights are token-efficient (default); text returns fuller page content; none returns metadata only.", default: "highlights", }), ), maxCharacters: Type.Optional( Type.Integer({ description: "Optional per-result character limit for highlights or text", minimum: 1, }), ), maxAgeHours: Type.Optional( Type.Integer({ description: "Maximum cached-content age in hours. Omit for normal fallback crawling, 0 to always livecrawl, -1 for cache only.", minimum: -1, }), ), moderation: Type.Optional( Type.Boolean({ description: "Filter unsafe or inappropriate results" }), ), }); export type WebSearchParams = Static; const WEBFETCH_DESCRIPTION = `Fetch a specific URL and return agent-readable content. Use webfetch when the user gives a URL to inspect, quote, summarize, debug, or use as context. Prefer format="markdown" unless the task specifically needs the original response body. Arguments: - url: required fully-qualified http:// or https:// URL. - format: "markdown" (default) or "raw". Use raw only for original HTML, JSON, or plain text. - timeout: request timeout in seconds, default ${DEFAULT_WEBFETCH_TIMEOUT_SECONDS}, max ${MAX_WEBFETCH_TIMEOUT_SECONDS}. - objective: optional focus query for markdown output. Use this when only part of a long page is relevant, e.g. "authentication options", "POST request example", or "pricing limits". The page is fetched normally, then narrowed to objective-relevant markdown. Ignore objective for format="raw". Behavior: - GitHub blob URLs return raw file content; tree URLs return directory listings; repo URLs return README content. - Image URLs return an image attachment. - Non-GitHub web pages return markdown by default, with best-effort cleanup and truncation. - Responses over 5MB are rejected. Output is truncated to ${formatSize(DEFAULT_MAX_BYTES)} / ${DEFAULT_MAX_LINES} lines when needed.`; const WEBSEARCH_DESCRIPTION = `Search the web for current or unknown information and return a consolidated result snippet. Use websearch for open-ended questions, recent information, discovery, or when the user asks about something outside the model's knowledge. If the user provides a specific URL, use webfetch instead. Arguments: - query: natural-language search query. Include the current year (${new Date().getFullYear()}) for recent/news/current-event queries when useful. - numResults: result count, default ${DEFAULT_WEBSEARCH_RESULTS}, max ${MAX_WEBSEARCH_RESULTS}. - type: "auto" (default), "fast", "instant", "deep-lite", "deep", or "deep-reasoning". - category: optional specialized index for companies, people, research papers, news, personal sites, or financial reports. - includeDomains/excludeDomains: optional domain filters. - startPublishedDate/endPublishedDate: optional ISO 8601 publication-date filters. - content: "highlights" (default), "text", or "none". Prefer highlights unless full page content is necessary. - maxCharacters: optional per-result content limit. - maxAgeHours: omit for normal fallback crawling, 0 for always-live, or -1 for cache only. - moderation: optionally filter unsafe results. Provider routing is internal: Exa handles the full schema, Jina is used as capability-aware failover for ordinary searches, and Exa's rate-limited basic MCP search is the no-credential fallback. Output is truncated to ${formatSize(DEFAULT_MAX_BYTES)} / ${DEFAULT_MAX_LINES} lines when needed.`; // --- GitHub URL handling --- type LineRange = { start: number; end: number }; type GitHubBlob = { type: "blob"; owner: string; repo: string; branch: string; path: string; lineRange: LineRange | null; }; type GitHubTree = { type: "tree"; owner: string; repo: string; branch: string; path: string }; type GitHubPull = { type: "pull"; owner: string; repo: string; number: number }; type GitHubIssue = { type: "issue"; owner: string; repo: string; number: number }; type GitHubRepo = { type: "repo"; owner: string; repo: string }; type GitHubUrl = GitHubBlob | GitHubTree | GitHubPull | GitHubIssue | GitHubRepo; const LINE_CONTEXT = 10; function parseLineFragment(fragment: string): LineRange | null { const single = fragment.match(/^L(\d+)$/); if (single) { const n = parseInt(single[1], 10); return { start: n, end: n }; } const range = fragment.match(/^L(\d+)-L(\d+)$/); if (range) return { start: parseInt(range[1], 10), end: parseInt(range[2], 10) }; return null; } function extractLines(content: string, start: number, end: number): string { const lines = content.split("\n"); const from = Math.max(1, start - LINE_CONTEXT); const to = Math.min(lines.length, end + LINE_CONTEXT); const width = String(to).length; return lines .slice(from - 1, to) .map((line, i) => { const lineNum = from + i; const num = String(lineNum).padStart(width, " "); const sep = lineNum >= start && lineNum <= end ? ":" : "-"; return `${num}${sep}${line}`; }) .join("\n"); } export function parseGitHubUrl(url: string): GitHubUrl | null { let parsed: URL; try { parsed = new URL(url); } catch { return null; } if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") return null; const [owner, repo, kind, ...rest] = parsed.pathname.split("/").filter(Boolean); if (!owner || !repo) return null; const lineRange = parsed.hash ? parseLineFragment(parsed.hash.slice(1)) : null; const number = rest[0] ? Number.parseInt(rest[0], 10) : NaN; if (kind === "blob" && rest.length >= 2) { const [branch, ...path] = rest; return { type: "blob", owner, repo, branch, path: path.join("/"), lineRange }; } if (kind === "tree" && rest.length >= 1) { const [branch, ...path] = rest; return { type: "tree", owner, repo, branch, path: path.join("/") }; } if (kind === "pull" && Number.isInteger(number)) return { type: "pull", owner, repo, number }; if (kind === "issues" && Number.isInteger(number)) return { type: "issue", owner, repo, number }; if (!kind) return { type: "repo", owner, repo }; // Unknown GitHub page. Treat it as a repo so we still authenticate through gh. return { type: "repo", owner, repo }; } async function fetchGitHubContent( pi: ExtensionAPI, owner: string, repo: string, path: string, ref: string, ): Promise { const endpoint = `repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`; const result = await pi.exec("gh", ["api", endpoint]); const parsed = JSON.parse(result.stdout) as { content?: string; encoding?: string; message?: string; }; if (typeof parsed.content !== "string") { const msg = typeof parsed.message === "string" ? parsed.message : "unknown error"; throw new Error(`GitHub API error for ${owner}/${repo}: ${msg}`); } if (parsed.encoding && parsed.encoding !== "base64") throw new Error(`Unsupported GitHub content encoding: ${parsed.encoding}`); return Buffer.from(parsed.content.replace(/\n/g, ""), "base64").toString("utf-8"); } function formatMarkdownList( items: unknown[], label: string, formatter: (item: any) => string, ): string[] { if (items.length === 0) return []; return [`\n## ${label}`, ...items.map(formatter)]; } function formatBytes(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; } function summarizeText(text: string, maxLen = 160): string { const normalized = text.replace(/\r\n/g, "\n").replace(/\n/g, "\\n"); if (normalized.length <= maxLen) return normalized; return `${normalized.slice(0, Math.max(0, maxLen - 3))}...`; } // --- Main extension --- export default function (pi: ExtensionAPI) { pi.registerProvider("exa", { name: "Exa", apiKey: "$EXA_API_KEY", }); pi.registerProvider("jina", { name: "Jina", apiKey: "$JINA_API_KEY", }); pi.registerTool({ name: "webfetch", label: "Web Fetch", description: WEBFETCH_DESCRIPTION, promptSnippet: "Fetch URL content as markdown/raw text and return images as image attachments.", promptGuidelines: [ "Use webfetch when the user provides a specific URL to inspect.", "Prefer format=markdown for readable page extraction unless the original response body is required.", ], parameters: WEBFETCH_PARAMS, renderCall(args, theme) { const params = args as Partial; let text = theme.fg("toolTitle", theme.bold("webfetch ")); if (typeof params.url === "string" && params.url.trim()) { text += theme.fg("accent", summarizeText(params.url.trim(), 90)); } else { text += theme.fg("muted", "url?"); } const format = typeof params.format === "string" ? params.format : "markdown"; if (format !== "markdown") text += " " + theme.fg("muted", format); if (typeof params.objective === "string" && params.objective.trim()) { text += " " + theme.fg("dim", JSON.stringify(summarizeText(params.objective.trim(), 80))); } return new Text(text, 0, 0); }, async execute(_toolCallId, params: WebFetchParams, signal, _onUpdate, ctx: ExtensionContext) { const { url } = params; // GitHub-aware handling const gh = parseGitHubUrl(url); if (gh) { if (gh.type === "blob") { const content = await fetchGitHubContent(pi, gh.owner, gh.repo, gh.path, gh.branch); const text = gh.lineRange ? extractLines(content, gh.lineRange.start, gh.lineRange.end) : content; const truncated = await truncateForModelWithTempFile(text, "webfetch"); return { content: [{ type: "text", text: truncated.text }], details: { url, source: "gh", truncation: truncated.details } as Record< string, unknown >, }; } if (gh.type === "tree") { const endpoint = `repos/${gh.owner}/${gh.repo}/contents/${gh.path}?ref=${gh.branch}`; const result = await pi.exec("gh", ["api", endpoint]); const parsed = JSON.parse(result.stdout) as unknown; if (!Array.isArray(parsed)) { const msg = typeof (parsed as Record).message === "string" ? ((parsed as Record).message as string) : "unknown error"; throw new Error(`GitHub API error for ${gh.owner}/${gh.repo}: ${msg}`); } const items = parsed as Array<{ name: string; type: string; size?: number; }>; const lines = [`# ${gh.owner}/${gh.repo}/${gh.path}\n`]; for (const item of items.sort((a, b) => { if (a.type !== b.type) return a.type === "dir" ? -1 : 1; return a.name.localeCompare(b.name); })) { const icon = item.type === "dir" ? "📁" : "📄"; const size = item.type === "file" && item.size ? ` (${formatBytes(item.size)})` : ""; lines.push(`${icon} ${item.name}${item.type === "dir" ? "/" : ""}${size}`); } return { content: [{ type: "text", text: lines.join("\n") }], details: { url } as Record, }; } if (gh.type === "pull") { const result = await pi.exec("gh", [ "pr", "view", `${gh.number}`, "--repo", `${gh.owner}/${gh.repo}`, "--json", "number,title,state,author,body,url,baseRefName,headRefName,isDraft,mergeable,reviewDecision,comments,reviews,files", ]); const pull = JSON.parse(result.stdout) as any; const comments = (pull.comments ?? []) as any[]; const reviews = (pull.reviews ?? []) as any[]; const files = (pull.files ?? []) as any[]; const lines = [ `# PR #${pull.number ?? gh.number}: ${pull.title ?? "(untitled)"}`, ``, `State: ${pull.state ?? "unknown"}${pull.isDraft ? " (draft)" : ""}`, `Author: ${pull.author?.login ?? "unknown"}`, `Base: ${pull.baseRefName ?? "?"} ← Head: ${pull.headRefName ?? "?"}`, `Mergeable: ${pull.mergeable ?? "unknown"}`, `Review decision: ${pull.reviewDecision ?? "unknown"}`, `URL: ${pull.url ?? url}`, ``, pull.body ?? "", ...formatMarkdownList( files, "Files", (file) => `- ${file.path} (+${file.additions ?? 0}/-${file.deletions ?? 0})`, ), ...formatMarkdownList( reviews, "Reviews", (review) => `- ${review.author?.login ?? "unknown"}: ${review.state ?? "unknown"}${review.body ? ` — ${review.body}` : ""}`, ), ...formatMarkdownList( comments, "Comments", (comment) => `- ${comment.author?.login ?? "unknown"}: ${comment.body ?? ""}`, ), ]; const truncated = await truncateForModelWithTempFile(lines.join("\n"), "webfetch"); return { content: [{ type: "text", text: truncated.text }], details: { url, source: "gh", truncation: truncated.details } as Record< string, unknown >, }; } if (gh.type === "issue") { const result = await pi.exec("gh", [ "issue", "view", `${gh.number}`, "--repo", `${gh.owner}/${gh.repo}`, "--json", "number,title,state,author,body,url,comments", ]); const issue = JSON.parse(result.stdout) as any; const comments = (issue.comments ?? []) as any[]; const lines = [ `# Issue #${issue.number ?? gh.number}: ${issue.title ?? "(untitled)"}`, ``, `State: ${issue.state ?? "unknown"}`, `Author: ${issue.author?.login ?? "unknown"}`, `URL: ${issue.url ?? url}`, ``, issue.body ?? "", ...formatMarkdownList( comments, "Comments", (comment) => `- ${comment.author?.login ?? "unknown"}: ${comment.body ?? ""}`, ), ]; const truncated = await truncateForModelWithTempFile(lines.join("\n"), "webfetch"); return { content: [{ type: "text", text: truncated.text }], details: { url, source: "gh", truncation: truncated.details } as Record< string, unknown >, }; } if (gh.type === "repo") { const result = await pi.exec("gh", ["api", `repos/${gh.owner}/${gh.repo}/readme`]); const readme = JSON.parse(result.stdout) as { content?: string; name?: string; message?: string; }; if (typeof readme.content !== "string") { const msg = typeof readme.message === "string" ? readme.message : "unknown error"; throw new Error(`GitHub API error for ${gh.owner}/${gh.repo}: ${msg}`); } const content = Buffer.from(readme.content, "base64").toString("utf-8"); const truncated = await truncateForModelWithTempFile(content, "webfetch"); return { content: [{ type: "text", text: truncated.text }], details: { url, file: readme.name, truncation: truncated.details } as Record< string, unknown >, }; } } // General URL fetch const normalizedUrl = validateAndNormalizeUrl(url); const format = params.format ?? "markdown"; const timeoutSeconds = Math.min( Math.max(params.timeout ?? DEFAULT_WEBFETCH_TIMEOUT_SECONDS, 1), MAX_WEBFETCH_TIMEOUT_SECONDS, ); const { signal: requestSignal, cleanup } = mergeAbortSignals(signal, timeoutSeconds * 1000); let response: Response; let responseBytes: Uint8Array; try { const headers = buildWebFetchHeaders(format); const initial = await fetch(normalizedUrl, { signal: requestSignal, headers }); response = initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" ? await fetch(normalizedUrl, { signal: requestSignal, headers: { ...headers, "User-Agent": "pi-web-tools" }, }) : initial; if (!response.ok) throw new Error(`Request failed with status code: ${response.status}`); responseBytes = await readResponseBytes(response, MAX_RESPONSE_SIZE); } catch (error) { if (requestSignal.aborted || isAbortError(error)) throw new Error(`Request timed out after ${timeoutSeconds} seconds`); throw error; } finally { cleanup(); } const contentType = response.headers.get("content-type") ?? "application/octet-stream"; const mime = contentType.split(";")[0]?.trim().toLowerCase() || "application/octet-stream"; const isImage = mime.startsWith("image/") && mime !== "image/svg+xml"; if (isImage) { return { content: [ { type: "text", text: `Fetched image: ${normalizedUrl} (${mime})` }, { type: "image", data: Buffer.from(responseBytes).toString("base64"), mimeType: mime }, ], details: { url: normalizedUrl, format, contentType, bytes: responseBytes.byteLength }, }; } const raw = new TextDecoder().decode(responseBytes); const transformed = await transformContent( raw, contentType, format, normalizedUrl, undefined, signal, MAX_RESPONSE_SIZE, ); // Optional: LLM-based objective narrowing. Best-effort — falls back to full markdown. const { objective } = params; let finalContent = transformed; let narrowed = false; let narrowingModel: string | undefined; let narrowingDiagnostics: Record | undefined; if (objective && format === "markdown") { const config = await loadWebfetchConfig(ctx.cwd); const result = await narrowMarkdown(transformed, objective, ctx, signal, { model: config.objectiveModel, }); finalContent = result.content; narrowed = result.narrowed; narrowingModel = result.model ? `${result.model.provider}/${result.model.id}` : undefined; narrowingDiagnostics = result.diagnostics; } const truncated = await truncateForModelWithTempFile(finalContent, "webfetch"); return { content: [{ type: "text", text: truncated.text }], details: { url: normalizedUrl, format, contentType, bytes: responseBytes.byteLength, narrowed, narrowingModel, narrowingDiagnostics, truncation: truncated.details, }, }; }, }); pi.registerTool({ name: "websearch", label: "Web Search", description: WEBSEARCH_DESCRIPTION, promptSnippet: "Search the web and return a consolidated result snippet.", promptGuidelines: [ "Use websearch for open-ended or recent-information questions.", "Add the current year to news and current-events queries when useful.", ], parameters: WEBSEARCH_PARAMS, prepareArguments(args) { if (!args || typeof args !== "object") return args as WebSearchParams; const input = args as Record; const { livecrawl, contextMaxCharacters, ...current } = input; if (current.maxAgeHours === undefined && livecrawl === "preferred") current.maxAgeHours = 0; if (current.maxCharacters === undefined && typeof contextMaxCharacters === "number") { current.maxCharacters = contextMaxCharacters; } return current as WebSearchParams; }, renderCall(args, theme) { const params = args as Partial; let text = theme.fg("toolTitle", theme.bold("websearch ")); if (typeof params.query === "string" && params.query.trim()) { text += theme.fg("accent", JSON.stringify(summarizeText(params.query.trim(), 100))); } else { text += theme.fg("muted", "query?"); } if (typeof params.type === "string" && params.type !== "auto") { text += " " + theme.fg("muted", params.type); } if (typeof params.numResults === "number") { text += " " + theme.fg("dim", `${params.numResults} results`); } return new Text(text, 0, 0); }, async execute(_toolCallId, params: WebSearchParams, signal, _onUpdate, ctx) { const [exa, jina] = await Promise.all([ ctx.modelRegistry.getApiKeyForProvider("exa"), ctx.modelRegistry.getApiKeyForProvider("jina"), ]); if (!exa?.trim() && !jina?.trim()) return searchExaMcp(params, signal); const routed = await searchWithFailover(params, { exa, jina }, signal); const output = formatSearchResponse(routed.response); const truncated = await truncateForModelWithTempFile(output, "websearch"); return { content: [{ type: "text", text: truncated.text }], details: { backend: routed.response.backend, attempts: routed.attempts, query: params.query, numResults: params.numResults ?? DEFAULT_WEBSEARCH_RESULTS, type: params.type ?? "auto", category: params.category, requestId: routed.response.requestId, resolvedSearchType: routed.response.resolvedSearchType, searchTime: routed.response.searchTime, costDollars: routed.response.costDollars, usage: routed.response.usage, truncation: truncated.details, }, }; }, }); } async function searchExaMcp(params: WebSearchParams, parentSignal: AbortSignal | undefined) { const usesAdvancedOptions = (params.type !== undefined && params.type !== "auto") || params.category !== undefined || params.includeDomains !== undefined || params.excludeDomains !== undefined || params.startPublishedDate !== undefined || params.endPublishedDate !== undefined || (params.content !== undefined && params.content !== "highlights") || params.maxCharacters !== undefined || params.maxAgeHours !== undefined || params.moderation !== undefined; if (usesAdvancedOptions) { throw new Error("This websearch request uses options that require an Exa API key"); } const { signal, cleanup } = mergeAbortSignals(parentSignal, WEBSEARCH_TIMEOUT_MS); try { const response = await fetch(`${WEBSEARCH_BASE_URL}${WEBSEARCH_ENDPOINT}`, { method: "POST", headers: { accept: "application/json, text/event-stream", "content-type": "application/json", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "web_search_exa", arguments: { query: params.query, numResults: params.numResults ?? DEFAULT_WEBSEARCH_RESULTS, }, }, }), signal, }); const responseText = await response.text(); if (!response.ok) throw new Error(`Search error (${response.status}): ${responseText}`); const output = parseWebSearchResponse(responseText); const truncated = await truncateForModelWithTempFile( output || "No search results found. Please try a different query.", "websearch", ); return { content: [{ type: "text" as const, text: truncated.text }], details: { backend: "mcp", query: params.query, numResults: params.numResults ?? DEFAULT_WEBSEARCH_RESULTS, truncation: truncated.details, }, }; } catch (error) { if (signal.aborted) throw new Error("Search request timed out or was cancelled"); throw error; } finally { cleanup(); } } function buildWebFetchHeaders(format: "markdown" | "raw") { let accept = "*/*"; switch (format) { case "markdown": accept = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"; break; case "raw": accept = "*/*"; break; } return { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", Accept: accept, "Accept-Language": "en-US,en;q=0.9", }; } export function parseWebSearchResponse(text: string): string | null { const payloads = [...parseSsePayloads(text), text]; for (const payload of payloads) { if (payload === "[DONE]") continue; let data: { error?: { message?: string }; result?: { isError?: boolean; content?: Array<{ type?: string; text?: string }>; }; }; try { data = JSON.parse(payload) as typeof data; } catch { continue; } if (data.error) throw new Error(data.error.message || "Exa MCP search failed"); const firstText = data.result?.content?.find( (item) => typeof item.text === "string" && item.text.length > 0, )?.text; if (data.result?.isError) throw new Error(firstText || "Exa MCP search failed"); if (firstText) return firstText; } return null; } function parseSsePayloads(raw: string): string[] { const payloads: string[] = []; let current: string[] = []; for (const line of raw.split(/\r?\n/)) { if (line.startsWith("data:")) { current.push(line.slice(5).trimStart()); continue; } if (line.trim() === "") { if (current.length > 0) { payloads.push(current.join("\n")); current = []; } } } if (current.length > 0) payloads.push(current.join("\n")); return payloads; } function mergeAbortSignals(parentSignal: AbortSignal | undefined, timeoutMs: number) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(new Error("Timed out")), timeoutMs); const onAbort = () => controller.abort(parentSignal?.reason ?? new Error("Aborted")); if (parentSignal) { if (parentSignal.aborted) onAbort(); else parentSignal.addEventListener("abort", onAbort, { once: true }); } return { signal: controller.signal, cleanup: () => { clearTimeout(timeout); if (parentSignal) parentSignal.removeEventListener("abort", onAbort); }, }; } function isAbortError(error: unknown): boolean { return ( error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("abort")) ); }