@bacnh85/pi-web 0.4.1 → 0.4.4

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.
@@ -0,0 +1,5 @@
1
+ require:
2
+ - tsx
3
+ spec:
4
+ - test/**/*.test.ts
5
+ timeout: 10000
@@ -49,6 +49,7 @@ const crawl4aiControlSchema = {
49
49
  timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
50
50
  };
51
51
 
52
+
52
53
  // ---------------------------------------------------------------------------
53
54
  // Helper: format unified search results for text output
54
55
  // ---------------------------------------------------------------------------
@@ -81,7 +82,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
81
82
  name: "web_search",
82
83
  label: "Web Search",
83
84
  description:
84
- "Search the web with adaptive backend selection. Uses SearXNG (self-hosted) for broad discovery, Brave (hosted API) for precision-sensitive queries and include_content, and Firecrawl as a last resort. Use backend to force a provider.",
85
+ "Search the web. Auto-selects backends adaptively: SearXNG, Brave, Firecrawl.",
85
86
  promptSnippet: "Search current web results",
86
87
  promptGuidelines: [
87
88
  "Use web_search for source discovery, documentation lookup, current facts, and general web search.",
@@ -132,7 +133,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
132
133
  name: "web_extract",
133
134
  label: "Web Content Extraction",
134
135
  description:
135
- "Extract readable content from a URL with automatic backend selection. Tries static extraction (JSDOM+Readability, no API key) first, then dynamic extraction (Firecrawl Scrape, JS rendering), then full browser extraction (Crawl4AI, headless browser). Use the mode parameter for explicit control. If all modes fail, try web_screenshot for a visual snapshot.",
136
+ "Extract readable content from a URL. Auto backend: static \u2192 dynamic \u2192 full.",
136
137
  promptSnippet: "Extract readable webpage content as markdown",
137
138
  promptGuidelines: [
138
139
  "Use web_extract to get clean markdown content from a known URL.",
@@ -183,7 +184,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
183
184
  name: "web_map",
184
185
  label: "Site URL Discovery",
185
186
  description:
186
- "Discover URLs from a site using Firecrawl Map. Best for finding candidate pages from a site or docs section before targeted extraction. Works best on base domains; may return empty results on sub-paths.",
187
+ "Discover URLs from a site using Firecrawl Map.",
187
188
  promptSnippet: "Map site URLs",
188
189
  promptGuidelines: [
189
190
  "Use web_map to discover URLs from a site before crawling or targeted extraction.",
@@ -213,7 +214,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
213
214
  if (params.use_index !== undefined) body.useIndex = params.use_index;
214
215
  if (params.ignore_cache !== undefined) body.ignoreCache = params.ignore_cache;
215
216
  const config = loadFirecrawlConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
216
- const result = await firecrawlRequest(config, "POST", "/map", body, true, signal);
217
+ const result = await firecrawlRequest(config, "POST", "/map", body, signal);
217
218
  const urls = result.data || result.links || result.urls || [];
218
219
  const text = Array.isArray(urls) && urls.length > 0
219
220
  ? (urls as Array<Record<string, unknown> | string>).map((u: any) => u.url || u).join("\n")
@@ -227,7 +228,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
227
228
  name: "web_crawl",
228
229
  label: "Site Crawl",
229
230
  description:
230
- "Crawl one or more pages from a site. Uses Firecrawl Crawl in 'light' mode (conservative, doc-focused) or Crawl4AI in 'full' mode (headless browser, rendered data, media, links). Accepts a single URL for Firecrawl-style crawling or multiple URLs for Crawl4AI-style crawling.",
231
+ "Crawl site pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
231
232
  promptSnippet: "Crawl a small site section",
232
233
  promptGuidelines: [
233
234
  "Use web_crawl only when multiple pages from a site are truly needed; prefer web_map + web_extract for small numbers of pages.",
@@ -288,14 +289,13 @@ export default function piWebExtension(pi: ExtensionAPI) {
288
289
  : [],
289
290
  scrapeOptions: { formats: ["markdown"], onlyMainContent: true },
290
291
  },
291
- true,
292
292
  signal,
293
293
  );
294
294
  const id = result.id || (result.data as Record<string, unknown> | undefined)?.id;
295
295
  if (params.poll && id && !Array.isArray(result.data)) {
296
296
  const { abortableSleep } = await import("./lib/retry");
297
297
  for (let i = 0; i < 60; i++) {
298
- result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined, true, signal);
298
+ result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined, signal);
299
299
  if (["completed", "failed", "cancelled"].includes(
300
300
  String(result.status || (result.data as Record<string, unknown> | undefined)?.status || ""),
301
301
  )) break;
@@ -319,7 +319,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
319
319
  name: "web_screenshot",
320
320
  label: "Web Page Screenshot",
321
321
  description:
322
- "Capture a full-page PNG screenshot of a URL using Crawl4AI's headless browser. Returns a base64-encoded PNG image suitable for visual inspection.",
322
+ "Capture a full-page PNG screenshot via Crawl4AI headless browser.",
323
323
  promptSnippet: "Screenshot a webpage",
324
324
  promptGuidelines: [
325
325
  "Use web_screenshot when a visual snapshot of a rendered page is needed, or when web_extract fails on a heavily JS-dependent or bot-protected page.",
@@ -360,7 +360,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
360
360
  name: "web_pdf",
361
361
  label: "Web Page PDF",
362
362
  description:
363
- "Generate a PDF document of a URL using Crawl4AI's headless browser. Returns a base64-encoded PDF suitable for saving or archiving.",
363
+ "Generate a PDF document via Crawl4AI headless browser.",
364
364
  promptSnippet: "PDF a webpage",
365
365
  promptGuidelines: [
366
366
  "Use web_pdf when a printable or archivable snapshot of a page is needed.",
@@ -389,13 +389,14 @@ export default function piWebExtension(pi: ExtensionAPI) {
389
389
  name: "web_status",
390
390
  label: "Web Provider Status",
391
391
  description:
392
- "Show all web provider configuration status (Brave, SearXNG, Firecrawl, Crawl4AI) without printing secrets. Includes Crawl4AI server health check. Use when web tools fail due to missing credentials or configuration issues.",
392
+ "Show web provider configuration status without printing secrets.",
393
393
  promptSnippet: "Check web provider configuration and server status",
394
394
  promptGuidelines: [
395
395
  "Use web_status when web tools fail due to missing credentials/config or to verify which backends are available.",
396
396
  "The output shows which backends are configured; if a backend is missing, configure its env vars.",
397
397
  "Never print API key values; this tool reports only presence and source.",
398
398
  "Shows Crawl4AI server health, version, and auth status when the server is reachable.",
399
+ "For Firecrawl, `apiKeyFound: false` is normal for self-hosted instances without auth. Use the `ready` field to check whether Firecrawl is actually usable.",
399
400
  ],
400
401
  parameters: Type.Object({}),
401
402
  async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
@@ -410,15 +411,19 @@ export default function piWebExtension(pi: ExtensionAPI) {
410
411
  const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
411
412
  const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
412
413
 
414
+ const fcBaseUrl = normalizeFirecrawlBaseUrl(fireUrl.value);
415
+ const fcHosted = !fireUrl.value || fcBaseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
416
+
413
417
  const status: Record<string, unknown> = {
414
418
  brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" },
415
419
  searxng: { baseUrl: normalizeSearxngBaseUrl(searxngUrl.value), baseUrlSource: searxngUrl.source || "default local" },
416
420
  firecrawl: {
417
- baseUrl: normalizeFirecrawlBaseUrl(fireUrl.value),
421
+ baseUrl: fcBaseUrl,
418
422
  apiUrlSource: fireUrl.source || "default hosted",
419
423
  apiKeyFound: Boolean(fireKey.value),
420
424
  apiKeySource: fireKey.value ? fireKey.source : "not set",
421
- hostedMode: !fireUrl.value || normalizeFirecrawlBaseUrl(fireUrl.value).startsWith(HOSTED_FIRECRAWL_BASE_URL),
425
+ hostedMode: fcHosted,
426
+ ready: fcHosted ? Boolean(fireKey.value) : Boolean(fireUrl.value?.trim()),
422
427
  },
423
428
  crawl4ai: {
424
429
  baseUrl: normalizeCrawl4aiApiUrl(c4aiUrl.value),
@@ -442,29 +447,4 @@ export default function piWebExtension(pi: ExtensionAPI) {
442
447
  },
443
448
  });
444
449
 
445
- // ── /web-status command ──────────────────────────────────────────────
446
- pi.registerCommand("web-status", {
447
- description: "Show web provider configuration status",
448
- handler: async (_args: string, ctx: any) => {
449
- const cwd = cwdFromContext(ctx);
450
- const trusted = includeProjectEnv(ctx);
451
- const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
452
- const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
453
- const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
454
- const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
455
- const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
456
- const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
457
- ctx.ui.notify(
458
- [
459
- `Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
460
- `SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
461
- `Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
462
- `Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
463
- `Crawl4AI API URL: ${normalizeCrawl4aiApiUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
464
- `Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
465
- ].join("\n"),
466
- "info",
467
- );
468
- },
469
- });
470
450
  }
@@ -0,0 +1,45 @@
1
+ // Brave Search API client.
2
+
3
+ import { sanitizeSnippet, type SearchResultItem } from "./format";
4
+
5
+ export interface BraveResult extends SearchResultItem {
6
+ age: string;
7
+ }
8
+
9
+ export async function fetchBraveResults(
10
+ query: string,
11
+ count: number,
12
+ country: string,
13
+ freshness: string,
14
+ apiKey: string,
15
+ signal?: AbortSignal,
16
+ ): Promise<BraveResult[]> {
17
+ for (let attempt = 0; attempt < 3; attempt++) {
18
+ try {
19
+ const params = new URLSearchParams({ q: query, count: String(count), country });
20
+ if (freshness) params.append("freshness", freshness);
21
+ const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
22
+ headers: {
23
+ Accept: "application/json",
24
+ "Accept-Encoding": "gzip",
25
+ "X-Subscription-Token": apiKey,
26
+ },
27
+ signal,
28
+ });
29
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}\n${await response.text()}`);
30
+ const data: any = await response.json();
31
+ return (data.web?.results || [])
32
+ .slice(0, count)
33
+ .map((r: any) => ({
34
+ title: sanitizeSnippet(r.title || ""),
35
+ url: r.url || "",
36
+ snippet: sanitizeSnippet(r.description || ""),
37
+ age: sanitizeSnippet(r.age || r.page_age || ""),
38
+ }));
39
+ } catch (e) {
40
+ if (attempt === 2) throw e;
41
+ await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
42
+ }
43
+ }
44
+ throw new Error("unreachable");
45
+ }
@@ -1,4 +1,4 @@
1
- // Configuration loading and environment helpers for pi-web.
1
+ // ponytail: duplicated in pi-munin/lib/helpers.ts. Extract when a third package needs it.
2
2
 
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
@@ -8,8 +8,6 @@ import path from "node:path";
8
8
  // Constants
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- export const OUTPUT_MAX_BYTES = 50 * 1024;
12
- export const OUTPUT_MAX_LINES = 2_000;
13
11
  export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
14
12
  export const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
15
13
  export const DEFAULT_CRAWL4AI_API_URL = "http://172.30.55.22:11235";
@@ -183,7 +181,6 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
183
181
  const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
184
182
  const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
185
183
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
186
- if (!isHosted && apiKey && !explicitApiKey) throw new Error("Refusing to send a configured FIRECRAWL_API_KEY to a custom Firecrawl URL. Pass firecrawl_api_key explicitly for that URL.");
187
184
  if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
188
185
  return { baseUrl, apiKey, isHosted, timeoutMs };
189
186
  }
@@ -3,7 +3,7 @@
3
3
  // Endpoints: /crawl, /crawl/stream, /md, /screenshot, /pdf, /health
4
4
 
5
5
  import type { Crawl4aiConfig } from "./config";
6
- import { signalWithTimeout, withRetry } from "./retry";
6
+ import { signalWithTimeout } from "./retry";
7
7
 
8
8
  // ---------------------------------------------------------------------------
9
9
  // Error
@@ -52,9 +52,15 @@ export async function crawl4aiRequest(
52
52
  body?: unknown,
53
53
  signal?: AbortSignal,
54
54
  ): Promise<Record<string, unknown>> {
55
- return withRetry(async () => {
56
- return await crawl4aiRequestJson(config, method, endpoint, body, signal);
57
- }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
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");
58
64
  }
59
65
 
60
66
  // ---------------------------------------------------------------------------
@@ -142,36 +148,6 @@ export async function fetchCrawl4aiCrawl(
142
148
  };
143
149
  }
144
150
 
145
- /**
146
- * Crawl one or more URLs via POST /crawl/stream.
147
- * Returns newline-delimited JSON lines as a string.
148
- */
149
- export async function fetchCrawl4aiStream(
150
- config: Crawl4aiConfig,
151
- urls: string[],
152
- browserConfig?: Record<string, unknown>,
153
- crawlerConfig?: Record<string, unknown>,
154
- signal?: AbortSignal,
155
- ): Promise<string> {
156
- const body: Record<string, unknown> = { urls };
157
- if (browserConfig) body.browser_config = browserConfig;
158
- if (crawlerConfig) body.crawler_config = crawlerConfig;
159
- const headers: Record<string, string> = { Accept: "application/json" };
160
- if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
161
- headers["Content-Type"] = "application/json";
162
- const response = await fetch(`${config.baseUrl}/crawl/stream`, {
163
- method: "POST",
164
- headers,
165
- body: JSON.stringify(body),
166
- signal: signalWithTimeout(config.timeoutMs, signal),
167
- });
168
- if (!response.ok) {
169
- const text = await response.text();
170
- throw new Crawl4aiHttpError(response.status, response.statusText, text);
171
- }
172
- return await response.text();
173
- }
174
-
175
151
  /**
176
152
  * Capture a screenshot via POST /screenshot.
177
153
  */
@@ -206,17 +182,7 @@ export async function fetchCrawl4aiHealth(
206
182
  config: Crawl4aiConfig,
207
183
  signal?: AbortSignal,
208
184
  ): Promise<{ status?: string; version?: string; timestamp?: number }> {
209
- const headers: Record<string, string> = { Accept: "application/json" };
210
- if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
211
- const timeoutMs = Math.min(config.timeoutMs, 10000);
212
- const response = await fetch(`${config.baseUrl}/health`, {
213
- method: "GET",
214
- headers,
215
- signal: signalWithTimeout(timeoutMs, signal),
216
- });
217
- const text = await response.text();
218
- if (!response.ok) throw new Crawl4aiHttpError(response.status, response.statusText, text);
219
- const data = text ? JSON.parse(text) : {};
185
+ const data = await crawl4aiRequest({ ...config, timeoutMs: Math.min(config.timeoutMs, 10000) }, "GET", "/health", undefined, signal);
220
186
  return {
221
187
  status: data.status as string | undefined,
222
188
  version: data.version as string | undefined,
@@ -111,7 +111,7 @@ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknow
111
111
  ...(params.wait_for ? { waitFor: params.wait_for } : {}),
112
112
  ...(params.mobile ? { mobile: true } : {}),
113
113
  };
114
- const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, true, params.signal) as FirecrawlResult;
114
+ const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, params.signal) as FirecrawlResult;
115
115
  const structured = findStructuredPayload(result);
116
116
  const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
117
117
  const titleMatch = raw.match(/^# (.+)$/m);
@@ -183,7 +183,3 @@ export async function extractWithDiagnostics(params: ExtractParams): Promise<Ext
183
183
  );
184
184
  }
185
185
 
186
- /** Compatibility wrapper returning only the selected extraction result. */
187
- export async function universalExtract(params: ExtractParams): Promise<ExtractResult> {
188
- return (await extractWithDiagnostics(params)).result;
189
- }
@@ -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 } from "./retry";
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Types
@@ -53,34 +53,22 @@ async function firecrawlRequestJson(
53
53
  }
54
54
 
55
55
  /**
56
- * Firecrawl API request with optional v2→v1 fallback on 404.
57
- * Returns the response data and optionally logs fallback via console.warn.
56
+ * Firecrawl API request with retry.
58
57
  */
59
58
  export async function firecrawlRequest(
60
59
  config: FirecrawlConfig,
61
60
  method: string,
62
61
  endpoint: string,
63
62
  body?: unknown,
64
- allowFallback = true,
65
63
  signal?: AbortSignal,
66
64
  ): Promise<Record<string, unknown>> {
67
- return withRetry(async () => {
65
+ for (let attempt = 0; attempt < 3; attempt++) {
68
66
  try {
69
67
  return await firecrawlRequestJson(config, method, endpoint, body, signal);
70
68
  } catch (e) {
71
- if (
72
- !allowFallback ||
73
- config.isHosted ||
74
- !config.baseUrl.endsWith("/v2") ||
75
- !(e instanceof FirecrawlHttpError) ||
76
- e.status !== 404
77
- )
78
- throw e;
79
- const v1Config = { ...config, baseUrl: config.baseUrl.replace(/\/v2$/, "/v1") };
80
- console.warn(
81
- `[pi-web] Firecrawl v2 endpoint returned 404, falling back to v1 at ${v1Config.baseUrl}`,
82
- );
83
- return await firecrawlRequestJson(v1Config, method, endpoint, body, signal);
69
+ if (attempt === 2) throw e;
70
+ await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
84
71
  }
85
- }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
72
+ }
73
+ throw new Error("unreachable");
86
74
  }
@@ -1,6 +1,7 @@
1
1
  // Text formatting and result formatting helpers for pi-web.
2
2
 
3
- import { OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES } from "./config";
3
+ const OUTPUT_MAX_BYTES = 50 * 1024;
4
+ const OUTPUT_MAX_LINES = 2_000;
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Search result item types
@@ -47,28 +48,6 @@ export function truncateText(text: string): string {
47
48
  return output;
48
49
  }
49
50
 
50
- // ---------------------------------------------------------------------------
51
- // Search result formatting
52
- // ---------------------------------------------------------------------------
53
-
54
- export function formatSearchResults(results: SearchResultItem[]): string {
55
- if (!results.length) return "No results found.";
56
- return results
57
- .map((r, i) =>
58
- [
59
- `--- Result ${i + 1} ---`,
60
- `Title: ${r.title || ""}`,
61
- `Link: ${r.url || ""}`,
62
- r.age ? `Age: ${r.age}` : "",
63
- r.snippet || r.description ? `Snippet: ${r.snippet || r.description}` : "",
64
- r.content || r.markdown ? `Content:\n${r.content || r.markdown}` : "",
65
- ]
66
- .filter(Boolean)
67
- .join("\n"),
68
- )
69
- .join("\n\n");
70
- }
71
-
72
51
  // ---------------------------------------------------------------------------
73
52
  // Firecrawl scrape formatting
74
53
  // ---------------------------------------------------------------------------
@@ -175,51 +154,3 @@ export function formatCrawl4aiResult(data: Record<string, unknown>, maxChars = 2
175
154
  .join("\n\n---\n\n") || "(No data)";
176
155
  }
177
156
 
178
- export function formatCrawl4aiStream(lines: string, _maxChars = 20000): string {
179
- const results: string[] = [];
180
- for (const line of lines.split("\n")) {
181
- const trimmed = line.trim();
182
- if (!trimmed) continue;
183
- try {
184
- const parsed = JSON.parse(trimmed);
185
- if (parsed.status === "completed") {
186
- results.push("[Stream complete]");
187
- break;
188
- }
189
- results.push(formatCrawl4aiResult(parsed, _maxChars));
190
- } catch {
191
- results.push(trimmed.slice(0, 1000));
192
- }
193
- }
194
- return results.join("\n\n---\n\n");
195
- }
196
-
197
- export function formatFirecrawlSearch(data: Record<string, unknown>, maxChars = 5000): string {
198
- const rootData = data.data as Record<string, unknown> | undefined;
199
- const web = (rootData?.web as unknown[]) || [];
200
- const news = (rootData?.news as unknown[]) || [];
201
- const images = (rootData?.images as unknown[]) || [];
202
- const results: SearchResultItem[] = [...web, ...news].map((r: any) => ({
203
- title: r.title || r.metadata?.title || "",
204
- url: r.url || r.metadata?.sourceURL || "",
205
- snippet: r.description || r.snippet || "",
206
- markdown: r.markdown ? String(r.markdown).slice(0, maxChars) : "",
207
- }));
208
- let text = formatSearchResults(results);
209
- if (images.length) {
210
- text +=
211
- "\n\n" +
212
- images
213
- .map((img: any, i: number) =>
214
- [
215
- `--- Image ${i + 1} ---`,
216
- `Title: ${img.title || ""}`,
217
- `Image: ${img.imageUrl || ""}`,
218
- `Page: ${img.url || ""}`,
219
- ].join("\n"),
220
- )
221
- .join("\n\n");
222
- }
223
- if (data.warning) text += `\n\nWarning: ${data.warning}`;
224
- return text;
225
- }
@@ -0,0 +1,29 @@
1
+ // Retry utility: signal/timeout helpers.
2
+
3
+ /**
4
+ * Combine a timeout signal with an optional external signal.
5
+ */
6
+ export function signalWithTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal {
7
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
8
+ if (!signal) return timeoutSignal;
9
+ return AbortSignal.any([signal, timeoutSignal]);
10
+ }
11
+
12
+ /**
13
+ * Sleep that is abortable via an AbortSignal.
14
+ */
15
+ export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
16
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Operation aborted."));
17
+ return new Promise((resolve, reject) => {
18
+ const timer = setTimeout(cleanupResolve, ms);
19
+ function cleanupResolve() {
20
+ signal?.removeEventListener("abort", cleanupReject);
21
+ resolve();
22
+ }
23
+ function cleanupReject() {
24
+ clearTimeout(timer);
25
+ reject(signal?.reason ?? new Error("Operation aborted."));
26
+ }
27
+ signal?.addEventListener("abort", cleanupReject, { once: true });
28
+ });
29
+ }
@@ -144,7 +144,7 @@ async function searchBrave(params: SearchParams, backends: BackendConfig): Promi
144
144
 
145
145
  async function searchFirecrawl(params: SearchParams, backends: BackendConfig): Promise<SearchResult[]> {
146
146
  if (!backends.firecrawl.configured || !backends.firecrawl.config) {
147
- throw new Error("Firecrawl not configured (no FIRECRAWL_API_KEY/FIRECRAWL_API_URL)");
147
+ throw new Error("Firecrawl not configured (no FIRECRAWL_API_URL)");
148
148
  }
149
149
  const body: Record<string, unknown> = {
150
150
  query: params.query,
@@ -152,7 +152,7 @@ async function searchFirecrawl(params: SearchParams, backends: BackendConfig): P
152
152
  sources: [{ type: "web" }],
153
153
  ...(params.country ? { country: String(params.country).toUpperCase() } : {}),
154
154
  };
155
- const result = await firecrawlRequest(backends.firecrawl.config, "POST", "/search", body, true, params.signal) as FirecrawlResult;
155
+ const result = await firecrawlRequest(backends.firecrawl.config, "POST", "/search", body, params.signal) as FirecrawlResult;
156
156
  const rootData = result.data as Record<string, unknown> | undefined;
157
157
  const web = (rootData?.web as unknown[]) || [];
158
158
  const news = (rootData?.news as unknown[]) || [];
@@ -238,7 +238,3 @@ export async function searchWithDiagnostics(params: SearchParams): Promise<Searc
238
238
  );
239
239
  }
240
240
 
241
- /** Compatibility wrapper returning only results. */
242
- export async function universalSearch(params: SearchParams): Promise<SearchResult[]> {
243
- return (await searchWithDiagnostics(params)).results;
244
- }
@@ -0,0 +1,69 @@
1
+ // SearXNG metasearch client.
2
+
3
+ import { sanitizeSnippet } from "./format";
4
+ import { signalWithTimeout } from "./retry";
5
+
6
+ export interface SearxngResultItem {
7
+ title: string;
8
+ url: string;
9
+ snippet: string;
10
+ engine: string;
11
+ category: string;
12
+ score?: number;
13
+ publishedDate: string;
14
+ }
15
+
16
+ export interface SearxngResponse {
17
+ results: SearxngResultItem[];
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ export async function fetchSearxngResults(
22
+ params: Record<string, unknown>,
23
+ baseUrl: string,
24
+ signal?: AbortSignal,
25
+ ): Promise<SearxngResponse> {
26
+ for (let attempt = 0; attempt < 3; attempt++) {
27
+ try {
28
+ const limit = Math.min(50, Math.max(1, (params.count as number) ?? 5));
29
+ const url = new URL(`${baseUrl}/search`);
30
+ url.searchParams.set("q", params.query as string);
31
+ url.searchParams.set("format", "json");
32
+ url.searchParams.set("pageno", String(Math.max(1, (params.pageno as number) ?? 1)));
33
+ for (const key of ["categories", "engines", "language", "time_range", "safesearch"] as const) {
34
+ const val = params[key];
35
+ if (val !== undefined && val !== "") url.searchParams.set(key, String(val));
36
+ }
37
+ const timeoutMs = (params.timeout_ms as number) || 15000;
38
+ const response = await fetch(url, {
39
+ headers: { Accept: "application/json", "User-Agent": "pi-web/0.1 SearXNG" },
40
+ signal: signalWithTimeout(timeoutMs, signal),
41
+ });
42
+ const text = await response.text();
43
+ if (!response.ok) {
44
+ const hint =
45
+ response.status === 403
46
+ ? "\nHint: enable JSON output in SearXNG settings.yml with search.formats including json."
47
+ : "";
48
+ throw new Error(`HTTP ${response.status}: ${response.statusText}${hint}${text ? `\n${text}` : ""}`);
49
+ }
50
+ const data: any = text ? JSON.parse(text) : {};
51
+ const results = (data.results || [])
52
+ .slice(0, limit)
53
+ .map((r: any) => ({
54
+ title: sanitizeSnippet(r.title || ""),
55
+ url: r.url || "",
56
+ snippet: sanitizeSnippet(r.content || r.snippet || ""),
57
+ engine: r.engine || "",
58
+ category: r.category || "",
59
+ score: r.score,
60
+ publishedDate: r.publishedDate || r.published_date || "",
61
+ }));
62
+ return { ...data, results };
63
+ } catch (e) {
64
+ if (attempt === 2) throw e;
65
+ await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
66
+ }
67
+ }
68
+ throw new Error("unreachable");
69
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }