@bacnh85/pi-web 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,8 +18,9 @@ import {
18
18
  truncateText,
19
19
  formatFirecrawlScrape,
20
20
  formatCrawl4aiResult,
21
+ formatUnifiedSearchResults,
21
22
  } from "./lib/format";
22
- import { searchWithDiagnostics, type SearchResult as UnifiedSearchResult } from "./lib/search";
23
+ import { searchWithDiagnostics } from "./lib/search";
23
24
  import { extractWithDiagnostics, type ExtractMode } from "./lib/extract";
24
25
  import { firecrawlRequest, type FirecrawlResult } from "./lib/firecrawl";
25
26
  import {
@@ -48,28 +49,6 @@ const crawl4aiControlSchema = {
48
49
  };
49
50
 
50
51
 
51
- // ---------------------------------------------------------------------------
52
- // Helper: format unified search results for text output
53
- // ---------------------------------------------------------------------------
54
-
55
- function formatUnifiedSearchResults(results: UnifiedSearchResult[]): string {
56
- if (!results.length) return "No results found.";
57
- return results
58
- .map((r, i) =>
59
- [
60
- `--- Result ${i + 1} (backend: ${r.backend}) ---`,
61
- `Title: ${r.title || ""}`,
62
- `Link: ${r.url || ""}`,
63
- r.age ? `Age: ${r.age}` : "",
64
- r.snippet ? `Snippet: ${r.snippet}` : "",
65
- r.content ? `Content:\n${r.content}` : "",
66
- ]
67
- .filter(Boolean)
68
- .join("\n"),
69
- )
70
- .join("\n\n");
71
- }
72
-
73
52
  // ---------------------------------------------------------------------------
74
53
  // Extension entry point
75
54
  // ---------------------------------------------------------------------------
@@ -16,6 +16,7 @@ export async function fetchBraveResults(
16
16
  signal?: AbortSignal,
17
17
  ): Promise<BraveResult[]> {
18
18
  return withRetry(async () => {
19
+ if (signal?.aborted) throw signal.reason ?? new Error("Operation aborted.");
19
20
  const params = new URLSearchParams({ q: query, count: String(count), country });
20
21
  if (freshness) params.append("freshness", freshness);
21
22
  const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
@@ -36,5 +37,5 @@ export async function fetchBraveResults(
36
37
  snippet: sanitizeSnippet(r.description || ""),
37
38
  age: sanitizeSnippet(r.age || r.page_age || ""),
38
39
  }));
39
- });
40
+ }, undefined, signal);
40
41
  }
@@ -9,8 +9,8 @@ import path from "node:path";
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
11
  export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
12
- export const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
13
- export const DEFAULT_CRAWL4AI_API_URL = "http://172.30.55.22:11235";
12
+ export const DEFAULT_SEARXNG_BASE_URL = "http://127.0.0.1:8888";
13
+ export const DEFAULT_CRAWL4AI_API_URL = "http://127.0.0.1:11235";
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Environment loading
@@ -54,7 +54,7 @@ export function parseDotenvValue(rawValue: string): string {
54
54
 
55
55
  export function parseDotenvFile(file: string): Record<string, string> | null {
56
56
  let text: string;
57
- try { text = fs.readFileSync(file, "utf8"); } catch (e: any) { if (e?.code === "ENOENT") return null; throw e; }
57
+ try { text = fs.readFileSync(file, "utf8"); } catch { return null; }
58
58
  const values: Record<string, string> = {};
59
59
  for (const rawLine of text.split(/\r?\n/)) {
60
60
  const line = rawLine.trim();
@@ -142,7 +142,10 @@ export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = p
142
142
  const explicitApiUrl = params.crawl4ai_api_url as string | undefined;
143
143
  const explicitApiToken = params.crawl4ai_api_token as string | undefined;
144
144
  const baseUrl = normalizeCrawl4aiApiUrl(explicitApiUrl || apiUrlLookup.value);
145
- const apiToken = explicitApiToken || apiTokenLookup.value || "";
145
+ // Security: only use env-var API token when URL is default or also from env var.
146
+ // If the URL is overridden via params but no explicit token was provided, don't send
147
+ // the env-var token to a potentially attacker-controlled URL.
148
+ const apiToken = explicitApiToken || (explicitApiUrl ? "" : apiTokenLookup.value) || "";
146
149
  const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_API_TIMEOUT_MS", cwd, includeCwdEnv).value;
147
150
  const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
148
151
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("CRAWL4AI_API_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
@@ -171,8 +174,11 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
171
174
  const apiKeyLookup = findEnvValue("FIRECRAWL_API_KEY", cwd, includeCwdEnv);
172
175
  const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
173
176
  const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
174
- const apiKey = explicitApiKey || apiKeyLookup.value || "";
175
- const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd).value;
177
+ // Security: only use env-var API key when URL is default or also from env var.
178
+ // If the URL is overridden via params but no explicit key was provided, don't send
179
+ // the env-var key to a potentially attacker-controlled URL.
180
+ const apiKey = explicitApiKey || (apiUrl !== apiUrlLookup.value && apiUrl ? "" : apiKeyLookup.value) || "";
181
+ const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd, includeCwdEnv).value;
176
182
  const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
177
183
  const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
178
184
  const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
@@ -62,17 +62,25 @@ export async function fetchReadableContent(
62
62
  const html = await response.text();
63
63
  const deps = loadReadableContentDependencies();
64
64
  const dom = new deps.JSDOM(html, { url });
65
- const article = new deps.Readability(dom.window.document).parse();
66
- if (article?.content) {
67
- return { title: article.title || "", markdown: htmlToMarkdown(article.content, deps) };
65
+ try {
66
+ const article = new deps.Readability(dom.window.document).parse();
67
+ if (article?.content) {
68
+ return { title: article.title || "", markdown: htmlToMarkdown(article.content, deps) };
69
+ }
70
+ } finally {
71
+ dom.window.close();
68
72
  }
69
73
  // Fallback: strip non-content elements and use main/article/body
70
74
  const fallbackDoc = new deps.JSDOM(html, { url });
71
- const doc = fallbackDoc.window.document;
72
- doc.querySelectorAll("script, style, noscript, nav, header, footer, aside").forEach((el: any) => el.remove());
73
- const title = doc.querySelector("title")?.textContent?.trim() || "";
74
- const main = doc.querySelector("main, article, [role='main'], .content, #content") || doc.body;
75
- const fallbackHtml = main?.innerHTML || "";
76
- if (fallbackHtml.trim().length <= 100) throw new Error("Could not extract readable content from this page.");
77
- return { title, markdown: htmlToMarkdown(fallbackHtml, deps) };
75
+ try {
76
+ const doc = fallbackDoc.window.document;
77
+ doc.querySelectorAll("script, style, noscript, nav, header, footer, aside").forEach((el: any) => el.remove());
78
+ const title = doc.querySelector("title")?.textContent?.trim() || "";
79
+ const main = doc.querySelector("main, article, [role='main'], .content, #content") || doc.body;
80
+ const fallbackHtml = main?.innerHTML || "";
81
+ if (fallbackHtml.trim().length <= 100) throw new Error("Could not extract readable content from this page.");
82
+ return { title, markdown: htmlToMarkdown(fallbackHtml, deps) };
83
+ } finally {
84
+ fallbackDoc.window.close();
85
+ }
78
86
  }
@@ -3,19 +3,7 @@
3
3
  // Endpoints: /crawl, /crawl/stream, /md, /screenshot, /pdf, /health
4
4
 
5
5
  import type { Crawl4aiConfig } from "./config";
6
- import { signalWithTimeout } from "./retry";
7
-
8
- // ---------------------------------------------------------------------------
9
- // Error
10
- // ---------------------------------------------------------------------------
11
-
12
- export class Crawl4aiHttpError extends Error {
13
- status: number;
14
- constructor(status: number, statusText: string, text: string) {
15
- super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`);
16
- this.status = status;
17
- }
18
- }
6
+ import { signalWithTimeout, withRetry, HttpError } from "./retry";
19
7
 
20
8
  // ---------------------------------------------------------------------------
21
9
  // Low-level HTTP helpers
@@ -38,7 +26,7 @@ async function crawl4aiRequestJson(
38
26
  signal: signalWithTimeout(config.timeoutMs, signal),
39
27
  });
40
28
  const text = await response.text();
41
- if (!response.ok) throw new Crawl4aiHttpError(response.status, response.statusText, text);
29
+ if (!response.ok) throw new HttpError(response.status, response.statusText, text);
42
30
  return text ? JSON.parse(text) : { success: true };
43
31
  }
44
32
 
@@ -52,15 +40,7 @@ export async function crawl4aiRequest(
52
40
  body?: unknown,
53
41
  signal?: AbortSignal,
54
42
  ): Promise<Record<string, unknown>> {
55
- for (let attempt = 0; attempt < 3; attempt++) {
56
- try {
57
- return await crawl4aiRequestJson(config, method, endpoint, body, signal);
58
- } catch (e) {
59
- if (attempt === 2) throw e;
60
- await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
61
- }
62
- }
63
- throw new Error("unreachable");
43
+ return withRetry(() => crawl4aiRequestJson(config, method, endpoint, body, signal), 3, signal);
64
44
  }
65
45
 
66
46
  // ---------------------------------------------------------------------------
@@ -10,7 +10,7 @@ import { loadFirecrawlConfig, loadCrawl4aiConfig, type FirecrawlConfig, type Cra
10
10
  import { fetchReadableContent } from "./content";
11
11
  import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
12
12
  import { fetchCrawl4aiMarkdown } from "./crawl4ai";
13
- import { formatFirecrawlScrape } from "./format";
13
+ import { formatFirecrawlScrape, sanitizeError } from "./format";
14
14
 
15
15
  export type ExtractMode = "auto" | "static" | "dynamic" | "full";
16
16
  export type ExtractAttemptStatus = "success" | "empty" | "error";
@@ -56,14 +56,6 @@ export interface ExtractDiagnostics {
56
56
 
57
57
  const MIN_USEFUL_MARKDOWN_CHARS = 120;
58
58
 
59
- function sanitizeError(error: unknown): string {
60
- const raw = error instanceof Error ? error.message : String(error);
61
- return raw
62
- .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
63
- .replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
64
- .slice(0, 500);
65
- }
66
-
67
59
  function isUseful(result: ExtractResult | null, mode: ExtractMode, explicit: boolean): result is ExtractResult {
68
60
  if (!result) return false;
69
61
  const length = result.markdown.trim().length;
@@ -99,12 +91,9 @@ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknow
99
91
  throw new Error(`Firecrawl configuration unavailable: ${sanitizeError(e)}`);
100
92
  }
101
93
 
102
- const formats: unknown[] = ["markdown"];
94
+ const formats: string[] = ["markdown"];
103
95
  if (params.prompt || params.schema) {
104
- const jsonEntry: Record<string, unknown> = { type: "json" };
105
- if (params.prompt) jsonEntry.prompt = params.prompt;
106
- if (params.schema) jsonEntry.schema = params.schema;
107
- formats.push(jsonEntry);
96
+ formats.push("json");
108
97
  }
109
98
  const body: Record<string, unknown> = {
110
99
  url: params.url,
@@ -113,6 +102,12 @@ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknow
113
102
  ...(params.wait_for ? { waitFor: params.wait_for } : {}),
114
103
  ...(params.mobile ? { mobile: true } : {}),
115
104
  };
105
+ if (params.prompt || params.schema) {
106
+ const jsonOptions: Record<string, unknown> = {};
107
+ if (params.prompt) jsonOptions.prompt = params.prompt;
108
+ if (params.schema) jsonOptions.schema = params.schema;
109
+ body.jsonOptions = jsonOptions;
110
+ }
116
111
  const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, params.signal) as FirecrawlResult;
117
112
  const structured = findStructuredPayload(result);
118
113
  const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
@@ -129,7 +124,7 @@ async function extractFull(params: ExtractParams, ctx?: Record<string, unknown>)
129
124
  let c4aiConfig: Crawl4aiConfig;
130
125
  try {
131
126
  c4aiConfig = loadCrawl4aiConfig(
132
- { crawl4ai_api_token: params.crawl4ai_api_token, crawl4ai_api_url: params.crawl4ai_api_url },
127
+ { crawl4ai_api_token: params.crawl4ai_api_token, crawl4ai_api_url: params.crawl4ai_api_url, timeout_ms: params.timeout_ms },
133
128
  cwdFromContext(ctx ?? {}),
134
129
  includeProjectEnv(ctx ?? {}),
135
130
  );
@@ -1,7 +1,7 @@
1
1
  // Firecrawl API client (search, scrape, map, crawl).
2
2
 
3
3
  import type { FirecrawlConfig } from "./config";
4
- import { signalWithTimeout, withRetry } from "./retry";
4
+ import { signalWithTimeout, withRetry, HttpError } from "./retry";
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Types
@@ -15,18 +15,6 @@ export interface FirecrawlResult {
15
15
  [key: string]: unknown;
16
16
  }
17
17
 
18
- // ---------------------------------------------------------------------------
19
- // Error
20
- // ---------------------------------------------------------------------------
21
-
22
- export class FirecrawlHttpError extends Error {
23
- status: number;
24
- constructor(status: number, statusText: string, text: string) {
25
- super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`);
26
- this.status = status;
27
- }
28
- }
29
-
30
18
  // ---------------------------------------------------------------------------
31
19
  // Low-level HTTP helpers
32
20
  // ---------------------------------------------------------------------------
@@ -48,7 +36,7 @@ async function firecrawlRequestJson(
48
36
  signal: signalWithTimeout(config.timeoutMs, signal),
49
37
  });
50
38
  const text = await response.text();
51
- if (!response.ok) throw new FirecrawlHttpError(response.status, response.statusText, text);
39
+ if (!response.ok) throw new HttpError(response.status, response.statusText, text);
52
40
  return text ? JSON.parse(text) : { success: true };
53
41
  }
54
42
 
@@ -62,5 +50,5 @@ export async function firecrawlRequest(
62
50
  body?: unknown,
63
51
  signal?: AbortSignal,
64
52
  ): Promise<Record<string, unknown>> {
65
- return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal));
53
+ return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal), undefined, signal);
66
54
  }
@@ -36,16 +36,56 @@ export function sanitizeSnippet(text = ""): string {
36
36
  .trim();
37
37
  }
38
38
 
39
+ /**
40
+ * Sanitize an error message — redact sensitive tokens and truncate length.
41
+ */
42
+ export function sanitizeError(error: unknown): string {
43
+ const raw = error instanceof Error ? error.message : String(error);
44
+ return raw
45
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
46
+ .replace(/X-Subscription-Token\s*[:=]\s*[^\s]+/gi, "X-Subscription-Token: [REDACTED]")
47
+ .replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
48
+ .slice(0, 500);
49
+ }
50
+
39
51
  export function truncateText(text: string): string {
40
52
  const lines = text.split("\n");
41
- let output = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
42
- while (Buffer.byteLength(output, "utf8") > OUTPUT_MAX_BYTES) {
43
- output = output.slice(0, Math.max(0, output.length - 1024));
44
- }
45
- if (lines.length > OUTPUT_MAX_LINES || output.length < text.length) {
46
- output += `\n\n[Web output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
47
- }
48
- return output;
53
+ const lineTruncated = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
54
+ const buf = Buffer.from(lineTruncated, "utf8");
55
+ if (buf.length <= OUTPUT_MAX_BYTES && lines.length <= OUTPUT_MAX_LINES) return text;
56
+ const truncated = buf.slice(0, OUTPUT_MAX_BYTES).toString("utf8").replace(/\uFFFD+$/g, "");
57
+ return truncated + `\n\n[Web output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Unified search result formatting
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface UnifiedSearchResult {
65
+ title: string;
66
+ url: string;
67
+ snippet: string;
68
+ age: string;
69
+ content: string;
70
+ backend: string;
71
+ }
72
+
73
+ export function formatUnifiedSearchResults(results: UnifiedSearchResult[]): string {
74
+ if (!results.length) return "No results found.";
75
+ return results
76
+ .map((r, i) =>
77
+ [
78
+ `--- Result ${i + 1} (backend: ${r.backend}) ---`,
79
+ `Title: ${r.title || ""}`,
80
+ `Link: ${r.url || ""}`,
81
+ r.age ? `Age: ${r.age}` : "",
82
+ r.snippet ? `Snippet: ${r.snippet}` : "",
83
+ r.content ? `Content:\n${r.content}` : "",
84
+ ]
85
+ .filter(Boolean)
86
+ .join("\n"),
87
+ )
88
+ .join("\n\n");
49
89
  }
50
90
 
51
91
  // ---------------------------------------------------------------------------
@@ -1,13 +1,30 @@
1
- // Retry utility: signal/timeout helpers.
1
+ // Retry utility: signal/timeout helpers and shared HTTP error class.
2
2
 
3
- export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
3
+ // ---------------------------------------------------------------------------
4
+ // Shared HTTP error
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export class HttpError extends Error {
8
+ status: number;
9
+ constructor(status: number, statusText: string, text: string) {
10
+ super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`);
11
+ this.status = status;
12
+ }
13
+ }
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Retry
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export async function withRetry<T>(fn: () => Promise<T>, attempts = 3, signal?: AbortSignal): Promise<T> {
4
20
  let last: unknown;
5
21
  for (let attempt = 0; attempt < attempts; attempt++) {
22
+ if (signal?.aborted) throw signal.reason ?? new Error("Operation aborted.");
6
23
  try { return await fn(); }
7
24
  catch (error) {
8
25
  last = error;
9
26
  if (attempt === attempts - 1) break;
10
- await abortableSleep(1000 * 2 ** attempt);
27
+ await abortableSleep(1000 * 2 ** attempt, signal);
11
28
  }
12
29
  }
13
30
  throw last ?? new Error("retry failed");
@@ -10,7 +10,7 @@ import { fetchSearxngResults } from "./searxng";
10
10
  import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
11
11
  import { loadFirecrawlConfig, loadSearxngConfig, type FirecrawlConfig } from "./config";
12
12
  import { fetchReadableContent } from "./content";
13
- import { sanitizeSnippet } from "./format";
13
+ import { sanitizeSnippet, sanitizeError } from "./format";
14
14
 
15
15
  export type SearchBackend = "searxng" | "brave" | "firecrawl";
16
16
  export type SearchAttemptStatus = "skipped" | "success" | "empty" | "error";
@@ -128,16 +128,22 @@ async function searchBrave(params: SearchParams, backends: BackendConfig): Promi
128
128
  }));
129
129
  if (params.include_content && results.length > 0) {
130
130
  const maxChars = params.content_chars ?? 5000;
131
- await Promise.all(
132
- results.map(async (r) => {
133
- try {
134
- const article = await fetchReadableContent(r.url, params.timeout_ms ?? 10000, params.signal);
135
- r.content = article.markdown.slice(0, maxChars);
136
- } catch (e: any) {
137
- r.content = `(Fetch error for ${r.url}: ${sanitizeError(e)})`;
138
- }
139
- }),
140
- );
131
+ // Cap concurrent content fetches to avoid OOM from parallel JSDOM parsing
132
+ const MAX_CONCURRENT_FETCHES = 5;
133
+ const batchSize = Math.min(results.length, MAX_CONCURRENT_FETCHES);
134
+ for (let i = 0; i < results.length; i += batchSize) {
135
+ const batch = results.slice(i, i + batchSize);
136
+ await Promise.all(
137
+ batch.map(async (r) => {
138
+ try {
139
+ const article = await fetchReadableContent(r.url, params.timeout_ms ?? 10000, params.signal);
140
+ r.content = article.markdown.slice(0, maxChars);
141
+ } catch (e: any) {
142
+ r.content = `(Fetch error for ${r.url}: ${sanitizeError(e)})`;
143
+ }
144
+ }),
145
+ );
146
+ }
141
147
  }
142
148
  return results;
143
149
  }
@@ -187,15 +193,6 @@ export function selectSearchBackendOrder(params: SearchParams): SearchBackend[]
187
193
  return ["searxng", "brave", "firecrawl"];
188
194
  }
189
195
 
190
- function sanitizeError(error: unknown): string {
191
- const raw = error instanceof Error ? error.message : String(error);
192
- return raw
193
- .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
194
- .replace(/X-Subscription-Token\s*[:=]\s*[^\s]+/gi, "X-Subscription-Token: [REDACTED]")
195
- .replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
196
- .slice(0, 500);
197
- }
198
-
199
196
  function isConfigured(backend: SearchBackend, backends: BackendConfig): boolean {
200
197
  if (backend === "searxng") return backends.searxng.configured;
201
198
  if (backend === "brave") return backends.brave.configured;
@@ -231,10 +228,6 @@ export async function searchWithDiagnostics(params: SearchParams): Promise<Searc
231
228
  }
232
229
  }
233
230
 
234
- const diagnostics = attempts.map((a) => `${a.backend}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
235
- throw new Error(
236
- `All web search backends failed or returned no results.\n${diagnostics}\n` +
237
- "Use backend to force a provider or check configuration with web_status.",
238
- );
231
+ return { results: [], attempts, selectedBackend: "" as const, backendOrder };
239
232
  }
240
233
 
@@ -59,5 +59,5 @@ export async function fetchSearxngResults(
59
59
  publishedDate: r.publishedDate || r.published_date || "",
60
60
  }));
61
61
  return { ...data, results };
62
- });
62
+ }, undefined, signal);
63
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, and Crawl4AI headless browser crawling.",
5
5
  "type": "module",
6
6
  "license": "MIT",