@bacnh85/pi-web 0.4.3 → 0.4.5

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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -446,29 +447,4 @@ export default function piWebExtension(pi: ExtensionAPI) {
446
447
  },
447
448
  });
448
449
 
449
- // ── /web-status command ──────────────────────────────────────────────
450
- pi.registerCommand("web-status", {
451
- description: "Show web provider configuration status",
452
- handler: async (_args: string, ctx: any) => {
453
- const cwd = cwdFromContext(ctx);
454
- const trusted = includeProjectEnv(ctx);
455
- const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
456
- const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
457
- const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
458
- const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
459
- const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
460
- const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
461
- ctx.ui.notify(
462
- [
463
- `Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
464
- `SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
465
- `Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
466
- `Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
467
- `Crawl4AI API URL: ${normalizeCrawl4aiApiUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
468
- `Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
469
- ].join("\n"),
470
- "info",
471
- );
472
- },
473
- });
474
450
  }
@@ -1,6 +1,7 @@
1
1
  // Brave Search API client.
2
2
 
3
3
  import { sanitizeSnippet, type SearchResultItem } from "./format";
4
+ import { withRetry } from "./retry";
4
5
 
5
6
  export interface BraveResult extends SearchResultItem {
6
7
  age: string;
@@ -14,32 +15,26 @@ export async function fetchBraveResults(
14
15
  apiKey: string,
15
16
  signal?: AbortSignal,
16
17
  ): 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");
18
+ return withRetry(async () => {
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
+ });
45
40
  }
@@ -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";
@@ -87,6 +87,20 @@ export function includeProjectEnv(ctx: Record<string, unknown>): boolean {
87
87
  return typeof ctx?.isProjectTrusted === "function" ? (ctx.isProjectTrusted as () => boolean)() : false;
88
88
  }
89
89
 
90
+ // ---------------------------------------------------------------------------
91
+ // URL helpers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function normalizeHttpUrl(raw: string | undefined, fallback: string): string {
95
+ let value = (raw || fallback).trim();
96
+ if (!/^https?:\/\//i.test(value)) {
97
+ value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
98
+ ? `http://${value}`
99
+ : `https://${value}`;
100
+ }
101
+ return value.replace(/\/+$/, "");
102
+ }
103
+
90
104
  // ---------------------------------------------------------------------------
91
105
  // SearXNG config
92
106
  // ---------------------------------------------------------------------------
@@ -97,13 +111,7 @@ export interface SearxngConfig {
97
111
  }
98
112
 
99
113
  export function normalizeSearxngBaseUrl(raw?: string): string {
100
- let value = (raw || DEFAULT_SEARXNG_BASE_URL).trim();
101
- if (!/^https?:\/\//i.test(value)) {
102
- value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
103
- ? `http://${value}`
104
- : `https://${value}`;
105
- }
106
- return value.replace(/\/+$/, "");
114
+ return normalizeHttpUrl(raw, DEFAULT_SEARXNG_BASE_URL);
107
115
  }
108
116
 
109
117
  export function loadSearxngConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): SearxngConfig {
@@ -125,13 +133,7 @@ export interface Crawl4aiConfig {
125
133
  }
126
134
 
127
135
  export function normalizeCrawl4aiApiUrl(raw?: string): string {
128
- let value = (raw || DEFAULT_CRAWL4AI_API_URL).trim();
129
- if (!/^https?:\/\//i.test(value)) {
130
- value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
131
- ? `http://${value}`
132
- : `https://${value}`;
133
- }
134
- return value.replace(/\/+$/, "");
136
+ return normalizeHttpUrl(raw, DEFAULT_CRAWL4AI_API_URL);
135
137
  }
136
138
 
137
139
  export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): Crawl4aiConfig {
@@ -159,13 +161,7 @@ export interface FirecrawlConfig {
159
161
  }
160
162
 
161
163
  export function normalizeFirecrawlBaseUrl(raw?: string): string {
162
- let value = (raw || HOSTED_FIRECRAWL_BASE_URL).trim();
163
- if (!/^https?:\/\//i.test(value)) {
164
- value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
165
- ? `http://${value}`
166
- : `https://${value}`;
167
- }
168
- value = value.replace(/\/+$/, "");
164
+ let value = normalizeHttpUrl(raw, HOSTED_FIRECRAWL_BASE_URL);
169
165
  if (!/\/v\d+$/i.test(value)) value += "/v2";
170
166
  return value;
171
167
  }
@@ -181,7 +177,6 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
181
177
  const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
182
178
  const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
183
179
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
184
- 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.");
185
180
  if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
186
181
  return { baseUrl, apiKey, isHosted, timeoutMs };
187
182
  }
@@ -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 } from "./retry";
4
+ import { signalWithTimeout, withRetry } from "./retry";
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Types
@@ -62,13 +62,5 @@ export async function firecrawlRequest(
62
62
  body?: unknown,
63
63
  signal?: AbortSignal,
64
64
  ): Promise<Record<string, unknown>> {
65
- for (let attempt = 0; attempt < 3; attempt++) {
66
- try {
67
- return await firecrawlRequestJson(config, method, endpoint, body, signal);
68
- } catch (e) {
69
- if (attempt === 2) throw e;
70
- await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
71
- }
72
- }
73
- throw new Error("unreachable");
65
+ return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal));
74
66
  }
@@ -1,5 +1,18 @@
1
1
  // Retry utility: signal/timeout helpers.
2
2
 
3
+ export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
4
+ let last: unknown;
5
+ for (let attempt = 0; attempt < attempts; attempt++) {
6
+ try { return await fn(); }
7
+ catch (error) {
8
+ last = error;
9
+ if (attempt === attempts - 1) break;
10
+ await abortableSleep(1000 * 2 ** attempt);
11
+ }
12
+ }
13
+ throw last ?? new Error("retry failed");
14
+ }
15
+
3
16
  /**
4
17
  * Combine a timeout signal with an optional external signal.
5
18
  */
@@ -1,7 +1,7 @@
1
1
  // SearXNG metasearch client.
2
2
 
3
3
  import { sanitizeSnippet } from "./format";
4
- import { signalWithTimeout } from "./retry";
4
+ import { signalWithTimeout, withRetry } from "./retry";
5
5
 
6
6
  export interface SearxngResultItem {
7
7
  title: string;
@@ -23,47 +23,41 @@ export async function fetchSearxngResults(
23
23
  baseUrl: string,
24
24
  signal?: AbortSignal,
25
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)));
26
+ return withRetry(async () => {
27
+ const limit = Math.min(50, Math.max(1, (params.count as number) ?? 5));
28
+ const url = new URL(`${baseUrl}/search`);
29
+ url.searchParams.set("q", params.query as string);
30
+ url.searchParams.set("format", "json");
31
+ url.searchParams.set("pageno", String(Math.max(1, (params.pageno as number) ?? 1)));
32
+ for (const key of ["categories", "engines", "language", "time_range", "safesearch"] as const) {
33
+ const val = params[key];
34
+ if (val !== undefined && val !== "") url.searchParams.set(key, String(val));
66
35
  }
67
- }
68
- throw new Error("unreachable");
36
+ const timeoutMs = (params.timeout_ms as number) || 15000;
37
+ const response = await fetch(url, {
38
+ headers: { Accept: "application/json", "User-Agent": "pi-web/0.1 SearXNG" },
39
+ signal: signalWithTimeout(timeoutMs, signal),
40
+ });
41
+ const text = await response.text();
42
+ if (!response.ok) {
43
+ const hint =
44
+ response.status === 403
45
+ ? "\nHint: enable JSON output in SearXNG settings.yml with search.formats including json."
46
+ : "";
47
+ throw new Error(`HTTP ${response.status}: ${response.statusText}${hint}${text ? `\n${text}` : ""}`);
48
+ }
49
+ const data: any = text ? JSON.parse(text) : {};
50
+ const results = (data.results || [])
51
+ .slice(0, limit)
52
+ .map((r: any) => ({
53
+ title: sanitizeSnippet(r.title || ""),
54
+ url: r.url || "",
55
+ snippet: sanitizeSnippet(r.content || r.snippet || ""),
56
+ engine: r.engine || "",
57
+ category: r.category || "",
58
+ score: r.score,
59
+ publishedDate: r.publishedDate || r.published_date || "",
60
+ }));
61
+ return { ...data, results };
62
+ });
69
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",