@bacnh85/pi-web 0.5.0 → 0.5.2

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
  }
@@ -172,7 +172,7 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
172
172
  const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
173
173
  const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
174
174
  const apiKey = explicitApiKey || apiKeyLookup.value || "";
175
- const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd).value;
175
+ const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd, includeCwdEnv).value;
176
176
  const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
177
177
  const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
178
178
  const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
@@ -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
  );
@@ -181,11 +176,20 @@ export async function extractWithDiagnostics(params: ExtractParams): Promise<Ext
181
176
  }
182
177
  }
183
178
 
184
- const diagnostics = attempts.map((a) => `${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
185
- const mode = params.mode ?? "auto";
186
- throw new Error(
187
- `Extraction failed for ${params.url} with mode="${mode}".\n${diagnostics}\n` +
188
- "Try a different mode or use web_screenshot for a visual snapshot.",
189
- );
179
+ // All modes exhausted return the best attempt with diagnostics instead of throwing
180
+ const lastAttempt = attempts[attempts.length - 1];
181
+ const bestResult: ExtractResult = {
182
+ title: "",
183
+ markdown: `[All extraction modes failed for ${params.url}]\n\n` +
184
+ attempts.map((a) => ` ${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n") +
185
+ "\n\nTry web_screenshot for a visual snapshot, or verify the URL is accessible.",
186
+ backend: lastAttempt?.backend ?? "none",
187
+ };
188
+ return {
189
+ result: bestResult,
190
+ attempts,
191
+ selectedMode: lastAttempt?.mode ?? "static",
192
+ fallbackUsed: true,
193
+ };
190
194
  }
191
195
 
@@ -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,6 +36,18 @@ 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
53
  let output = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
@@ -48,6 +60,37 @@ export function truncateText(text: string): string {
48
60
  return output;
49
61
  }
50
62
 
63
+ // ---------------------------------------------------------------------------
64
+ // Unified search result formatting
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface UnifiedSearchResult {
68
+ title: string;
69
+ url: string;
70
+ snippet: string;
71
+ age: string;
72
+ content: string;
73
+ backend: string;
74
+ }
75
+
76
+ export function formatUnifiedSearchResults(results: UnifiedSearchResult[]): string {
77
+ if (!results.length) return "No results found.";
78
+ return results
79
+ .map((r, i) =>
80
+ [
81
+ `--- Result ${i + 1} (backend: ${r.backend}) ---`,
82
+ `Title: ${r.title || ""}`,
83
+ `Link: ${r.url || ""}`,
84
+ r.age ? `Age: ${r.age}` : "",
85
+ r.snippet ? `Snippet: ${r.snippet}` : "",
86
+ r.content ? `Content:\n${r.content}` : "",
87
+ ]
88
+ .filter(Boolean)
89
+ .join("\n"),
90
+ )
91
+ .join("\n\n");
92
+ }
93
+
51
94
  // ---------------------------------------------------------------------------
52
95
  // Firecrawl scrape formatting
53
96
  // ---------------------------------------------------------------------------
@@ -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";
@@ -187,15 +187,6 @@ export function selectSearchBackendOrder(params: SearchParams): SearchBackend[]
187
187
  return ["searxng", "brave", "firecrawl"];
188
188
  }
189
189
 
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
190
  function isConfigured(backend: SearchBackend, backends: BackendConfig): boolean {
200
191
  if (backend === "searxng") return backends.searxng.configured;
201
192
  if (backend === "brave") return backends.brave.configured;
@@ -231,10 +222,6 @@ export async function searchWithDiagnostics(params: SearchParams): Promise<Searc
231
222
  }
232
223
  }
233
224
 
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
- );
225
+ return { results: [], attempts, selectedBackend: "" as const, backendOrder };
239
226
  }
240
227
 
@@ -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.0",
3
+ "version": "0.5.2",
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",
@@ -24,7 +24,7 @@
24
24
  "crawling"
25
25
  ],
26
26
  "scripts": {
27
- "test": "cd extensions && mocha"
27
+ "test": "cd extensions && npx mocha"
28
28
  },
29
29
  "files": [
30
30
  "README.md",