@bacnh85/pi-web 0.1.2 → 0.2.1

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.
package/lib/brave.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Brave Search API client.
2
+
3
+ import { sanitizeSnippet, type SearchResultItem } from "./format";
4
+ import { withRetry } from "./retry";
5
+
6
+ export interface BraveResult extends SearchResultItem {
7
+ age: string;
8
+ }
9
+
10
+ export async function fetchBraveResults(
11
+ query: string,
12
+ count: number,
13
+ country: string,
14
+ freshness: string,
15
+ apiKey: string,
16
+ signal?: AbortSignal,
17
+ ): Promise<BraveResult[]> {
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
+ }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
40
+ }
package/lib/config.ts ADDED
@@ -0,0 +1,155 @@
1
+ // Configuration loading and environment helpers for pi-web.
2
+
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Constants
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const OUTPUT_MAX_BYTES = 50 * 1024;
12
+ export const OUTPUT_MAX_LINES = 2_000;
13
+ export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
14
+ export const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Environment loading
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export function piConfigDirs(): string[] {
21
+ return process.env.PI_CODING_AGENT_DIR
22
+ ? [process.env.PI_CODING_AGENT_DIR]
23
+ : [path.join(os.homedir(), ".pi", "agent")];
24
+ }
25
+
26
+ export function envFileCandidates(cwd = process.cwd(), includeCwd = true): string[] {
27
+ return [
28
+ ...(includeCwd ? [path.resolve(cwd, ".env.local"), path.resolve(cwd, ".env")] : []),
29
+ ...piConfigDirs().flatMap((dir) => [path.join(dir, ".env.local"), path.join(dir, ".env")]),
30
+ ];
31
+ }
32
+
33
+ export function stripInlineComment(value: string): string {
34
+ let quote = "";
35
+ let escaped = false;
36
+ for (let i = 0; i < value.length; i++) {
37
+ const char = value[i];
38
+ if (escaped) { escaped = false; continue; }
39
+ if (char === "\\") { escaped = true; continue; }
40
+ if (quote) { if (char === quote) quote = ""; continue; }
41
+ if (char === '"' || char === "'") { quote = char; continue; }
42
+ if (char === "#" && (i === 0 || /\s/.test(value[i - 1]))) return value.slice(0, i);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ export function parseDotenvValue(rawValue: string): string {
48
+ let value = stripInlineComment(rawValue).trim();
49
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
50
+ value = value.slice(1, -1);
51
+ return value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
52
+ }
53
+ return value.trim();
54
+ }
55
+
56
+ export function parseDotenvFile(file: string): Record<string, string> | null {
57
+ let text: string;
58
+ try { text = fs.readFileSync(file, "utf8"); } catch (e: any) { if (e?.code === "ENOENT") return null; throw e; }
59
+ const values: Record<string, string> = {};
60
+ for (const rawLine of text.split(/\r?\n/)) {
61
+ const line = rawLine.trim();
62
+ if (!line || line.startsWith("#")) continue;
63
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
64
+ if (match) values[match[1]] = parseDotenvValue(match[2]);
65
+ }
66
+ return values;
67
+ }
68
+
69
+ export function findEnvValue(name: string, cwd = process.cwd(), includeCwd = true): { value?: string; source: string; checkedFiles: string[] } {
70
+ if (process.env[name]) return { value: process.env[name], source: "process.env", checkedFiles: [] };
71
+ const checkedFiles = envFileCandidates(cwd, includeCwd);
72
+ for (const file of checkedFiles) {
73
+ const parsed = parseDotenvFile(file);
74
+ if (parsed?.[name]) return { value: parsed[name], source: file, checkedFiles };
75
+ }
76
+ return { value: undefined, source: "", checkedFiles };
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Context helpers
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export function cwdFromContext(ctx: Record<string, unknown>): string {
84
+ return typeof ctx?.cwd === "string" && (ctx.cwd as string) ? (ctx.cwd as string) : process.cwd();
85
+ }
86
+
87
+ export function includeProjectEnv(ctx: Record<string, unknown>): boolean {
88
+ return typeof ctx?.isProjectTrusted === "function" ? (ctx.isProjectTrusted as () => boolean)() : false;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // SearXNG config
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export interface SearxngConfig {
96
+ baseUrl: string;
97
+ source: string;
98
+ }
99
+
100
+ export function normalizeSearxngBaseUrl(raw?: string): string {
101
+ let value = (raw || DEFAULT_SEARXNG_BASE_URL).trim();
102
+ if (!/^https?:\/\//i.test(value)) {
103
+ value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
104
+ ? `http://${value}`
105
+ : `https://${value}`;
106
+ }
107
+ return value.replace(/\/+$/, "");
108
+ }
109
+
110
+ export function loadSearxngConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): SearxngConfig {
111
+ const baseUrlEnv = findEnvValue("SEARXNG_BASE_URL", cwd, includeCwdEnv);
112
+ const explicitBaseUrl = params.searxng_base_url as string | undefined;
113
+ const baseUrl = normalizeSearxngBaseUrl(explicitBaseUrl || (params.base_url as string) || baseUrlEnv.value);
114
+ const source = explicitBaseUrl || params.base_url ? "tool parameter" : baseUrlEnv.source || "default local";
115
+ return { baseUrl, source };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Firecrawl config
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export interface FirecrawlConfig {
123
+ baseUrl: string;
124
+ apiKey: string;
125
+ isHosted: boolean;
126
+ timeoutMs: number;
127
+ }
128
+
129
+ export function normalizeFirecrawlBaseUrl(raw?: string): string {
130
+ let value = (raw || HOSTED_FIRECRAWL_BASE_URL).trim();
131
+ if (!/^https?:\/\//i.test(value)) {
132
+ value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
133
+ ? `http://${value}`
134
+ : `https://${value}`;
135
+ }
136
+ value = value.replace(/\/+$/, "");
137
+ if (!/\/v\d+$/i.test(value)) value += "/v2";
138
+ return value;
139
+ }
140
+
141
+ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): FirecrawlConfig {
142
+ const apiUrlLookup = findEnvValue("FIRECRAWL_API_URL", cwd, includeCwdEnv);
143
+ const apiKeyLookup = findEnvValue("FIRECRAWL_API_KEY", cwd, includeCwdEnv);
144
+ const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
145
+ const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
146
+ const apiKey = explicitApiKey || apiKeyLookup.value || "";
147
+ const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd).value;
148
+ const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
149
+ const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
150
+ const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
151
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
152
+ 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.");
153
+ if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
154
+ return { baseUrl, apiKey, isHosted, timeoutMs };
155
+ }
package/lib/content.ts ADDED
@@ -0,0 +1,78 @@
1
+ // Readable content extraction using JSDOM + Readability + Turndown.
2
+
3
+ import { createRequire } from "node:module";
4
+ import { signalWithTimeout } from "./retry";
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ export interface ReadableContentDeps {
9
+ Readability: any;
10
+ JSDOM: any;
11
+ TurndownService: any;
12
+ gfm: unknown;
13
+ }
14
+
15
+ export function loadReadableContentDependencies(): ReadableContentDeps {
16
+ try {
17
+ return {
18
+ Readability: require("@mozilla/readability").Readability,
19
+ JSDOM: require("jsdom").JSDOM,
20
+ TurndownService: require("turndown"),
21
+ gfm: require("turndown-plugin-gfm").gfm,
22
+ };
23
+ } catch (error: any) {
24
+ throw new Error(
25
+ `Readable content extraction dependencies are missing. Install pi-web dependencies with \`npm install\` in the pi-web extension directory, or install the package with Pi so runtime dependencies are installed. Original error: ${error?.message || error}`,
26
+ );
27
+ }
28
+ }
29
+
30
+ export function htmlToMarkdown(html: string, deps = loadReadableContentDependencies()): string {
31
+ const turndown = new deps.TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
32
+ turndown.use(deps.gfm as any);
33
+ turndown.addRule("removeEmptyLinks", {
34
+ filter: (node: any) => node.nodeName === "A" && !node.textContent?.trim(),
35
+ replacement: () => "",
36
+ });
37
+ return turndown
38
+ .turndown(html)
39
+ .replace(/\[\\?\[\s*\\?\]\]\([^)]*\)/g, "")
40
+ .replace(/ +/g, " ")
41
+ .replace(/\s+,/g, ",")
42
+ .replace(/\s+\./g, ".")
43
+ .replace(/\n{3,}/g, "\n\n")
44
+ .trim();
45
+ }
46
+
47
+ export async function fetchReadableContent(
48
+ url: string,
49
+ timeoutMs = 15000,
50
+ signal?: AbortSignal,
51
+ ): Promise<{ title: string; markdown: string }> {
52
+ const response = await fetch(url, {
53
+ headers: {
54
+ "User-Agent":
55
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36",
56
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
57
+ "Accept-Language": "en-US,en;q=0.9",
58
+ },
59
+ signal: signalWithTimeout(timeoutMs, signal),
60
+ });
61
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
62
+ const html = await response.text();
63
+ const deps = loadReadableContentDependencies();
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) };
68
+ }
69
+ // Fallback: strip non-content elements and use main/article/body
70
+ 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) };
78
+ }
@@ -0,0 +1,86 @@
1
+ // Firecrawl API client (search, scrape, map, crawl).
2
+
3
+ import type { FirecrawlConfig } from "./config";
4
+ import { signalWithTimeout, withRetry } from "./retry";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface FirecrawlResult {
11
+ success: boolean;
12
+ data?: Record<string, unknown>;
13
+ warning?: string;
14
+ id?: string;
15
+ [key: string]: unknown;
16
+ }
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
+ // ---------------------------------------------------------------------------
31
+ // Low-level HTTP helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ async function firecrawlRequestJson(
35
+ config: FirecrawlConfig,
36
+ method: string,
37
+ endpoint: string,
38
+ body?: unknown,
39
+ signal?: AbortSignal,
40
+ ): Promise<Record<string, unknown>> {
41
+ const headers: Record<string, string> = { Accept: "application/json" };
42
+ if (body !== undefined) headers["Content-Type"] = "application/json";
43
+ if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
44
+ const response = await fetch(`${config.baseUrl}${endpoint}`, {
45
+ method,
46
+ headers,
47
+ body: body === undefined ? undefined : JSON.stringify(body),
48
+ signal: signalWithTimeout(config.timeoutMs, signal),
49
+ });
50
+ const text = await response.text();
51
+ if (!response.ok) throw new FirecrawlHttpError(response.status, response.statusText, text);
52
+ return text ? JSON.parse(text) : { success: true };
53
+ }
54
+
55
+ /**
56
+ * Firecrawl API request with optional v2→v1 fallback on 404.
57
+ * Returns the response data and optionally logs fallback via console.warn.
58
+ */
59
+ export async function firecrawlRequest(
60
+ config: FirecrawlConfig,
61
+ method: string,
62
+ endpoint: string,
63
+ body?: unknown,
64
+ allowFallback = true,
65
+ signal?: AbortSignal,
66
+ ): Promise<Record<string, unknown>> {
67
+ return withRetry(async () => {
68
+ try {
69
+ return await firecrawlRequestJson(config, method, endpoint, body, signal);
70
+ } 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);
84
+ }
85
+ }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
86
+ }
package/lib/format.ts ADDED
@@ -0,0 +1,121 @@
1
+ // Text formatting and result formatting helpers for pi-web.
2
+
3
+ import { OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES } from "./config";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Search result item types
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface SearchResultItem {
10
+ title: string;
11
+ url: string;
12
+ snippet?: string;
13
+ age?: string;
14
+ description?: string;
15
+ content?: string;
16
+ markdown?: string;
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Text utilities
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export function sanitizeSnippet(text = ""): string {
24
+ return text
25
+ .replace(/<[^>]*>/g, "")
26
+ .replace(/&nbsp;/gi, " ")
27
+ .replace(/&amp;/gi, "&")
28
+ .replace(/&lt;/gi, "<")
29
+ .replace(/&gt;/gi, ">")
30
+ .replace(/&quot;/gi, '"')
31
+ .replace(/&#39;|&#x27;/gi, "'")
32
+ .replace(/&#(\d+);/g, (_: string, code: string) => String.fromCodePoint(Number(code)))
33
+ .replace(/&#x([0-9a-f]+);/gi, (_: string, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
34
+ .replace(/\s+/g, " ")
35
+ .trim();
36
+ }
37
+
38
+ export function truncateText(text: string): string {
39
+ const lines = text.split("\n");
40
+ let output = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
41
+ while (Buffer.byteLength(output, "utf8") > OUTPUT_MAX_BYTES) {
42
+ output = output.slice(0, Math.max(0, output.length - 1024));
43
+ }
44
+ if (lines.length > OUTPUT_MAX_LINES || output.length < text.length) {
45
+ output += `\n\n[Web output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
46
+ }
47
+ return output;
48
+ }
49
+
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
+ // ---------------------------------------------------------------------------
73
+ // Firecrawl scrape formatting
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export function formatFirecrawlScrape(data: Record<string, unknown>, maxChars = 20000): string {
77
+ const page = (data.data as Record<string, unknown>) || data;
78
+ const meta = (page.metadata as Record<string, unknown>) || {};
79
+ const parts: string[] = [];
80
+ if (meta.title) parts.push(`# ${meta.title}`);
81
+ if (meta.sourceURL || meta.url) parts.push(`Source: ${(meta.sourceURL || meta.url) as string}`);
82
+ if (meta.statusCode) parts.push(`Status: ${meta.statusCode}`);
83
+ const warning = (data.warning || page.warning) as string | undefined;
84
+ if (warning) parts.push(`Warning: ${warning}`);
85
+ const body = (page.markdown || page.summary || page.answer || page.html || page.rawHtml) as string | undefined;
86
+ if (body) parts.push(String(body).slice(0, maxChars));
87
+ if (Array.isArray(page.links)) {
88
+ parts.push(["## Links", ...(page.links as string[]).slice(0, 100).map((l: string) => `- ${l}`)].join("\n"));
89
+ }
90
+ return parts.join("\n\n") || JSON.stringify(data, null, 2);
91
+ }
92
+
93
+ export function formatFirecrawlSearch(data: Record<string, unknown>, maxChars = 5000): string {
94
+ const rootData = data.data as Record<string, unknown> | undefined;
95
+ const web = (rootData?.web as unknown[]) || [];
96
+ const news = (rootData?.news as unknown[]) || [];
97
+ const images = (rootData?.images as unknown[]) || [];
98
+ const results: SearchResultItem[] = [...web, ...news].map((r: any) => ({
99
+ title: r.title || r.metadata?.title || "",
100
+ url: r.url || r.metadata?.sourceURL || "",
101
+ snippet: r.description || r.snippet || "",
102
+ markdown: r.markdown ? String(r.markdown).slice(0, maxChars) : "",
103
+ }));
104
+ let text = formatSearchResults(results);
105
+ if (images.length) {
106
+ text +=
107
+ "\n\n" +
108
+ images
109
+ .map((img: any, i: number) =>
110
+ [
111
+ `--- Image ${i + 1} ---`,
112
+ `Title: ${img.title || ""}`,
113
+ `Image: ${img.imageUrl || ""}`,
114
+ `Page: ${img.url || ""}`,
115
+ ].join("\n"),
116
+ )
117
+ .join("\n\n");
118
+ }
119
+ if (data.warning) text += `\n\nWarning: ${data.warning}`;
120
+ return text;
121
+ }
package/lib/retry.ts ADDED
@@ -0,0 +1,142 @@
1
+ // Retry logic with exponential backoff for transient network failures.
2
+ // Ported from pi-munin/lib/retry.ts.
3
+
4
+ export interface RetryOptions {
5
+ maxRetries?: number;
6
+ initialDelay?: number;
7
+ maxDelay?: number;
8
+ multiplier?: number;
9
+ retryableErrors?: string[];
10
+ onRetry?: (attempt: number, max: number, delay: number, error: Error) => void;
11
+ }
12
+
13
+ function isRetryableError(error: Error): boolean {
14
+ const errorMessage = (error.message || "").toLowerCase();
15
+ const retryablePatterns = [
16
+ "econnrefused",
17
+ "enotfound",
18
+ "etimedout",
19
+ "econnreset",
20
+ "econnaborted",
21
+ "enetunreach",
22
+ "etimeout",
23
+ "timeout",
24
+ "network",
25
+ "socket hang up",
26
+ "socket",
27
+ "connection",
28
+ "fetch",
29
+ "request timeout",
30
+ ];
31
+ return retryablePatterns.some((pattern) => errorMessage.includes(pattern));
32
+ }
33
+
34
+ function sleep(ms: number): Promise<void> {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ function calculateDelay(
39
+ attempt: number,
40
+ initialDelay: number,
41
+ maxDelay: number,
42
+ multiplier: number,
43
+ ): number {
44
+ const exponentialDelay = initialDelay * Math.pow(multiplier, attempt);
45
+ const jitter = Math.random() * 0.1 * exponentialDelay;
46
+ return Math.min(exponentialDelay + jitter, maxDelay);
47
+ }
48
+
49
+ /**
50
+ * Retry a function with exponential backoff.
51
+ * Never retries auth, not_found, or validation errors.
52
+ */
53
+ export async function withRetry<T>(
54
+ fn: () => Promise<T>,
55
+ options: RetryOptions = {},
56
+ ): Promise<T> {
57
+ const {
58
+ maxRetries = 3,
59
+ initialDelay = 1000,
60
+ maxDelay = 10000,
61
+ multiplier = 2,
62
+ retryableErrors = [],
63
+ onRetry,
64
+ } = options;
65
+
66
+ // Errors we never retry (non-transient)
67
+ const nonRetryablePatterns = [
68
+ "unauthorized",
69
+ "invalid api key",
70
+ "not found",
71
+ "validation",
72
+ "required",
73
+ "refusing to send",
74
+ "refusing to send a configured",
75
+ "must use http or https",
76
+ "could not extract readable content",
77
+ "brave_api_key is required",
78
+ "firecrawl_api_key is required",
79
+ ];
80
+
81
+ let lastError: Error;
82
+
83
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
84
+ try {
85
+ return await fn();
86
+ } catch (error) {
87
+ lastError = error as Error;
88
+
89
+ if (attempt === maxRetries) throw error;
90
+
91
+ // Never retry non-transient errors
92
+ const msg = lastError.message.toLowerCase();
93
+ if (nonRetryablePatterns.some((p) => msg.includes(p))) throw error;
94
+
95
+ // Check if error is retryable
96
+ const shouldRetry =
97
+ retryableErrors.length > 0
98
+ ? retryableErrors.some((pattern) => msg.includes(pattern.toLowerCase()))
99
+ : isRetryableError(lastError);
100
+
101
+ if (!shouldRetry) throw error;
102
+
103
+ const delay = calculateDelay(attempt, initialDelay, maxDelay, multiplier);
104
+
105
+ if (onRetry) {
106
+ onRetry(attempt + 1, maxRetries + 1, delay, lastError);
107
+ }
108
+
109
+ await sleep(delay);
110
+ }
111
+ }
112
+
113
+ throw lastError!;
114
+ }
115
+
116
+ /**
117
+ * Combine a timeout signal with an optional external signal.
118
+ */
119
+ export function signalWithTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal {
120
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
121
+ if (!signal) return timeoutSignal;
122
+ return AbortSignal.any([signal, timeoutSignal]);
123
+ }
124
+
125
+ /**
126
+ * Sleep that is abortable via an AbortSignal.
127
+ */
128
+ export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
129
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Operation aborted."));
130
+ return new Promise((resolve, reject) => {
131
+ const timer = setTimeout(cleanupResolve, ms);
132
+ function cleanupResolve() {
133
+ signal?.removeEventListener("abort", cleanupReject);
134
+ resolve();
135
+ }
136
+ function cleanupReject() {
137
+ clearTimeout(timer);
138
+ reject(signal?.reason ?? new Error("Operation aborted."));
139
+ }
140
+ signal?.addEventListener("abort", cleanupReject, { once: true });
141
+ });
142
+ }
package/lib/searxng.ts ADDED
@@ -0,0 +1,63 @@
1
+ // SearXNG metasearch client.
2
+
3
+ import { sanitizeSnippet } from "./format";
4
+ import { signalWithTimeout, withRetry } 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
+ 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));
35
+ }
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
+ }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
63
+ }