{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/core/web-research/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,0BAA0B,EAC/B,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,MAAM,YAAY,CAAC;AAYpB,UAAU,qBAAqB;IAC9B,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,cAAe,SAAQ,qBAAqB;IACrD,OAAO,EAAE,MAAM,CAAC;CAChB;AA4GD,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,eAAe,EAAE,CAarF;AAED,qBAAa,sBAAuB,YAAW,iBAAiB;IAKnD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,QAAQ,CAAC,EAAE,oBAA8B;IACzC,QAAQ,CAAC,YAAY;;;;;;MAA6F;IAClH,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IAEzC,YAA6B,OAAO,EAAE,qBAAqB,EAE1D;IAEK,MAAM,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA0ClE;IAEK,WAAW,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAmBxE;CACD;AAED,qBAAa,eAAgB,YAAW,iBAAiB;IAK5C,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,QAAQ,CAAC,EAAE,YAAsB;IACjC,QAAQ,CAAC,YAAY;;;;;;MAA0F;IAC/G,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IAEzC,YAA6B,OAAO,EAAE,cAAc,EAEnD;IAED,OAAO,CAAC,SAAS;IAWX,MAAM,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA+DlE;IAEK,WAAW,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAmBxE;CACD;AAMD,qBAAa,yBAAyB;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAqD;IAE/E,YAAY,SAAS,EAAE,iBAAiB,EAAE,EAEzC;IAED,GAAG,CAAC,EAAE,EAAE,mBAAmB,GAAG,iBAAiB,CAI9C;IAED,IAAI,IAAI,iBAAiB,EAAE,CAE1B;IAEK,MAAM,CAAC,SAAS,EAAE,0BAA0B,EAAE,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAWzG;CACD;AAED,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,cAAc,GAAG,yBAAyB,CAE/F;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAM5D","sourcesContent":["import {\n\tWebResearchError,\n\ttype WebSearchProvider,\n\ttype WebSearchProviderHealth,\n\ttype WebSearchProviderId,\n\ttype WebSearchProviderSelection,\n\ttype WebSearchRequest,\n\ttype WebSearchResponse,\n\ttype WebSearchResult,\n} from \"./types.js\";\nimport { canonicalizeWebUrl, normalizeDomain } from \"./url.js\";\n\nconst HTML_ENTITY_MAP: Record<string, string> = {\n\tamp: \"&\",\n\tlt: \"<\",\n\tgt: \">\",\n\tquot: '\"',\n\tapos: \"'\",\n\tnbsp: \" \",\n};\n\ninterface SearchProviderOptions {\n\tfetch?: typeof fetch;\n\ttimeoutMs: number;\n\tmaxResults: number;\n\tuserAgent: string;\n}\n\ninterface SearxngOptions extends SearchProviderOptions {\n\tbaseUrl: string;\n}\n\ninterface SearxngResult {\n\ttitle?: unknown;\n\turl?: unknown;\n\tcontent?: unknown;\n\tpublishedDate?: unknown;\n\tengine?: unknown;\n\tscore?: unknown;\n}\n\ninterface SearxngPayload {\n\tquery?: unknown;\n\tresults?: unknown;\n}\n\nfunction timeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {\n\tconst timeout = AbortSignal.timeout(timeoutMs);\n\treturn signal ? AbortSignal.any([signal, timeout]) : timeout;\n}\n\nfunction elapsed(started: number): number {\n\treturn Math.max(0, Math.round(performance.now() - started));\n}\n\nfunction normalizeLimit(value: number | undefined, maximum: number): number {\n\tif (value === undefined) return Math.min(5, maximum);\n\tif (!Number.isFinite(value)) throw new WebResearchError(\"INVALID_REQUEST\", \"Search result limit must be finite\");\n\treturn Math.min(maximum, Math.max(1, Math.floor(value)));\n}\n\nfunction normalizeResults(\n\tresults: Omit<WebSearchResult, \"rank\">[],\n\tmaximum: number,\n): {\n\tresults: WebSearchResult[];\n\tdeduplicatedCount: number;\n} {\n\tconst seen = new Set<string>();\n\tconst normalized: Omit<WebSearchResult, \"rank\">[] = [];\n\tlet duplicates = 0;\n\tfor (const result of results) {\n\t\tlet canonical: string;\n\t\ttry {\n\t\t\tcanonical = canonicalizeWebUrl(result.url);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(canonical)) {\n\t\t\tduplicates++;\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(canonical);\n\t\tnormalized.push({\n\t\t\t...result,\n\t\t\ttitle: normalizeWhitespace(result.title),\n\t\t\turl: canonical,\n\t\t\tsource: result.source ?? normalizeDomain(new URL(canonical).hostname),\n\t\t});\n\t}\n\treturn {\n\t\tresults: normalized.slice(0, maximum).map((result, index) => ({ ...result, rank: index + 1 })),\n\t\tdeduplicatedCount: duplicates,\n\t};\n}\n\nfunction normalizeWhitespace(value: string): string {\n\treturn value.replace(/\\r\\n?/g, \"\\n\").replace(/\\s+/g, \" \").trim();\n}\n\nfunction optionalText(value: unknown): string | undefined {\n\treturn typeof value === \"string\" && value.trim() ? normalizeWhitespace(value) : undefined;\n}\n\nfunction optionalNumber(value: unknown): number | undefined {\n\treturn typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n}\n\nfunction decodeHtmlEntities(value: string): string {\n\treturn value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => {\n\t\tif (/^#x/i.test(entity)) {\n\t\t\tconst point = Number.parseInt(entity.slice(2), 16);\n\t\t\treturn Number.isFinite(point) ? String.fromCodePoint(point) : match;\n\t\t}\n\t\tif (entity.startsWith(\"#\")) {\n\t\t\tconst point = Number.parseInt(entity.slice(1), 10);\n\t\t\treturn Number.isFinite(point) ? String.fromCodePoint(point) : match;\n\t\t}\n\t\treturn HTML_ENTITY_MAP[entity] ?? match;\n\t});\n}\n\nfunction cleanHtmlText(value: string): string {\n\treturn normalizeWhitespace(decodeHtmlEntities(value.replace(/<br\\s*\\/?>/gi, \" \").replace(/<[^>]+>/g, \" \"))).replace(\n\t\t/\\s+([.,;:!?])/g,\n\t\t\"$1\",\n\t);\n}\n\nfunction resolveDuckDuckGoHref(rawHref: string): string | undefined {\n\ttry {\n\t\tconst redirect = new URL(decodeHtmlEntities(rawHref), \"https://duckduckgo.com\");\n\t\treturn redirect.searchParams.get(\"uddg\") ?? redirect.toString();\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nexport function parseDuckDuckGoLiteResults(html: string, limit = 5): WebSearchResult[] {\n\tconst parsed: Omit<WebSearchResult, \"rank\">[] = [];\n\tconst pattern =\n\t\t/<a(?=[^>]*class=['\"]result-link['\"])(?=[^>]*href=['\"]([^'\"]+)['\"])[^>]*>([\\s\\S]*?)<\\/a>([\\s\\S]*?)(?=<a(?=[^>]*class=['\"]result-link['\"])|<\\/table>|<form action=['\"]\\/lite\\/['\"]|$)/gi;\n\tfor (const match of html.matchAll(pattern)) {\n\t\tconst url = resolveDuckDuckGoHref(match[1]);\n\t\tconst title = cleanHtmlText(match[2]);\n\t\tconst tail = match[3] ?? \"\";\n\t\tconst snippet = cleanHtmlText(/<td class=['\"]result-snippet['\"]>([\\s\\S]*?)<\\/td>/i.exec(tail)?.[1] ?? \"\");\n\t\tif (!url || !title) continue;\n\t\tparsed.push({ title, url, snippet: snippet || undefined, provider: \"duckduckgo-lite\" });\n\t}\n\treturn normalizeResults(parsed, limit).results;\n}\n\nexport class DuckDuckGoLiteProvider implements WebSearchProvider {\n\treadonly id = \"duckduckgo-lite\" as const;\n\treadonly capabilities = { freshness: false, language: false, region: true, categories: false, safeSearch: false };\n\tprivate readonly fetchImpl: typeof fetch;\n\n\tconstructor(private readonly options: SearchProviderOptions) {\n\t\tthis.fetchImpl = options.fetch ?? fetch;\n\t}\n\n\tasync search(request: WebSearchRequest): Promise<WebSearchResponse> {\n\t\tconst query = request.query.trim();\n\t\tif (!query) throw new WebResearchError(\"INVALID_REQUEST\", \"Search query must not be empty\");\n\t\tconst limit = normalizeLimit(request.maxResults, this.options.maxResults);\n\t\tconst started = performance.now();\n\t\tconst body = new URLSearchParams({ q: query });\n\t\tif (request.region) body.set(\"kl\", request.region);\n\t\tlet response: Response;\n\t\ttry {\n\t\t\tresponse = await this.fetchImpl(\"https://lite.duckduckgo.com/lite/\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: {\n\t\t\t\t\tAccept: \"text/html,application/xhtml+xml\",\n\t\t\t\t\t\"Content-Type\": \"application/x-www-form-urlencoded\",\n\t\t\t\t\t\"User-Agent\": this.options.userAgent,\n\t\t\t\t},\n\t\t\t\tbody,\n\t\t\t\tsignal: timeoutSignal(request.signal, this.options.timeoutMs),\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tif (request.signal?.aborted)\n\t\t\t\tthrow new WebResearchError(\"ABORTED\", \"DuckDuckGo search was aborted\", { cause: error });\n\t\t\tthrow new WebResearchError(\"PROVIDER_UNAVAILABLE\", \"DuckDuckGo Lite search failed\", {\n\t\t\t\tcause: error,\n\t\t\t\tprovider: this.id,\n\t\t\t});\n\t\t}\n\t\tif (!response.ok) {\n\t\t\tthrow new WebResearchError(\"PROVIDER_UNAVAILABLE\", `DuckDuckGo Lite search failed: ${response.status}`, {\n\t\t\t\tprovider: this.id,\n\t\t\t});\n\t\t}\n\t\tconst html = await response.text();\n\t\tconst results = parseDuckDuckGoLiteResults(html, limit);\n\t\treturn {\n\t\t\tprovider: this.id,\n\t\t\tquery,\n\t\t\tresults,\n\t\t\trawResultCount: results.length,\n\t\t\tdeduplicatedCount: 0,\n\t\t\tdurationMs: elapsed(started),\n\t\t};\n\t}\n\n\tasync healthCheck(signal?: AbortSignal): Promise<WebSearchProviderHealth> {\n\t\tconst started = performance.now();\n\t\ttry {\n\t\t\tconst response = await this.fetchImpl(\"https://lite.duckduckgo.com/lite/\", {\n\t\t\t\tmethod: \"HEAD\",\n\t\t\t\theaders: { \"User-Agent\": this.options.userAgent },\n\t\t\t\tsignal: timeoutSignal(signal, Math.min(this.options.timeoutMs, 3000)),\n\t\t\t});\n\t\t\treturn response.ok\n\t\t\t\t? { provider: this.id, status: \"healthy\", latencyMs: elapsed(started) }\n\t\t\t\t: {\n\t\t\t\t\t\tprovider: this.id,\n\t\t\t\t\t\tstatus: \"unhealthy\",\n\t\t\t\t\t\tlatencyMs: elapsed(started),\n\t\t\t\t\t\treason: `HTTP ${response.status}`,\n\t\t\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn { provider: this.id, status: \"unknown\", latencyMs: elapsed(started), reason: errorMessage(error) };\n\t\t}\n\t}\n}\n\nexport class SearxngProvider implements WebSearchProvider {\n\treadonly id = \"searxng\" as const;\n\treadonly capabilities = { freshness: true, language: true, region: false, categories: true, safeSearch: true };\n\tprivate readonly fetchImpl: typeof fetch;\n\n\tconstructor(private readonly options: SearxngOptions) {\n\t\tthis.fetchImpl = options.fetch ?? fetch;\n\t}\n\n\tprivate searchUrl(request: WebSearchRequest): URL {\n\t\tconst url = new URL(\"/search\", `${this.options.baseUrl}/`);\n\t\turl.searchParams.set(\"q\", request.query.trim());\n\t\turl.searchParams.set(\"format\", \"json\");\n\t\tif (request.language) url.searchParams.set(\"language\", request.language);\n\t\tif (request.freshness) url.searchParams.set(\"time_range\", request.freshness);\n\t\tif (request.categories?.length) url.searchParams.set(\"categories\", [...request.categories].sort().join(\",\"));\n\t\tif (request.safeSearch !== undefined) url.searchParams.set(\"safesearch\", request.safeSearch ? \"1\" : \"0\");\n\t\treturn url;\n\t}\n\n\tasync search(request: WebSearchRequest): Promise<WebSearchResponse> {\n\t\tconst query = request.query.trim();\n\t\tif (!query) throw new WebResearchError(\"INVALID_REQUEST\", \"Search query must not be empty\");\n\t\tconst limit = normalizeLimit(request.maxResults, this.options.maxResults);\n\t\tconst started = performance.now();\n\t\tlet response: Response;\n\t\ttry {\n\t\t\tresponse = await this.fetchImpl(this.searchUrl({ ...request, query }), {\n\t\t\t\theaders: { Accept: \"application/json\", \"User-Agent\": this.options.userAgent },\n\t\t\t\tsignal: timeoutSignal(request.signal, this.options.timeoutMs),\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tif (request.signal?.aborted)\n\t\t\t\tthrow new WebResearchError(\"ABORTED\", \"SearXNG search was aborted\", { cause: error });\n\t\t\tthrow new WebResearchError(\"PROVIDER_UNAVAILABLE\", \"SearXNG search failed\", {\n\t\t\t\tcause: error,\n\t\t\t\tprovider: this.id,\n\t\t\t});\n\t\t}\n\t\tif (!response.ok) {\n\t\t\tthrow new WebResearchError(\"PROVIDER_UNAVAILABLE\", `SearXNG search failed with HTTP ${response.status}`, {\n\t\t\t\tprovider: this.id,\n\t\t\t});\n\t\t}\n\t\tlet payload: SearxngPayload;\n\t\ttry {\n\t\t\tpayload = (await response.json()) as SearxngPayload;\n\t\t} catch (error) {\n\t\t\tthrow new WebResearchError(\"PROVIDER_RESPONSE_INVALID\", \"SearXNG returned invalid JSON\", {\n\t\t\t\tcause: error,\n\t\t\t\tprovider: this.id,\n\t\t\t});\n\t\t}\n\t\tif (!Array.isArray(payload.results)) {\n\t\t\tthrow new WebResearchError(\"PROVIDER_RESPONSE_INVALID\", \"SearXNG response is missing a results array\", {\n\t\t\t\tprovider: this.id,\n\t\t\t});\n\t\t}\n\t\tconst raw = payload.results as SearxngResult[];\n\t\tconst mapped: Omit<WebSearchResult, \"rank\">[] = [];\n\t\tfor (const item of raw) {\n\t\t\tconst title = optionalText(item.title);\n\t\t\tconst url = optionalText(item.url);\n\t\t\tif (!title || !url) continue;\n\t\t\tmapped.push({\n\t\t\t\ttitle,\n\t\t\t\turl,\n\t\t\t\tsnippet: optionalText(item.content),\n\t\t\t\tpublishedAt: optionalText(item.publishedDate),\n\t\t\t\tengine: optionalText(item.engine),\n\t\t\t\tprovider: this.id,\n\t\t\t\tscore: optionalNumber(item.score),\n\t\t\t});\n\t\t}\n\t\tconst normalized = normalizeResults(mapped, limit);\n\t\treturn {\n\t\t\tprovider: this.id,\n\t\t\tquery,\n\t\t\tresults: normalized.results,\n\t\t\trawResultCount: raw.length,\n\t\t\tdeduplicatedCount: normalized.deduplicatedCount,\n\t\t\tdurationMs: elapsed(started),\n\t\t};\n\t}\n\n\tasync healthCheck(signal?: AbortSignal): Promise<WebSearchProviderHealth> {\n\t\tconst started = performance.now();\n\t\ttry {\n\t\t\tconst url = new URL(\"/healthz\", `${this.options.baseUrl}/`);\n\t\t\tconst response = await this.fetchImpl(url, {\n\t\t\t\theaders: { \"User-Agent\": this.options.userAgent },\n\t\t\t\tsignal: timeoutSignal(signal, Math.min(this.options.timeoutMs, 3000)),\n\t\t\t});\n\t\t\treturn response.ok\n\t\t\t\t? { provider: this.id, status: \"healthy\", latencyMs: elapsed(started) }\n\t\t\t\t: {\n\t\t\t\t\t\tprovider: this.id,\n\t\t\t\t\t\tstatus: \"unhealthy\",\n\t\t\t\t\t\tlatencyMs: elapsed(started),\n\t\t\t\t\t\treason: `HTTP ${response.status}`,\n\t\t\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn { provider: this.id, status: \"unhealthy\", latencyMs: elapsed(started), reason: errorMessage(error) };\n\t\t}\n\t}\n}\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\nexport class WebSearchProviderRegistry {\n\tprivate readonly providers = new Map<WebSearchProviderId, WebSearchProvider>();\n\n\tconstructor(providers: WebSearchProvider[]) {\n\t\tfor (const provider of providers) this.providers.set(provider.id, provider);\n\t}\n\n\tget(id: WebSearchProviderId): WebSearchProvider {\n\t\tconst provider = this.providers.get(id);\n\t\tif (!provider) throw new WebResearchError(\"INVALID_CONFIGURATION\", `Search provider ${id} is not registered`);\n\t\treturn provider;\n\t}\n\n\tlist(): WebSearchProvider[] {\n\t\treturn [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id));\n\t}\n\n\tasync search(selection: WebSearchProviderSelection, request: WebSearchRequest): Promise<WebSearchResponse> {\n\t\tif (selection !== \"auto\") return this.get(selection).search(request);\n\t\tconst primary = this.get(\"searxng\");\n\t\ttry {\n\t\t\treturn await primary.search(request);\n\t\t} catch (error) {\n\t\t\tif (error instanceof WebResearchError && (error.code === \"INVALID_REQUEST\" || error.code === \"ABORTED\"))\n\t\t\t\tthrow error;\n\t\t\tconst fallback = await this.get(\"duckduckgo-lite\").search(request);\n\t\t\treturn { ...fallback, fallbackFrom: primary.id };\n\t\t}\n\t}\n}\n\nexport function createSearchProviderRegistry(options: SearxngOptions): WebSearchProviderRegistry {\n\treturn new WebSearchProviderRegistry([new SearxngProvider(options), new DuckDuckGoLiteProvider(options)]);\n}\n\nexport function resultDomain(result: WebSearchResult): string {\n\ttry {\n\t\treturn normalizeDomain(new URL(result.url).hostname);\n\t} catch {\n\t\treturn \"\";\n\t}\n}\n"]}