/** * AnySearch API client for the pi-anysearch extension — REST transport. * * Docs: https://www.anysearch.com/docs — REST API /v1 (also used by Skill v3.1.1). * Search: POST https://api.anysearch.com/v1/search (JSON summaries, title/URL/snippet) * Support: GET /v1/sub-domains (capability discovery), POST /v1/extract (HTML extraction) * Direct PDF extraction uses native http/https plus the system /usr/bin/pdftotext. * * Auth: `Authorization: Bearer ` is optional — anonymous requests work * but are rate-limited per IP and metered against the daily free quota. */ import { spawn } from "node:child_process"; import { lookup } from "node:dns/promises"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import http from "node:http"; import https from "node:https"; import { BlockList, isIP } from "node:net"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; const ANYSEARCH_MCP_URL = "https://api.anysearch.com/mcp"; const ANYSEARCH_SEARCH_URL = "https://api.anysearch.com/v1/search"; const ANYSEARCH_EXTRACT_URL = "https://api.anysearch.com/v1/extract"; const ANYSEARCH_SUB_DOMAINS_URL = "https://api.anysearch.com/v1/sub-domains"; const ANYSEARCH_CLIENT = "pi-anysearch-tools/0.4.0"; // ponytail: static version for X-Anysearch-Client, bump when package.json bumps (telemetry parity with skill/3.1.0) const SEARCH_TIMEOUT_MS = 30_000; const PDF_TIMEOUT_MS = 30_000; const PDF_MAX_BYTES = 20 * 1024 * 1024; const PDF_MAX_OUTPUT_CHARS = 50_000; const PDF_MAX_REDIRECTS = 5; const PDFTOTEXT_COMMAND = "/usr/bin/pdftotext"; const MAX_RESULTS = 10; // server hard cap export const DEFAULT_SEARCH_RESULTS = 5; export const DEFAULT_BATCH_RESULTS = 3; export const MAX_SNIPPET_CHARS = 500; export const MAX_SEARCH_OUTPUT_CHARS = 12_000; interface SearchItem { title: string; url: string; snippet: string; } interface SearchApiCallResult { items: SearchItem[]; isError: boolean; errorText?: string; requestId?: string; autoRegisteredApiKey?: string; raw: unknown; } /** The 17 vertical domains supported by the search API. */ export const DOMAINS = [ "general", "resource", "social_media", "finance", "academic", "legal", "health", "business", "security", "ip", "code", "energy", "environment", "agriculture", "travel", "film", "gaming", ] as const; export type AnySearchDomain = (typeof DOMAINS)[number]; /** One argument set for the `search` tool (and for each `batch_search` item). */ export interface AnySearchParams { query: string; domain?: string; sub_domain?: string; sub_domain_params?: Record; max_results?: number; // zone/language are REST passthrough fields for POST https://api.anysearch.com/v1/search (documented). zone?: string; language?: string; } /** Parsed result of a JSON-RPC tools/call against /mcp. */ export interface McpCallResult { /** First text item of result.content (Markdown). */ text: string; /** result.isError — true when the server reports an error in-band. */ isError: boolean; /** _meta.request_id when present. */ requestId?: string; /** auto_registered.api_key when the response carries one. */ autoRegisteredApiKey?: string; /** The full JSON-RPC response (raw object, or raw body text when unparseable). */ raw: unknown; } /** Config file shape. Located at /anysearch.json. */ interface AnySearchConfig { anysearchApiKey?: unknown; apiKey?: unknown; } /** Package-internal state (reminder bookkeeping). Located at /pi-anysearch-state.json. */ interface AnySearchState { setupPrompted?: boolean; } let cachedConfig: AnySearchConfig | null = null; let cachedState: AnySearchState | null = null; /** Path of the user-editable config file: /anysearch.json */ export function getConfigPath(): string { return join(getAgentDir(), "anysearch.json"); } /** Path of the package-internal state file: /pi-anysearch-state.json */ export function getStatePath(): string { return join(getAgentDir(), "pi-anysearch-state.json"); } /** Drop cached config so the next resolveApiKey() re-reads the file. */ export function invalidateConfigCache(): void { cachedConfig = null; } /** True when an API key is configured (env var or config file). */ export function hasApiKey(): boolean { return resolveApiKey() !== undefined; } /** Persist an API key to /anysearch.json, preserving other fields. */ export function writeApiKey(key: string): void { const normalized = key.trim(); if (!normalized) throw new Error("API key is empty"); const configPath = getConfigPath(); let config: Record = {}; if (existsSync(configPath)) { try { const parsed: unknown = JSON.parse(readFileSync(configPath, "utf-8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { config = parsed as Record; } } catch { // Corrupt config: overwrite with the new key only. } } config.anysearchApiKey = normalized; mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); invalidateConfigCache(); } function loadState(): AnySearchState { if (cachedState) return cachedState; const statePath = getStatePath(); if (!existsSync(statePath)) { cachedState = {}; return cachedState; } try { const parsed: unknown = JSON.parse(readFileSync(statePath, "utf-8")); cachedState = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as AnySearchState) : {}; } catch { cachedState = {}; } return cachedState; } /** Whether the setup reminder has already been shown (avoid nagging every session). */ export function hasBeenPrompted(): boolean { return loadState().setupPrompted === true; } /** Record that the setup reminder was shown. */ export function markPrompted(): void { const statePath = getStatePath(); mkdirSync(dirname(statePath), { recursive: true }); writeFileSync( statePath, `${JSON.stringify({ setupPrompted: true }, null, 2)}\n`, "utf-8", ); cachedState = { setupPrompted: true }; } function loadConfig(): AnySearchConfig { if (cachedConfig) return cachedConfig; const configPath = join(getAgentDir(), "anysearch.json"); if (!existsSync(configPath)) { cachedConfig = {}; return cachedConfig; } const raw = readFileSync(configPath, "utf-8"); let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to parse ${configPath}: ${message}`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`Invalid config in ${configPath}: expected a JSON object`); } cachedConfig = parsed as AnySearchConfig; return cachedConfig; } function normalizeConfigKey(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed || undefined; } /** * Resolve the AnySearch API key. Priority: * 1. `ANYSEARCH_API_KEY` environment variable * 2. `anysearchApiKey` or `apiKey` in /anysearch.json * Returns undefined for anonymous access. */ export function resolveApiKey(): string | undefined { const envKey = normalizeConfigKey(process.env.ANYSEARCH_API_KEY); if (envKey) return envKey; const config = loadConfig(); const fileKey = normalizeConfigKey(config.anysearchApiKey) ?? normalizeConfigKey(config.apiKey); if (fileKey) return fileKey; return undefined; } function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } /** One retry: proxy/DNS blips and 5xx recover within it; more attempts would only add latency. */ // ponytail: a single retry covers the observed sub-second proxy blips (300 ms backoff). // Ceiling: a multi-second outage still fails the call — raise RETRY_ATTEMPTS with a // longer backoff only if measured flakes ever exceed that window. const RETRY_ATTEMPTS = 2; const RETRY_BASE_DELAY_MS = 300; const RETRY_MAX_DELAY_MS = 2_000; /** undici transport failures worth one retry: proxy resets, DNS hiccups, connect stalls. */ const RETRYABLE_TRANSPORT_CODES = new Set([ "ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "EPIPE", "ETIMEDOUT", "ENETUNREACH", "ENETDOWN", "EHOSTUNREACH", "EHOSTDOWN", "EAI_AGAIN", "ENOTFOUND", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", ]); /** The reason undici hides in `cause` — a bare "fetch failed" hides ECONNRESET/ENOTFOUND/… */ function errorCause(err: unknown): { code?: string; message?: string } | undefined { const cause = (err as { cause?: unknown } | null | undefined)?.cause; if (cause instanceof Error) { const code = (cause as { code?: unknown }).code; return { ...(typeof code === "string" ? { code } : {}), message: cause.message, }; } if (typeof cause === "string") return { message: cause }; return undefined; } function describeTransportFailure(err: unknown): string { const message = errorMessage(err); const cause = errorCause(err); const detail = [cause?.code, cause?.message].filter(Boolean).join(": "); if (!detail || message.includes(detail)) return message; return `${message} (${detail})`; } function isRetryableTransportFailure(err: unknown): boolean { if (isAbortError(err)) return false; const code = errorCause(err)?.code; if (code) return RETRYABLE_TRANSPORT_CODES.has(code); // Undici reports every connection-level fault as a bare "fetch failed"; a request // that never got a response is safe to resend exactly once. return errorMessage(err) === "fetch failed"; } /** 5xx is a server fault; these tools are read-only, so resending is idempotent. */ function isRetryableStatus(status: number): boolean { return status >= 500 && status < 600; } function retryDelayMs(response: Response): number { const header = response.headers.get("retry-after"); const seconds = header ? Number(header) : Number.NaN; if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1000, RETRY_MAX_DELAY_MS); return RETRY_BASE_DELAY_MS; } function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { const done = () => { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); }; const timer = setTimeout(done, ms); signal.addEventListener("abort", done, { once: true }); }); } function isAbortError(err: unknown): boolean { return ( !!err && typeof err === "object" && "name" in err && ((err as { name: unknown }).name === "AbortError" || (err as { name: unknown }).name === "TimeoutError") ); } function truncate(text: string, maxChars: number): string { if (text.length <= maxChars) return text; return `${text.slice(0, maxChars)}… (truncated)`; } /** HTTP 402 can contain generated login credentials in its message. Never echo it. */ function apiErrorDetail(raw: unknown, status?: number): string { if (status === 402) { return "Quota exhausted. Configure an API key with /anysearch-setup or ANYSEARCH_API_KEY; account credentials are withheld."; } const envelope = raw && typeof raw === "object" ? (raw as Record) : {}; const error = envelope.error; const errorMessage = error && typeof error === "object" ? (error as Record).message : error; // Select only the error message, never serialize an upstream envelope that // may include auto_registered credentials alongside an otherwise safe error. const message = typeof errorMessage === "string" ? errorMessage : typeof envelope.message === "string" ? envelope.message : typeof raw === "string" ? raw : "unknown error"; if ( /as_sk_|auto_registered|\b(?:username|password|api[_ -]?key)["']?\s*[:=]/i.test(message) ) { return "Upstream error details withheld because they contain account credentials."; } return truncate(message, 300); } /** Clamp max_results into the server-supported 1-10 range (default 5). */ export function normalizeMaxResults( value: number | undefined, fallback = DEFAULT_SEARCH_RESULTS, ): number { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; return Math.max(1, Math.min(Math.floor(value), MAX_RESULTS)); } /** Build the `arguments` object for the search / batch_search items. */ export function buildSearchArguments( params: AnySearchParams, defaultMaxResults = DEFAULT_SEARCH_RESULTS, ): Record { const args: Record = { query: params.query, max_results: normalizeMaxResults(params.max_results, defaultMaxResults), format: "json", }; if (params.sub_domain) args.tag = params.sub_domain; if (params.sub_domain_params) args.params = params.sub_domain_params; if (params.zone) args.zone = params.zone; if (params.language) args.language = params.language; return args; } /** Find a request id in a shallow _meta / request_id field, if present. */ function findRequestId(node: unknown): string | undefined { if (!node || typeof node !== "object") return undefined; const record = node as Record; const direct = record.request_id; if (typeof direct === "string") return direct; const meta = record._meta; if (meta && typeof meta === "object") { const metaId = (meta as Record).request_id; if (typeof metaId === "string") return metaId; } return undefined; } /** * Extract an auto-registered API key from a quota-exhaustion response. * The documented shape is result.auto_registered.api_key (object with `key`, * or a bare string); the key may also be embedded in the response text. */ export function extractAutoRegisteredKey( structured: unknown, text: string, ): string | undefined { /** Walk the documented shapes: key string, api_key (string | {key}), auto_registered (object). */ const fromKeyNode = (node: unknown): string | undefined => { if (typeof node === "string") return node.startsWith("as_sk_") ? node : undefined; if (!node || typeof node !== "object") return undefined; const record = node as Record; const direct = typeof record.key === "string" && record.key.startsWith("as_sk_") ? record.key : undefined; return ( direct ?? fromKeyNode(record.api_key) ?? fromKeyNode(record.auto_registered) ); }; const fromStructured = fromKeyNode(structured); if (fromStructured) return fromStructured; if (text && text.includes("auto_registered")) { const match = text.match(/as_sk_[A-Za-z0-9_-]{8,}/); if (match) return match[0]; } return undefined; } let rpcId = 0; async function requestJson( url: string, body: unknown, accept: string, signal?: AbortSignal, method: "GET" | "POST" = "POST", ): Promise<{ response: Response; rawText: string; raw: unknown }> { const apiKey = resolveApiKey(); const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS); const requestSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal; const payload = method === "POST" ? JSON.stringify(body) : undefined; for (let attempt = 1; ; attempt++) { let response: Response; try { response = await fetch(url, { method, headers: { Accept: accept, ...(method === "POST" ? { "Content-Type": "application/json" } : {}), "X-Anysearch-Client": ANYSEARCH_CLIENT, ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }, ...(payload !== undefined ? { body: payload } : {}), signal: requestSignal, }); } catch (err) { if (isAbortError(err)) throw new Error("AnySearch request was cancelled or timed out"); const message = errorMessage(err); if (message.toLowerCase().includes("abort")) { throw new Error("AnySearch request was cancelled or timed out"); } if (attempt < RETRY_ATTEMPTS && isRetryableTransportFailure(err)) { await sleep(RETRY_BASE_DELAY_MS, requestSignal); continue; } const attempts = attempt > 1 ? ` after ${attempt} attempts` : ""; throw new Error( `AnySearch request failed${attempts}: ${describeTransportFailure(err)}`, ); } if (attempt < RETRY_ATTEMPTS && isRetryableStatus(response.status)) { // Drain the body so the retry can reuse the connection, then back off. await response.text().catch(() => ""); await sleep(retryDelayMs(response), requestSignal); continue; } const rawText = await response.text().catch(() => ""); let raw: unknown; try { raw = rawText ? JSON.parse(rawText) : {}; } catch { raw = rawText; } return { response, rawText, raw }; } } /** * Call one AnySearch MCP tool (JSON-RPC 2.0 tools/call) against /mcp. * Throws a descriptive Error on network/HTTP/JSON-RPC failure; in-band tool * errors are returned via isError + text. */ export async function callMcpTool( toolName: string, args: Record, signal?: AbortSignal, ): Promise { const body = { jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: toolName, arguments: args }, }; const { response, raw } = await requestJson( ANYSEARCH_MCP_URL, body, "application/json, text/event-stream", signal, ); if (!response.ok) { const envelope = raw && typeof raw === "object" ? (raw as Record) : {}; const requestId = findRequestId(envelope) ?? findRequestId(envelope.error); const detail = apiErrorDetail(raw, response.status); const retryAfterHeader = response.headers.get("retry-after"); const retryHint = retryAfterHeader ? ` Retry-After: ${retryAfterHeader}s.` : ""; const requestHint = requestId ? ` (request_id: ${requestId})` : ""; throw new Error( `AnySearch API error ${response.status}: ${detail || "unknown error"}.${requestHint}${retryHint}`, ); } const envelope = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; // JSON-RPC-level error (protocol or tool dispatch failure). if (envelope.error && typeof envelope.error === "object") { const rpcError = envelope.error as Record; const requestId = findRequestId(envelope) ?? findRequestId(rpcError); const requestHint = requestId ? ` (request_id: ${requestId})` : ""; throw new Error( `AnySearch API error: ${apiErrorDetail(envelope)}${requestHint}`, ); } const result = envelope.result; if (!result || typeof result !== "object" || Array.isArray(result)) { throw new Error( `AnySearch API returned invalid response: missing result object${responseHint(envelope)}`, ); } const resultRecord = result as Record; const content = Array.isArray(resultRecord.content) ? resultRecord.content : []; const firstText = content.find( (item): item is { type: string; text: string } => !!item && typeof item === "object" && typeof (item as Record).type === "string" && typeof (item as Record).text === "string", ); const text = firstText ? firstText.text : ""; const requestId = findRequestId(resultRecord._meta) ?? findRequestId(resultRecord); const isError = resultRecord.isError === true; if (!isError && !firstText) { throw new Error( `AnySearch API returned invalid response: empty content${requestId ? ` (request_id: ${requestId})` : ""}`, ); } const autoRegisteredApiKey = extractAutoRegisteredKey(resultRecord, text) ?? extractAutoRegisteredKey(envelope, text); const safeText = isError ? apiErrorDetail(text) : text; return { text: safeText, isError, ...(requestId ? { requestId } : {}), ...(autoRegisteredApiKey ? { autoRegisteredApiKey } : {}), raw: isError ? { error: safeText } : raw, }; } function responseHint(envelope: Record): string { const requestId = findRequestId(envelope); return requestId ? ` (request_id: ${requestId})` : ""; } /** Normalize a search-result URL for dedupe: scheme/www/trailing-slash/case-insensitive, drop hash and tracking params. */ export function normalizeSearchUrl(raw: string): string { if (!raw) return ""; try { const u = new URL(raw); u.hash = ""; for (const p of [ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid", "ref", "ref_src", ]) { u.searchParams.delete(p); } let s = u.host + u.pathname + (u.search || "").replace(/(%2[fF]|\/)+$/, ""); if (s.startsWith("www.")) s = s.slice(4); return s.replace(/\/+$/, "").toLowerCase(); } catch { return raw .trim() .toLowerCase() .replace(/^[a-z][a-z0-9+.-]*:\/\//, "") .replace(/\/+$/, ""); } } /** * Normalize a search-result title for dedupe: case/punctuation-insensitive (spaces, commas, * "&", truncation dots, "… | Site" suffixes all become plain words), and strips a leading * arXiv id ("[2511.10687] …") so the same paper listed with/without the id matches. */ export function normalizeSearchTitle(raw: string): string { const t = raw .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); return t.replace(/^\d{4}\s+\d{4,5}\s+/, ""); // leading arXiv id ("[2511.10687] …") } /** Character-bigram overlap coefficient (Szymkiewicz–Simpson): 1.0 for identical / truncated-prefix titles. */ export function titleSimilarity(a: string, b: string): number { if (a.length < 2 || b.length < 2) return 0; const ga = new Set(); for (let i = 0; i < a.length - 1; i++) ga.add(a.slice(i, i + 2)); const gb = new Set(); for (let i = 0; i < b.length - 1; i++) gb.add(b.slice(i, i + 2)); let shared = 0; for (const g of ga) if (gb.has(g)) shared++; return shared / Math.min(ga.size, gb.size); } /** Titles at/above this normalized similarity are treated as duplicates (empirical gap: real duplicates >= 0.96, distinct papers <= 0.72). */ const TITLE_SIMILARITY_THRESHOLD = 0.85; const MIN_TITLE_DEDUPE_CHARS = 12; // short generic titles ("Paris") never collapse on title /** Narrow structured-item dedupe helper: filters duplicate URLs/titles using shared seen sets. */ function dedupeSearchItems( items: SearchItem[], seenUrls: Set, seenTitles: string[], ): SearchItem[] { const unique: SearchItem[] = []; for (const item of items) { // URL-less structured values can share a title without being duplicates. if (!item.url) { unique.push(item); continue; } const uKey = normalizeSearchUrl(item.url); const tKey = normalizeSearchTitle(item.title); const dupUrl = uKey !== "" && seenUrls.has(uKey); const dupTitle = tKey.length >= MIN_TITLE_DEDUPE_CHARS && seenTitles.some( (prev) => titleSimilarity(prev, tKey) >= TITLE_SIMILARITY_THRESHOLD, ); if (dupUrl || dupTitle) continue; unique.push(item); if (uKey) seenUrls.add(uKey); if (tKey.length >= MIN_TITLE_DEDUPE_CHARS) seenTitles.push(tKey); } return unique; } async function callSearchApi( params: AnySearchParams, defaultMaxResults: number, signal?: AbortSignal, ): Promise { const { response, rawText, raw } = await requestJson( ANYSEARCH_SEARCH_URL, buildSearchArguments(params, defaultMaxResults), "application/json", signal, ); if (!response.ok) { const envelope = raw && typeof raw === "object" ? (raw as Record) : {}; const requestId = findRequestId(envelope) ?? findRequestId((envelope as Record).error); const detail = apiErrorDetail(raw, response.status); const retryAfterHeader = response.headers.get("retry-after"); const retryHint = retryAfterHeader ? ` Retry-After: ${retryAfterHeader}s.` : ""; const requestHint = requestId ? ` (request_id: ${requestId})` : ""; throw new Error( `AnySearch API error ${response.status}: ${detail || "unknown error"}.${requestHint}${retryHint}`, ); } const envelope = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; const requestId = findRequestId(envelope); const autoRegisteredApiKey = extractAutoRegisteredKey(envelope, rawText) ?? extractAutoRegisteredKey((envelope as Record).data, rawText); if (envelope.code !== 0) { if (autoRegisteredApiKey) { const errorText = apiErrorDetail(envelope); return { items: [], isError: true, errorText, ...(requestId ? { requestId } : {}), autoRegisteredApiKey, raw: { error: errorText }, }; } const detail = apiErrorDetail(envelope); const requestHint = requestId ? ` (request_id: ${requestId})` : ""; throw new Error(`AnySearch API error: ${detail}${requestHint}`); } const data = envelope.data as Record | undefined; if (!data || !Array.isArray((data as Record).results)) { const requestHint = requestId ? ` (request_id: ${requestId})` : ""; throw new Error( `AnySearch API returned invalid response: missing results array${requestHint}`, ); } const rawResults = (data as { results: unknown[] }).results; const items: SearchItem[] = []; for (const r of rawResults) { if (!r || typeof r !== "object" || Array.isArray(r)) continue; const rec = r as Record; const title = typeof rec.title === "string" ? rec.title.trim() : ""; let url = typeof rec.url === "string" ? rec.url.trim() : ""; try { if (!/^https?:\/\//i.test(url) || !["http:", "https:"].includes(new URL(url).protocol)) url = ""; } catch { url = ""; } const rawSnippet = typeof rec.snippet === "string" ? rec.snippet : ""; const normalizedSnippet = rawSnippet .replace(/\s+/g, " ") .trim() .slice(0, MAX_SNIPPET_CHARS); if (!title && !url && !normalizedSnippet) continue; items.push({ title, url, snippet: normalizedSnippet }); } return { items, isError: false, ...(requestId ? { requestId } : {}), ...(autoRegisteredApiKey ? { autoRegisteredApiKey } : {}), raw, }; } /** Missing source URLs are valid for structured results; never fabricate a citation. */ function renderSearchItem( item: SearchItem, index: number, snippet = item.snippet, ): string { return [ `### ${index + 1}. ${item.title || "Untitled result"}`, ...(item.url ? [`- **URL**: ${item.url}`] : []), ...(snippet ? [`- ${snippet}`] : []), ].join("\n"); } function buildSectionCandidate( header: string, nextHeader: string, emitted: SearchItem[], nextBlock: string, ): string { const blocks = emitted.map((item, i) => renderSearchItem(item, i)); blocks.push(nextBlock); return `${header}\n\n${nextHeader}\n\n${blocks.join("\n\n")}`; } function renderSearchResults(items: SearchItem[]): string { const header = `## Search Results (${items.length} results)`; if (items.length === 0) return header; const lines: string[] = [header, ""]; items.forEach((item, i) => { lines.push(renderSearchItem(item, i)); if (i < items.length - 1) lines.push(""); }); let text = lines.join("\n"); if (text.length > MAX_SEARCH_OUTPUT_CHARS) { text = truncate(text, MAX_SEARCH_OUTPUT_CHARS); } return text; } /** Run a single search (general or vertical) via the search tool. */ export async function searchAnySearch( params: AnySearchParams, signal?: AbortSignal, ): Promise { if (!params.query || !params.query.trim()) throw new Error("query is required"); const result = await callSearchApi(params, DEFAULT_SEARCH_RESULTS, signal); if (result.isError) { return { text: result.errorText ?? "", isError: true, ...(result.requestId ? { requestId: result.requestId } : {}), ...(result.autoRegisteredApiKey ? { autoRegisteredApiKey: result.autoRegisteredApiKey } : {}), raw: result.raw, }; } const uniqueItems = dedupeSearchItems(result.items, new Set(), []); const text = renderSearchResults(uniqueItems); return { text, isError: false, ...(result.requestId ? { requestId: result.requestId } : {}), ...(result.autoRegisteredApiKey ? { autoRegisteredApiKey: result.autoRegisteredApiKey } : {}), raw: result.raw, }; } /** Run 1-5 searches via REST with Promise.allSettled; preserves order, isolates failures. */ export async function batchSearchAnySearch( queries: AnySearchParams[], signal?: AbortSignal, ): Promise { if (!Array.isArray(queries) || queries.length < 1 || queries.length > 5) { throw new Error("batch_search requires 1-5 queries"); } for (const item of queries) { if (!item.query || !item.query.trim()) throw new Error("each batch_search query item requires a non-empty query"); } if (signal?.aborted) throw new Error("AnySearch request was cancelled or timed out"); const settled = await Promise.allSettled( queries.map((q) => callSearchApi(q, DEFAULT_BATCH_RESULTS, signal)), ); // If caller aborted, propagate cancellation instead of partial errors if (signal?.aborted) throw new Error("AnySearch request was cancelled or timed out"); for (const r of settled) { if (r.status === "rejected") { if (isAbortError(r.reason)) throw r.reason; const msg = errorMessage(r.reason); if ( msg.toLowerCase().includes("abort") || msg.includes("cancelled or timed out") ) { throw r.reason; } } } const sections: string[] = []; const raws: unknown[] = []; const seenUrls = new Set(); const seenTitles: string[] = []; const separatorChars = Math.max(0, settled.length - 1) * 2; const perSectionBudget = Math.floor( (MAX_SEARCH_OUTPUT_CHARS - separatorChars) / settled.length, ); for (let i = 0; i < settled.length; i++) { const q = queries[i]; const header = `## Query ${i + 1}: ${q.query}`; const s = settled[i]; if (s.status === "fulfilled") { const v = s.value; if (v.isError) { const msg = truncate(v.errorText ?? "Unknown error", 300); sections.push(`${header}\n\nSearch failed: ${msg}`); raws.push({ error: msg }); } else { const uniqueItems = dedupeSearchItems(v.items, seenUrls, seenTitles); // Fair per-section budget rendering const emitted: SearchItem[] = []; let sectionText = `${header}\n\n## Search Results (0 results)`; for (const item of uniqueItems) { const nextCount = emitted.length + 1; const nextHeader = `## Search Results (${nextCount} results)`; const hasSnippet = !!item.snippet; const blockNext = renderSearchItem(item, nextCount - 1); const candidateFull = buildSectionCandidate( header, nextHeader, emitted, blockNext, ); if (candidateFull.length <= perSectionBudget) { emitted.push(item); sectionText = candidateFull; continue; } // Try shrinking snippet before omitting title/URL if (hasSnippet) { const blockNoSnippet = renderSearchItem(item, nextCount - 1, ""); const candidateNoSnippet = buildSectionCandidate( header, nextHeader, emitted, blockNoSnippet, ); if (candidateNoSnippet.length <= perSectionBudget) { const remaining = perSectionBudget - candidateNoSnippet.length - 3; if (remaining >= 0) { const truncated = item.snippet.slice(0, remaining); if (truncated.length > 0) { const blockTrunc = renderSearchItem( item, nextCount - 1, truncated, ); const candidateTrunc = buildSectionCandidate( header, nextHeader, emitted, blockTrunc, ); if (candidateTrunc.length <= perSectionBudget) { emitted.push({ ...item, snippet: truncated }); sectionText = candidateTrunc; continue; } } } // Omit snippet entirely emitted.push({ ...item, snippet: "" }); sectionText = candidateNoSnippet; continue; } } // Stop before adding a block that would exceed allocation break; } if (emitted.length === 0 && uniqueItems.length > 0) { // Nothing fit but we still need header (edge: very long title/URL) sectionText = `${header}\n\n## Search Results (0 results)`; } else if (emitted.length === 0) { sectionText = `${header}\n\n## Search Results (0 results)`; } sections.push(sectionText); raws.push(v.raw); } } else { const msg = truncate(errorMessage(s.reason), 300); sections.push(`${header}\n\nSearch failed: ${msg}`); raws.push({ error: msg }); } } const anySuccess = settled.some( (r) => r.status === "fulfilled" && !(r.value as SearchApiCallResult).isError, ); let text = sections.join("\n\n"); if (text.length > MAX_SEARCH_OUTPUT_CHARS) text = text.slice(0, MAX_SEARCH_OUTPUT_CHARS); return { text, isError: !anySuccess, raw: raws, }; } interface PdfResponse { status: number; headers: Record; body: Buffer; url: string; } class PdfDirectError extends Error { security: boolean; constructor(message: string, security = false) { super(message); this.name = "PdfDirectError"; this.security = security; } } const PRIVATE_NETWORKS = new BlockList(); for (const [address, prefix] of [ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4], ] as const) { PRIVATE_NETWORKS.addSubnet(address, prefix, "ipv4"); } for (const [address, prefix] of [ ["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8], ["2001:db8::", 32], ] as const) { PRIVATE_NETWORKS.addSubnet(address, prefix, "ipv6"); } function parseExternalUrl(rawUrl: string): URL { if (!/^https?:\/\//i.test(rawUrl)) throw new Error("url must start with http:// or https://"); let parsed: URL; try { parsed = new URL(rawUrl); } catch { throw new Error("url must be a valid http:// or https:// URL"); } if (parsed.username || parsed.password) throw new PdfDirectError("URL credentials are not allowed", true); if (!parsed.hostname) throw new PdfDirectError("URL must include a host", true); return parsed; } function isUnsafeAddress(address: string): boolean { const normalized = address.replace(/%.+$/, ""); if (normalized.toLowerCase().startsWith("::ffff:")) return true; const family = isIP(normalized); if (family === 4) return PRIVATE_NETWORKS.check(normalized, "ipv4"); if (family === 6) return PRIVATE_NETWORKS.check(normalized, "ipv6"); return true; } async function resolveFakeIpHost( host: string, signal?: AbortSignal, ): Promise> { const url = new URL("https://cloudflare-dns.com/dns-query"); url.searchParams.set("name", host); url.searchParams.set("type", "A"); const response = await fetch(url, { headers: { Accept: "application/dns-json" }, redirect: "error", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(PDF_TIMEOUT_MS)]) : AbortSignal.timeout(PDF_TIMEOUT_MS), }); if (!response.ok || !response.body) throw new PdfDirectError("Public DNS lookup failed", true); const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 16_384) throw new PdfDirectError("Public DNS response exceeds size limit", true); chunks.push(value); } } finally { await reader.cancel(); } const data = JSON.parse(Buffer.concat(chunks).toString("utf8")); if (data.Status !== 0 || !Array.isArray(data.Answer)) throw new PdfDirectError("Public DNS lookup returned no addresses", true); return data.Answer.filter((answer: { type: number }) => answer.type === 1).map( (answer: { data: string }) => ({ address: answer.data, family: 4 }), ); } async function resolvePublicAddress( hostname: string, signal?: AbortSignal, ): Promise<{ address: string; family: number }> { if (signal?.aborted) throw new PdfDirectError("Direct PDF fetch was cancelled or timed out"); const host = hostname.replace(/^\[|\]$/g, ""); const literalFamily = isIP(host); if (literalFamily) { if (isUnsafeAddress(host)) throw new PdfDirectError( "Direct PDF fetch rejects private, loopback, or link-local targets", true, ); return { address: host, family: literalFamily }; } let addresses: Array<{ address: string; family: number }>; let dnsTimer: ReturnType | undefined; let removeDnsAbort: (() => void) | undefined; const dnsTimeout = new Promise((_, reject) => { dnsTimer = setTimeout( () => reject(new PdfDirectError("Direct PDF host resolution timed out")), PDF_TIMEOUT_MS, ); }); const dnsAbort = new Promise((_, reject) => { if (!signal) return; const onAbort = () => reject(new PdfDirectError("Direct PDF fetch was cancelled or timed out")); removeDnsAbort = () => signal.removeEventListener("abort", onAbort); signal.addEventListener("abort", onAbort, { once: true }); }); try { addresses = await Promise.race([ lookup(host, { all: true, verbatim: true }), dnsTimeout, dnsAbort, ]); } catch (err) { throw new PdfDirectError( `Direct PDF fetch could not resolve the host: ${errorMessage(err)}`, ); } finally { if (dnsTimer) clearTimeout(dnsTimer); removeDnsAbort?.(); } // Fake-IP is a local proxy mapping, never a connectable public address. // Resolve its hostname independently and validate/pin the real address below. if ( addresses.length && addresses.every(({ address }) => /^198\.(18|19)\./.test(address)) ) addresses = await resolveFakeIpHost(host, signal); if ( !addresses.length || addresses.some((entry) => isUnsafeAddress(entry.address)) ) throw new PdfDirectError( "Direct PDF fetch rejects private, loopback, or link-local targets", true, ); const selected = addresses[0]; if (!selected || !isIP(selected.address)) throw new PdfDirectError( "Direct PDF fetch could not resolve a public address", ); return selected; } function responseHeader( headers: Record, name: string, ): string | undefined { const key = Object.keys(headers).find( (candidate) => candidate.toLowerCase() === name.toLowerCase(), ); const value = key ? headers[key] : undefined; return Array.isArray(value) ? value[0] : value; } function isPdfContentType(contentType: string | undefined): boolean { return ( !!contentType && /^application\/(?:x-)?pdf(?:\s*;|$)/i.test(contentType) ); } function isPdfUrl(parsed: URL): boolean { const pathname = parsed.pathname.toLowerCase(); return ( pathname.endsWith(".pdf") || (parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase() === "arxiv.org" && pathname.startsWith("/pdf/")) ); } function readPdfResponseBody(response: any, request: any): Promise { return new Promise((resolve, reject) => { let totalBytes = 0; const chunks: Buffer[] = []; let settled = false; const finish = (error?: Error) => { if (settled) return; settled = true; if (error) reject(error); else resolve(Buffer.concat(chunks)); }; response.on("data", (chunk: Buffer | string) => { if (settled) return; const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); totalBytes += part.length; if (totalBytes > PDF_MAX_BYTES) { response.destroy?.(); request.destroy?.(); finish( new PdfDirectError( `PDF download exceeds the ${PDF_MAX_BYTES / (1024 * 1024)} MiB size limit`, ), ); return; } chunks.push(part); }); response.on("end", () => finish()); response.on("error", (err: unknown) => finish(new PdfDirectError(`Direct PDF fetch failed: ${errorMessage(err)}`)), ); }); } async function requestPdfUrl( rawUrl: string, method: "GET" | "HEAD", signal?: AbortSignal, ): Promise { const parsed = parseExternalUrl(rawUrl); const transport = parsed.protocol === "https:" ? https : http; const requestPath = `${parsed.pathname || "/"}${parsed.search}`; const timeoutSignal = AbortSignal.timeout(PDF_TIMEOUT_MS); const requestSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal; if (requestSignal.aborted) throw new PdfDirectError("Direct PDF fetch was cancelled or timed out"); const fixedAddress = await resolvePublicAddress( parsed.hostname, requestSignal, ); return new Promise((resolve, reject) => { let request: any; let settled = false; const finish = (error?: Error, response?: PdfResponse) => { if (settled) return; settled = true; requestSignal.removeEventListener("abort", onAbort); if (error) reject(error); else if (response) resolve(response); }; const onAbort = () => { request?.destroy(); finish(new PdfDirectError("Direct PDF fetch was cancelled or timed out")); }; requestSignal.addEventListener("abort", onAbort, { once: true }); try { request = transport.request( { protocol: parsed.protocol, hostname: parsed.hostname.replace(/^\[|\]$/g, ""), port: parsed.port || undefined, path: requestPath, method, headers: { Accept: "application/pdf, text/html;q=0.9", Host: parsed.host, }, agent: false, lookup: ( _hostname: string, options: { all?: boolean }, callback: Function, ) => options.all ? callback(null, [fixedAddress]) : callback(null, fixedAddress.address, fixedAddress.family), ...(parsed.protocol === "https:" ? { servername: parsed.hostname.replace(/^\[|\]$/g, ""), } : {}), }, (response: any) => { const status = response.statusCode ?? 0; const headers = (response.headers ?? {}) as Record< string, string | string[] | undefined >; const contentLength = Number(responseHeader(headers, "content-length")); if ( method === "GET" && Number.isFinite(contentLength) && contentLength > PDF_MAX_BYTES ) { response.resume?.(); request.destroy?.(); finish( new PdfDirectError( `PDF download exceeds the ${PDF_MAX_BYTES / (1024 * 1024)} MiB size limit`, ), ); return; } if (method === "HEAD") { response.resume?.(); finish(undefined, { status, headers, body: Buffer.alloc(0), url: rawUrl, }); return; } readPdfResponseBody(response, request).then( (body) => finish(undefined, { status, headers, body, url: rawUrl }), (error: unknown) => finish( error instanceof Error ? error : new PdfDirectError( `Direct PDF fetch failed: ${errorMessage(error)}`, ), ), ); }, ); request.on("error", (err: unknown) => { if (settled) return; if (isAbortError(err)) onAbort(); else finish( new PdfDirectError(`Direct PDF fetch failed: ${errorMessage(err)}`), ); }); request.setTimeout?.(PDF_TIMEOUT_MS, onAbort); request.end(); } catch (err) { finish( err instanceof PdfDirectError ? err : new PdfDirectError(`Direct PDF fetch failed: ${errorMessage(err)}`), ); } }); } async function fetchPdfWithRedirects( url: string, method: "GET" | "HEAD", signal?: AbortSignal, ): Promise { let currentUrl = url; for (let redirects = 0; ; redirects += 1) { const response = await requestPdfUrl(currentUrl, method, signal); if (![301, 302, 303, 307, 308].includes(response.status)) return response; const location = responseHeader(response.headers, "location"); if (!location) throw new PdfDirectError( "PDF download redirect did not include a Location header", ); if (redirects >= PDF_MAX_REDIRECTS) throw new PdfDirectError("PDF download exceeded the redirect limit"); let nextUrl: string; try { nextUrl = new URL(location, currentUrl).toString(); parseExternalUrl(nextUrl); } catch (err) { if (err instanceof PdfDirectError) throw err; throw new PdfDirectError( "PDF download redirect contained an invalid URL", true, ); } currentUrl = nextUrl; } } async function probePdfUrl( url: string, signal?: AbortSignal, ): Promise { try { const response = await fetchPdfWithRedirects(url, "HEAD", signal); return response.status >= 200 && response.status < 300 && isPdfContentType(responseHeader(response.headers, "content-type")) ? response.url : undefined; } catch (err) { if (err instanceof PdfDirectError && err.security) throw err; if (signal?.aborted) throw err; return undefined; } } async function downloadPdf( url: string, signal?: AbortSignal, ): Promise { const response = await fetchPdfWithRedirects(url, "GET", signal); if (response.status < 200 || response.status >= 300) throw new PdfDirectError( `PDF download failed with HTTP status ${response.status}`, ); const contentType = responseHeader(response.headers, "content-type"); const hasPdfHeader = response.body.subarray(0, 5).equals(Buffer.from("%PDF-")); if (!hasPdfHeader) throw new PdfDirectError( "PDF download returned a non-PDF body (missing %PDF- header)", ); if ( contentType && !isPdfContentType(contentType) && !/^application\/octet-stream(?:\s*;|$)/i.test(contentType) ) throw new PdfDirectError( `PDF download returned unexpected Content-Type: ${contentType}`, ); return response; } function runPdfToText(pdfPath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { let child: any; let stdout = ""; let stderr = ""; let outputLimited = false; let settled = false; let timer: ReturnType | undefined; const finish = (error?: Error, text?: string) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); signal?.removeEventListener("abort", onAbort); if (error) reject(error); else resolve(text ?? ""); }; const onAbort = () => { child?.kill("SIGKILL"); finish(new Error("PDF text extraction was cancelled or timed out")); }; if (signal?.aborted) { onAbort(); return; } signal?.addEventListener("abort", onAbort, { once: true }); try { child = spawn(PDFTOTEXT_COMMAND, ["-q", "-enc", "UTF-8", pdfPath, "-"], { stdio: ["ignore", "pipe", "pipe"], }); child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { if (stdout.length >= PDF_MAX_OUTPUT_CHARS) { outputLimited = true; child.kill("SIGTERM"); return; } stdout += chunk.slice(0, PDF_MAX_OUTPUT_CHARS + 1 - stdout.length); if (stdout.length > PDF_MAX_OUTPUT_CHARS) { stdout = stdout.slice(0, PDF_MAX_OUTPUT_CHARS); outputLimited = true; child.kill("SIGTERM"); } }); child.stderr.on("data", (chunk: string) => { stderr = `${stderr}${chunk}`.slice(-1000); }); child.on("error", (err: any) => { if (err?.code === "ENOENT") finish( new Error( `PDF extraction requires ${PDFTOTEXT_COMMAND}; command is missing`, ), ); else finish(new Error(`PDF text extraction failed: ${errorMessage(err)}`)); }); child.on("close", (code: number | null) => { if (settled) return; if (outputLimited) return finish(undefined, stdout); if (code !== 0) { const detail = stderr.trim() ? `: ${stderr.trim()}` : ""; finish( new Error( `PDF text extraction failed (exit code ${code ?? "unknown"})${detail}`, ), ); return; } finish(undefined, stdout); }); timer = setTimeout(() => onAbort(), PDF_TIMEOUT_MS); } catch (err) { finish(new Error(`PDF text extraction failed: ${errorMessage(err)}`)); } }); } async function pdfToText(body: Buffer, signal?: AbortSignal): Promise { if (!existsSync(PDFTOTEXT_COMMAND)) throw new Error( `PDF extraction requires ${PDFTOTEXT_COMMAND}; command is missing`, ); const directory = mkdtempSync(join(tmpdir(), "pi-anysearch-pdf-")); const pdfPath = join(directory, "input.pdf"); try { writeFileSync(pdfPath, body, { mode: 0o600 }); const text = await runPdfToText(pdfPath, signal); if (!text.trim()) throw new Error( "PDF has no extractable text; scanned documents require OCR (not supported)", ); return text; } finally { rmSync(directory, { recursive: true, force: true }); } } function formatPdfResult(url: string, text: string): string { const source = `**Source**: ${url}\n\n`; const body = text.trim(); if (source.length >= PDF_MAX_OUTPUT_CHARS) return `${source.slice(0, PDF_MAX_OUTPUT_CHARS - 16)}… (truncated)`; const available = PDF_MAX_OUTPUT_CHARS - source.length; if (body.length <= available) return source + body; const marker = "… (truncated)"; return source + body.slice(0, Math.max(0, available - marker.length)) + marker; } async function extractHtmlAnySearch( url: string, signal?: AbortSignal, ): Promise { const { response, rawText, raw } = await requestJson( ANYSEARCH_EXTRACT_URL, { url }, "application/json", signal, ); const envelope = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; const requestId = findRequestId(envelope); if (!response.ok || envelope.code !== 0) { const detail = apiErrorDetail(raw, response.status); throw new Error( `AnySearch API error ${response.status}: ${detail || "unknown error"}${requestId ? ` (request_id: ${requestId})` : ""}`, ); } const data = envelope.data; if (!data || typeof data !== "object" || Array.isArray(data)) { throw new Error( `AnySearch API returned invalid response: missing data object${requestId ? ` (request_id: ${requestId})` : ""}`, ); } const page = data as Record; if (typeof page.content !== "string") { throw new Error( `AnySearch API returned invalid response: missing content${requestId ? ` (request_id: ${requestId})` : ""}`, ); } const source = typeof page.url === "string" ? page.url : url; const text = typeof page.title === "string" && page.title ? `**Source**: ${source}\n\n## ${page.title}\n\n${page.content}` : page.content; const autoRegisteredApiKey = extractAutoRegisteredKey(envelope, rawText); return { text, isError: false, ...(requestId ? { requestId } : {}), ...(autoRegisteredApiKey ? { autoRegisteredApiKey } : {}), raw, }; } /** Fetch a URL's full page content as Markdown (server truncates at 50k chars). */ export async function extractAnySearch( url: string, signal?: AbortSignal, ): Promise { const parsedUrl = parseExternalUrl(url); if (isPdfUrl(parsedUrl)) { const response = await downloadPdf(url, signal); const text = await pdfToText(response.body, signal); return { text: formatPdfResult(response.url, text), isError: false, raw: undefined, }; } const pdfUrl = await probePdfUrl(url, signal); if (pdfUrl) { const response = await downloadPdf(pdfUrl, signal); const text = await pdfToText(response.body, signal); return { text: formatPdfResult(response.url, text), isError: false, raw: undefined, }; } return extractHtmlAnySearch(url, signal); } /** Session-level cache for get_sub_domains results (keyed by normalized domain set). */ const subDomainCache = new Map(); /** Drop the get_sub_domains session cache (test hook). */ export function clearSubDomainCache(): void { subDomainCache.clear(); } /** * Query the vertical domain directory. `domains` (array, max 5) takes priority * over `domain`. Results are cached per session for the same domain set. */ export async function getSubDomainsAnySearch( opts: { domain?: string; domains?: string[] }, signal?: AbortSignal, ): Promise { const domains = opts.domains && opts.domains.length > 0 ? opts.domains : opts.domain ? [opts.domain] : undefined; if (!domains || domains.length === 0) throw new Error("get_sub_domains requires domain or domains"); if (domains.length > 5) throw new Error("get_sub_domains accepts at most 5 domains"); for (const domain of domains) { if (!DOMAINS.includes(domain as AnySearchDomain)) throw new Error(`unknown domain: ${domain}`); } const cacheKey = domains.slice().sort().join(","); const cached = subDomainCache.get(cacheKey); if (cached) return cached; const query = domains .map((domain) => `domain=${encodeURIComponent(domain)}`) .join("&"); const { response, rawText, raw } = await requestJson( `${ANYSEARCH_SUB_DOMAINS_URL}?${query}`, undefined, "application/json", signal, "GET", ); const envelope = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; const requestId = findRequestId(envelope); if (!response.ok || envelope.code !== 0) { const detail = apiErrorDetail(raw, response.status); throw new Error( `AnySearch API error ${response.status}: ${detail || "unknown error"}${requestId ? ` (request_id: ${requestId})` : ""}`, ); } const data = envelope.data; const rawDomains = data && typeof data === "object" && !Array.isArray(data) ? (data as Record).domains : undefined; if (!Array.isArray(rawDomains)) { throw new Error( `AnySearch API returned invalid response: missing domains array${requestId ? ` (request_id: ${requestId})` : ""}`, ); } const sections = rawDomains.flatMap((rawDomain) => { if (!rawDomain || typeof rawDomain !== "object" || Array.isArray(rawDomain)) return []; const domainRecord = rawDomain as Record; if ( typeof domainRecord.domain !== "string" || !Array.isArray(domainRecord.sub_domains) ) return []; const subDomains = domainRecord.sub_domains.flatMap((rawSubDomain) => { if ( !rawSubDomain || typeof rawSubDomain !== "object" || Array.isArray(rawSubDomain) ) return []; const subDomain = rawSubDomain as Record; if (typeof subDomain.sub_domain !== "string") return []; const lines = [`### ${subDomain.sub_domain}`]; if (typeof subDomain.description === "string" && subDomain.description) lines.push(subDomain.description); if ( subDomain.params && typeof subDomain.params === "object" && !Array.isArray(subDomain.params) ) { const params = Object.entries(subDomain.params as Record) .filter( (entry): entry is [string, Record] => !!entry[1] && typeof entry[1] === "object" && !Array.isArray(entry[1]), ) .sort( (a, b) => Number(a[1].sort_order ?? 0) - Number(b[1].sort_order ?? 0), ); if (params.length > 0) { lines.push("", "**Parameters:**"); for (const [name, definition] of params) { const required = definition.required === true ? " (required)" : ""; const description = typeof definition.description === "string" ? `: ${definition.description}` : ""; lines.push(`- \`${name}\`${required}${description}`); } } } return [lines.join("\n")]; }); const lines = [ `## ${domainRecord.domain} Domain Capabilities (${subDomains.length} available)`, ]; if (typeof domainRecord.description === "string" && domainRecord.description) lines.push("", domainRecord.description); if (subDomains.length > 0) lines.push("", subDomains.join("\n\n")); return [lines.join("\n")]; }); const result: McpCallResult = { text: sections.join("\n\n"), isError: false, ...(requestId ? { requestId } : {}), raw, }; subDomainCache.set(cacheKey, result); return result; }