@bacnh85/pi-web 0.1.0
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/README.md +72 -0
- package/index.ts +329 -0
- package/package.json +48 -0
- package/types.d.ts +3 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# pi-web
|
|
2
|
+
|
|
3
|
+
Pi extension for web search, readable page extraction, and Firecrawl scraping/crawling.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd extensions/pi-web
|
|
9
|
+
npm install
|
|
10
|
+
cd ../..
|
|
11
|
+
|
|
12
|
+
pi install ./extensions/pi-web
|
|
13
|
+
# or test directly
|
|
14
|
+
pi -e ./extensions/pi-web
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
If you manually copy this directory instead of using `pi install`, run `npm install --omit=dev` in the copied `pi-web` directory so readable-content dependencies such as `@mozilla/readability` are present.
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
Environment lookup order:
|
|
22
|
+
|
|
23
|
+
1. Process environment
|
|
24
|
+
2. Current working directory `.env.local`
|
|
25
|
+
3. Current working directory `.env`
|
|
26
|
+
4. Pi global config `.env.local` (`$PI_CODING_AGENT_DIR/.env.local` when set; otherwise `~/.pi/agent/.env.local` then `~/.pi/agents/.env.local`)
|
|
27
|
+
5. Pi global config `.env` (`$PI_CODING_AGENT_DIR/.env` when set; otherwise `~/.pi/agent/.env` then `~/.pi/agents/.env`)
|
|
28
|
+
|
|
29
|
+
Variables:
|
|
30
|
+
|
|
31
|
+
- `BRAVE_API_KEY` — required for Brave Search.
|
|
32
|
+
- `SEARXNG_BASE_URL` — optional; defaults to `http://172.30.55.22:8888`.
|
|
33
|
+
- `FIRECRAWL_API_URL` — optional; defaults to `https://api.firecrawl.dev/v2`.
|
|
34
|
+
- `FIRECRAWL_API_KEY` — required for hosted Firecrawl, optional for self-hosted instances without auth.
|
|
35
|
+
- `FIRECRAWL_TIMEOUT_MS` — optional request timeout, default `60000`.
|
|
36
|
+
|
|
37
|
+
Secrets are never printed; status reports only show presence/source.
|
|
38
|
+
|
|
39
|
+
## Tools
|
|
40
|
+
|
|
41
|
+
Brave:
|
|
42
|
+
|
|
43
|
+
- `brave_search` — search web results, optionally fetch readable result content.
|
|
44
|
+
- `brave_content` — fetch a URL and extract readable markdown.
|
|
45
|
+
|
|
46
|
+
SearXNG:
|
|
47
|
+
|
|
48
|
+
- `searxng_search` — search web results through a configured self-hosted SearXNG metasearch instance.
|
|
49
|
+
|
|
50
|
+
Firecrawl:
|
|
51
|
+
|
|
52
|
+
- `firecrawl_search` — search web/news/images, optionally scrape result markdown.
|
|
53
|
+
- `firecrawl_scrape` — scrape a URL as markdown/html/links/summary/json.
|
|
54
|
+
- `firecrawl_map` — discover URLs for a site.
|
|
55
|
+
- `firecrawl_crawl` — start a conservative crawl, optionally polling for results.
|
|
56
|
+
|
|
57
|
+
Utility:
|
|
58
|
+
|
|
59
|
+
- `web_status` — show configured provider status without secrets.
|
|
60
|
+
- `/web-status` — command version of the status check.
|
|
61
|
+
|
|
62
|
+
## Practical Guidance
|
|
63
|
+
|
|
64
|
+
- Use `searxng_search` first for general web search, docs lookup, current facts, and source discovery; it is fast, self-hosted, and avoids hosted API costs/rate limits.
|
|
65
|
+
- Use `brave_search` as a fast hosted fallback when SearXNG results are weak/unavailable, or when an independent search index is useful. Use `include_content` sparingly.
|
|
66
|
+
- Use `brave_content` for fast known-URL article/docs extraction when simple readable markdown is enough.
|
|
67
|
+
- Use `firecrawl_search` when SearXNG/Brave are unavailable, when Firecrawl search is preferred, or when search results should include scraped markdown.
|
|
68
|
+
- Use `firecrawl_scrape` for known URLs when extraction quality matters, pages are dynamic, links are needed, or structured JSON extraction is requested.
|
|
69
|
+
- Use `firecrawl_map` before crawling to discover candidate URLs and keep crawls targeted.
|
|
70
|
+
- Use `firecrawl_crawl` only when multiple pages are required; keep limits conservative and use include/exclude paths.
|
|
71
|
+
- When answering from web content, cite source URLs from result links or metadata.
|
|
72
|
+
- Use `web_status` when provider configuration is uncertain or a web tool fails due to credentials/config.
|
package/index.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/// <reference path="./types.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
const OUTPUT_MAX_BYTES = 50 * 1024;
|
|
13
|
+
const OUTPUT_MAX_LINES = 2_000;
|
|
14
|
+
const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
|
|
15
|
+
const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
|
|
16
|
+
|
|
17
|
+
function piConfigDirs(): string[] {
|
|
18
|
+
return process.env.PI_CODING_AGENT_DIR ? [process.env.PI_CODING_AGENT_DIR] : [path.join(os.homedir(), ".pi", "agent"), path.join(os.homedir(), ".pi", "agents")];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function envFileCandidates(cwd = process.cwd()): string[] {
|
|
22
|
+
return [path.resolve(cwd, ".env.local"), path.resolve(cwd, ".env"), ...piConfigDirs().flatMap((dir) => [path.join(dir, ".env.local"), path.join(dir, ".env")])];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stripInlineComment(value: string): string {
|
|
26
|
+
let quote = "";
|
|
27
|
+
let escaped = false;
|
|
28
|
+
for (let i = 0; i < value.length; i++) {
|
|
29
|
+
const char = value[i];
|
|
30
|
+
if (escaped) { escaped = false; continue; }
|
|
31
|
+
if (char === "\\") { escaped = true; continue; }
|
|
32
|
+
if (quote) { if (char === quote) quote = ""; continue; }
|
|
33
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
34
|
+
if (char === "#" && (i === 0 || /\s/.test(value[i - 1]))) return value.slice(0, i);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseDotenvValue(rawValue: string): string {
|
|
40
|
+
let value = stripInlineComment(rawValue).trim();
|
|
41
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
42
|
+
value = value.slice(1, -1);
|
|
43
|
+
return value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
|
|
44
|
+
}
|
|
45
|
+
return value.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseDotenvFile(file: string): Record<string, string> | null {
|
|
49
|
+
let text: string;
|
|
50
|
+
try { text = fs.readFileSync(file, "utf8"); } catch (e: any) { if (e?.code === "ENOENT") return null; throw e; }
|
|
51
|
+
const values: Record<string, string> = {};
|
|
52
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
53
|
+
const line = rawLine.trim();
|
|
54
|
+
if (!line || line.startsWith("#")) continue;
|
|
55
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
56
|
+
if (match) values[match[1]] = parseDotenvValue(match[2]);
|
|
57
|
+
}
|
|
58
|
+
return values;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findEnvValue(name: string, cwd = process.cwd()): { value?: string; source: string; checkedFiles: string[] } {
|
|
62
|
+
if (process.env[name]) return { value: process.env[name], source: "process.env", checkedFiles: [] };
|
|
63
|
+
const checkedFiles = envFileCandidates(cwd);
|
|
64
|
+
for (const file of checkedFiles) {
|
|
65
|
+
const parsed = parseDotenvFile(file);
|
|
66
|
+
if (parsed?.[name]) return { value: parsed[name], source: file, checkedFiles };
|
|
67
|
+
}
|
|
68
|
+
return { value: undefined, source: "", checkedFiles };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function truncateText(text: string): string {
|
|
72
|
+
const lines = text.split("\n");
|
|
73
|
+
let output = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
|
|
74
|
+
while (Buffer.byteLength(output, "utf8") > OUTPUT_MAX_BYTES) output = output.slice(0, Math.max(0, output.length - 1024));
|
|
75
|
+
if (lines.length > OUTPUT_MAX_LINES || output.length < text.length) output += `\n\n[Web output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
|
|
76
|
+
return output;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cwdFromContext(ctx: any): string {
|
|
80
|
+
return typeof ctx?.cwd === "string" && ctx.cwd ? ctx.cwd : process.cwd();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function signalWithTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
84
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
85
|
+
if (!signal) return timeoutSignal;
|
|
86
|
+
return AbortSignal.any([signal, timeoutSignal]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
90
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Operation aborted."));
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const timer = setTimeout(cleanupResolve, ms);
|
|
93
|
+
function cleanupResolve() {
|
|
94
|
+
signal?.removeEventListener("abort", cleanupReject);
|
|
95
|
+
resolve();
|
|
96
|
+
}
|
|
97
|
+
function cleanupReject() {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
reject(signal?.reason ?? new Error("Operation aborted."));
|
|
100
|
+
}
|
|
101
|
+
signal?.addEventListener("abort", cleanupReject, { once: true });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sanitizeSnippet(text = ""): string {
|
|
106
|
+
return text.replace(/<[^>]*>/g, "").replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16))).replace(/\s+/g, " ").trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loadReadableContentDependencies(): { Readability: any; JSDOM: any; TurndownService: any; gfm: any } {
|
|
110
|
+
try {
|
|
111
|
+
return {
|
|
112
|
+
Readability: require("@mozilla/readability").Readability,
|
|
113
|
+
JSDOM: require("jsdom").JSDOM,
|
|
114
|
+
TurndownService: require("turndown"),
|
|
115
|
+
gfm: require("turndown-plugin-gfm").gfm,
|
|
116
|
+
};
|
|
117
|
+
} catch (error: any) {
|
|
118
|
+
throw new Error(`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}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function htmlToMarkdown(html: string, deps = loadReadableContentDependencies()): string {
|
|
123
|
+
const turndown = new deps.TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
|
124
|
+
turndown.use(deps.gfm as any);
|
|
125
|
+
turndown.addRule("removeEmptyLinks", { filter: (node: any) => node.nodeName === "A" && !node.textContent?.trim(), replacement: () => "" });
|
|
126
|
+
return turndown.turndown(html).replace(/\[\\?\[\s*\\?\]\]\([^)]*\)/g, "").replace(/ +/g, " ").replace(/\s+,/g, ",").replace(/\s+\./g, ".").replace(/\n{3,}/g, "\n\n").trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function fetchReadableContent(url: string, timeoutMs = 15000, signal?: AbortSignal): Promise<{ title: string; markdown: string }> {
|
|
130
|
+
const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9" }, signal: signalWithTimeout(timeoutMs, signal) });
|
|
131
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
132
|
+
const html = await response.text();
|
|
133
|
+
const deps = loadReadableContentDependencies();
|
|
134
|
+
const dom = new deps.JSDOM(html, { url });
|
|
135
|
+
const article = new deps.Readability(dom.window.document).parse();
|
|
136
|
+
if (article?.content) return { title: article.title || "", markdown: htmlToMarkdown(article.content, deps) };
|
|
137
|
+
const fallbackDoc = new deps.JSDOM(html, { url });
|
|
138
|
+
const doc = fallbackDoc.window.document;
|
|
139
|
+
doc.querySelectorAll("script, style, noscript, nav, header, footer, aside").forEach((el: Element) => el.remove());
|
|
140
|
+
const title = doc.querySelector("title")?.textContent?.trim() || "";
|
|
141
|
+
const main = doc.querySelector("main, article, [role='main'], .content, #content") || doc.body;
|
|
142
|
+
const fallbackHtml = main?.innerHTML || "";
|
|
143
|
+
if (fallbackHtml.trim().length <= 100) throw new Error("Could not extract readable content from this page.");
|
|
144
|
+
return { title, markdown: htmlToMarkdown(fallbackHtml, deps) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fetchBraveResults(query: string, count: number, country: string, freshness: string, apiKey: string, signal?: AbortSignal) {
|
|
148
|
+
const params = new URLSearchParams({ q: query, count: String(count), country });
|
|
149
|
+
if (freshness) params.append("freshness", freshness);
|
|
150
|
+
const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, { headers: { Accept: "application/json", "Accept-Encoding": "gzip", "X-Subscription-Token": apiKey }, signal });
|
|
151
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}\n${await response.text()}`);
|
|
152
|
+
const data: any = await response.json();
|
|
153
|
+
return (data.web?.results || []).slice(0, count).map((r: any) => ({ title: sanitizeSnippet(r.title || ""), url: r.url || "", snippet: sanitizeSnippet(r.description || ""), age: sanitizeSnippet(r.age || r.page_age || "") }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeSearxngBaseUrl(raw?: string): string {
|
|
157
|
+
let value = (raw || DEFAULT_SEARXNG_BASE_URL).trim();
|
|
158
|
+
if (!/^https?:\/\//i.test(value)) value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value) ? `http://${value}` : `https://${value}`;
|
|
159
|
+
return value.replace(/\/+$/, "");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function loadSearxngConfig(params: any = {}, cwd = process.cwd()) {
|
|
163
|
+
const baseUrlEnv = findEnvValue("SEARXNG_BASE_URL", cwd);
|
|
164
|
+
return { baseUrl: normalizeSearxngBaseUrl(params.searxng_base_url || params.base_url || baseUrlEnv.value), source: params.searxng_base_url || params.base_url ? "tool parameter" : baseUrlEnv.source || "default local" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function fetchSearxngResults(params: any, baseUrl: string, signal?: AbortSignal) {
|
|
168
|
+
const limit = Math.min(50, Math.max(1, params.count ?? 5));
|
|
169
|
+
const url = new URL(`${baseUrl}/search`);
|
|
170
|
+
url.searchParams.set("q", params.query);
|
|
171
|
+
url.searchParams.set("format", "json");
|
|
172
|
+
url.searchParams.set("pageno", String(Math.max(1, params.pageno ?? 1)));
|
|
173
|
+
for (const key of ["categories", "engines", "language", "time_range", "safesearch"] as const) if (params[key] !== undefined && params[key] !== "") url.searchParams.set(key, String(params[key]));
|
|
174
|
+
const response = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "pi-web/0.1 SearXNG" }, signal: signalWithTimeout(params.timeout_ms || 15000, signal) });
|
|
175
|
+
const text = await response.text();
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const hint = response.status === 403 ? "\nHint: enable JSON output in SearXNG settings.yml with search.formats including json." : "";
|
|
178
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}${hint}${text ? `\n${text}` : ""}`);
|
|
179
|
+
}
|
|
180
|
+
const data: any = text ? JSON.parse(text) : {};
|
|
181
|
+
const results = (data.results || []).slice(0, limit).map((r: any) => ({ title: sanitizeSnippet(r.title || ""), url: r.url || "", snippet: sanitizeSnippet(r.content || r.snippet || ""), engine: r.engine || "", category: r.category || "", score: r.score, publishedDate: r.publishedDate || r.published_date || "" }));
|
|
182
|
+
return { ...data, results };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatSearchResults(results: any[]): string {
|
|
186
|
+
if (!results.length) return "No results found.";
|
|
187
|
+
return results.map((r, i) => [`--- Result ${i + 1} ---`, `Title: ${r.title || ""}`, `Link: ${r.url || r.link || ""}`, r.age ? `Age: ${r.age}` : "", r.snippet || r.description ? `Snippet: ${r.snippet || r.description}` : "", r.content || r.markdown ? `Content:\n${r.content || r.markdown}` : ""].filter(Boolean).join("\n")).join("\n\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeFirecrawlBaseUrl(raw?: string): string {
|
|
191
|
+
let value = (raw || HOSTED_FIRECRAWL_BASE_URL).trim();
|
|
192
|
+
if (!/^https?:\/\//i.test(value)) value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value) ? `http://${value}` : `https://${value}`;
|
|
193
|
+
value = value.replace(/\/+$/, "");
|
|
194
|
+
if (!/\/v\d+$/i.test(value)) value += "/v2";
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function loadFirecrawlConfig(params: any = {}, cwd = process.cwd()) {
|
|
199
|
+
const apiUrl = params.firecrawl_api_url || params.api_url || findEnvValue("FIRECRAWL_API_URL", cwd).value;
|
|
200
|
+
const apiKey = params.firecrawl_api_key || params.api_key || findEnvValue("FIRECRAWL_API_KEY", cwd).value;
|
|
201
|
+
const timeoutValue = params.timeout_ms || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd).value;
|
|
202
|
+
const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
|
|
203
|
+
const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
204
|
+
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
205
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
206
|
+
if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
|
|
207
|
+
return { baseUrl, apiKey, isHosted, timeoutMs };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
class FirecrawlHttpError extends Error { status: number; constructor(status: number, statusText: string, text: string) { super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`); this.status = status; } }
|
|
211
|
+
|
|
212
|
+
async function firecrawlRequest(config: any, method: string, endpoint: string, body?: unknown, allowFallback = true, signal?: AbortSignal): Promise<any> {
|
|
213
|
+
try { return await firecrawlRequestJson(config, method, endpoint, body, signal); }
|
|
214
|
+
catch (e) {
|
|
215
|
+
if (!allowFallback || config.isHosted || !config.baseUrl.endsWith("/v2") || !(e instanceof FirecrawlHttpError) || e.status !== 404) throw e;
|
|
216
|
+
return firecrawlRequestJson({ ...config, baseUrl: config.baseUrl.replace(/\/v2$/, "/v1") }, method, endpoint, body, signal);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function firecrawlRequestJson(config: any, method: string, endpoint: string, body?: unknown, signal?: AbortSignal): Promise<any> {
|
|
221
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
222
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
223
|
+
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
|
224
|
+
const response = await fetch(`${config.baseUrl}${endpoint}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: signalWithTimeout(config.timeoutMs, signal) });
|
|
225
|
+
const text = await response.text();
|
|
226
|
+
if (!response.ok) throw new FirecrawlHttpError(response.status, response.statusText, text);
|
|
227
|
+
return text ? JSON.parse(text) : { success: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatFirecrawlScrape(data: any, maxChars = 20000): string {
|
|
231
|
+
const page = data.data || data;
|
|
232
|
+
const meta = page.metadata || {};
|
|
233
|
+
const parts: string[] = [];
|
|
234
|
+
if (meta.title) parts.push(`# ${meta.title}`);
|
|
235
|
+
if (meta.sourceURL || meta.url) parts.push(`Source: ${meta.sourceURL || meta.url}`);
|
|
236
|
+
if (meta.statusCode) parts.push(`Status: ${meta.statusCode}`);
|
|
237
|
+
if (data.warning || page.warning) parts.push(`Warning: ${data.warning || page.warning}`);
|
|
238
|
+
const body = page.markdown || page.summary || page.answer || page.html || page.rawHtml || "";
|
|
239
|
+
if (body) parts.push(String(body).slice(0, maxChars));
|
|
240
|
+
if (Array.isArray(page.links)) parts.push(["## Links", ...page.links.slice(0, 100).map((l: string) => `- ${l}`)].join("\n"));
|
|
241
|
+
return parts.join("\n\n") || JSON.stringify(data, null, 2);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatFirecrawlSearch(data: any, maxChars = 5000): string {
|
|
245
|
+
const web = data.data?.web || [];
|
|
246
|
+
const news = data.data?.news || [];
|
|
247
|
+
const images = data.data?.images || [];
|
|
248
|
+
const results = [...web, ...news].map((r: any) => ({ title: r.title || r.metadata?.title || "", url: r.url || r.metadata?.sourceURL || "", snippet: r.description || r.snippet || "", markdown: r.markdown ? String(r.markdown).slice(0, maxChars) : "" }));
|
|
249
|
+
let text = formatSearchResults(results);
|
|
250
|
+
if (images.length) text += "\n\n" + images.map((img: any, i: number) => [`--- Image ${i + 1} ---`, `Title: ${img.title || ""}`, `Image: ${img.imageUrl || ""}`, `Page: ${img.url || ""}`].join("\n")).join("\n\n");
|
|
251
|
+
if (data.warning) text += `\n\nWarning: ${data.warning}`;
|
|
252
|
+
return text;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const controlSchema = { api_key: Type.Optional(Type.String({ description: "Provider API key override. Prefer env vars." })), timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })) };
|
|
256
|
+
const firecrawlControlSchema = { firecrawl_api_key: Type.Optional(Type.String({ description: "Firecrawl API key override. Prefer FIRECRAWL_API_KEY." })), firecrawl_api_url: Type.Optional(Type.String({ description: "Firecrawl API URL override. Defaults to FIRECRAWL_API_URL or hosted." })), timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })) };
|
|
257
|
+
const searxngControlSchema = { searxng_base_url: Type.Optional(Type.String({ description: "SearXNG base URL override. Defaults to SEARXNG_BASE_URL or http://172.30.55.22:8888." })), timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })) };
|
|
258
|
+
|
|
259
|
+
export default function piWebExtension(pi: ExtensionAPI) {
|
|
260
|
+
pi.registerTool({ name: "brave_search", label: "Brave Search", description: "Search the web with Brave Search API, optionally fetching readable page content. Best as a fast hosted fallback or independent index for source discovery, current facts, docs lookup, and general web search.", promptSnippet: "Search current web results with Brave", promptGuidelines: ["Use SearXNG first for general web search when the self-hosted instance is available; use Brave as a fast hosted fallback or independent second source.", "Use include_content only for a small number of results to avoid rate limits and extra page fetches.", "Cite result URLs when web results materially support the answer; if Brave is rate-limited, try SearXNG or Firecrawl search."], parameters: Type.Object({ ...controlSchema, query: Type.String(), count: Type.Optional(Type.Number({ default: 5 })), country: Type.Optional(Type.String({ default: "US" })), freshness: Type.Optional(Type.String()), include_content: Type.Optional(Type.Boolean({ default: false })), content_chars: Type.Optional(Type.Number({ default: 5000 })) }), async execute(_id, params: any, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
261
|
+
const cwd = cwdFromContext(ctx);
|
|
262
|
+
const apiKey = params.api_key || findEnvValue("BRAVE_API_KEY", cwd).value;
|
|
263
|
+
if (!apiKey) throw new Error("BRAVE_API_KEY is required.");
|
|
264
|
+
const count = Math.min(20, Math.max(1, params.count ?? 5));
|
|
265
|
+
const results = await fetchBraveResults(params.query, count, (params.country || "US").toUpperCase(), params.freshness || "", apiKey, signal);
|
|
266
|
+
if (params.include_content) await Promise.all(results.map(async (r: any) => { try { r.content = (await fetchReadableContent(r.url, params.timeout_ms || 10000, signal)).markdown.slice(0, params.content_chars ?? 5000); } catch (e: any) { r.content = `(Fetch error for ${r.url}: ${e?.message || String(e)})`; } }));
|
|
267
|
+
return { content: [{ type: "text" as const, text: truncateText(formatSearchResults(results)) }], details: results };
|
|
268
|
+
} });
|
|
269
|
+
|
|
270
|
+
pi.registerTool({ name: "brave_content", label: "Brave Content", description: "Fetch a URL and extract readable markdown content without using the Brave Search API. Best for fast known-URL article/docs extraction.", promptSnippet: "Extract readable webpage content as markdown", promptGuidelines: ["Use for known URLs when simple readable markdown extraction is enough.", "Prefer Firecrawl scrape for dynamic pages, pages that block direct fetching, or when links/structured extraction are needed.", "Cite the source URL when using extracted content in an answer."], parameters: Type.Object({ url: Type.String(), timeout_ms: Type.Optional(Type.Number({ default: 15000 })), content_chars: Type.Optional(Type.Number({ default: 20000 })) }), async execute(_id, params: any, signal: AbortSignal) {
|
|
271
|
+
const parsed = new URL(params.url); if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("URL must use http or https.");
|
|
272
|
+
const article = await fetchReadableContent(parsed.href, params.timeout_ms || 15000, signal);
|
|
273
|
+
const text = `${article.title ? `# ${article.title}\n\n` : ""}${article.markdown.slice(0, params.content_chars ?? 20000)}`;
|
|
274
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { url: parsed.href, ...article } };
|
|
275
|
+
} });
|
|
276
|
+
|
|
277
|
+
pi.registerTool({ name: "searxng_search", label: "SearXNG Search", description: "Search the web through the configured self-hosted SearXNG metasearch instance. Best for free, self-hosted source discovery without hosted API keys.", promptSnippet: "Search web results with self-hosted SearXNG", promptGuidelines: ["Use SearXNG for self-hosted metasearch and source discovery, especially when Brave is unavailable or avoiding hosted search APIs.", "Use SearXNG before browser/scrape tools when the task starts with finding URLs or sources.", "Cite result URLs when SearXNG results materially support the answer."], parameters: Type.Object({ ...searxngControlSchema, query: Type.String(), count: Type.Optional(Type.Number({ default: 5 })), pageno: Type.Optional(Type.Number({ default: 1 })), categories: Type.Optional(Type.String({ description: "Comma-separated SearXNG categories, e.g. general,science." })), engines: Type.Optional(Type.String({ description: "Comma-separated SearXNG engines, e.g. brave,wikipedia." })), language: Type.Optional(Type.String()), time_range: Type.Optional(Type.Union([Type.Literal("day"), Type.Literal("month"), Type.Literal("year")])), safesearch: Type.Optional(Type.Number({ description: "SearXNG safesearch level: 0, 1, or 2." })) }), async execute(_id, params: any, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
278
|
+
const config = loadSearxngConfig(params, cwdFromContext(ctx));
|
|
279
|
+
const result = await fetchSearxngResults(params, config.baseUrl, signal);
|
|
280
|
+
return { content: [{ type: "text" as const, text: truncateText(formatSearchResults(result.results || [])) }], details: { ...result, baseUrl: config.baseUrl } };
|
|
281
|
+
} });
|
|
282
|
+
|
|
283
|
+
pi.registerTool({ name: "firecrawl_search", label: "Firecrawl Search", description: "Search web/news/images through Firecrawl, optionally scraping result markdown. Best when SearXNG/Brave are unavailable or when Firecrawl-backed search/scrape is preferred.", promptSnippet: "Search with Firecrawl", promptGuidelines: ["Use when SearXNG and Brave are missing/rate-limited/insufficient, when Firecrawl search is preferred, or when you need scraped markdown from results.", "Keep limits conservative; scraped search is slower and heavier than plain search.", "Cite result URLs and verify scraped content before relying on it."], parameters: Type.Object({ ...firecrawlControlSchema, query: Type.String(), limit: Type.Optional(Type.Number({ default: 5 })), sources: Type.Optional(Type.String({ description: "Comma-separated sources: web,news,images", default: "web" })), country: Type.Optional(Type.String()), location: Type.Optional(Type.String()), tbs: Type.Optional(Type.String()), scrape: Type.Optional(Type.Boolean({ default: false })), content_chars: Type.Optional(Type.Number({ default: 5000 })) }), async execute(_id, params: any, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
284
|
+
const config = loadFirecrawlConfig(params, cwdFromContext(ctx));
|
|
285
|
+
const sources = String(params.sources || "web").split(",").map((type) => ({ type: type.trim() })).filter((s) => s.type);
|
|
286
|
+
const body = { query: params.query, limit: Math.min(100, Math.max(1, params.limit ?? 5)), sources, ...(params.country ? { country: String(params.country).toUpperCase() } : {}), ...(params.location ? { location: params.location } : {}), ...(params.tbs ? { tbs: params.tbs } : {}), ...(params.scrape ? { scrapeOptions: { formats: ["markdown"], onlyMainContent: true } } : {}) };
|
|
287
|
+
const result = await firecrawlRequest(config, "POST", "/search", body, true, signal);
|
|
288
|
+
return { content: [{ type: "text" as const, text: truncateText(formatFirecrawlSearch(result, params.content_chars ?? 5000)) }], details: result };
|
|
289
|
+
} });
|
|
290
|
+
|
|
291
|
+
pi.registerTool({ name: "firecrawl_scrape", label: "Firecrawl Scrape", description: "Scrape a URL through Firecrawl as markdown/html/links/summary/json. Best for dynamic pages, higher-quality extraction, links, or structured JSON extraction.", promptSnippet: "Scrape a URL with Firecrawl", promptGuidelines: ["Use for known URLs when extraction quality matters, pages are dynamic, or markdown/links/JSON extraction is needed.", "Use prompt/schema only for explicit structured extraction tasks.", "Prefer brave_content for simple fast known-URL extraction when Firecrawl is unnecessary."], parameters: Type.Object({ ...firecrawlControlSchema, url: Type.String(), formats: Type.Optional(Type.String({ default: "markdown" })), prompt: Type.Optional(Type.String()), schema: Type.Optional(Type.Any()), wait_for: Type.Optional(Type.Number()), timeout: Type.Optional(Type.Number()), mobile: Type.Optional(Type.Boolean()), country: Type.Optional(Type.String()), actions: Type.Optional(Type.Any()), only_main_content: Type.Optional(Type.Boolean({ default: true })), content_chars: Type.Optional(Type.Number({ default: 20000 })) }), async execute(_id, params: any, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
292
|
+
const formats = String(params.formats || "markdown").split(",").map((f) => f.trim()).filter(Boolean).map((f) => f === "json" && (params.prompt || params.schema) ? { type: "json", ...(params.prompt ? { prompt: params.prompt } : {}), ...(params.schema ? { schema: params.schema } : {}) } : f);
|
|
293
|
+
const body = { url: params.url, formats, onlyMainContent: params.only_main_content !== false, ...(params.wait_for ? { waitFor: params.wait_for } : {}), ...(params.timeout ? { timeout: params.timeout } : {}), ...(params.mobile ? { mobile: true } : {}), ...(params.country ? { location: { country: String(params.country).toUpperCase() } } : {}), ...(params.actions ? { actions: params.actions } : {}) };
|
|
294
|
+
const result = await firecrawlRequest(loadFirecrawlConfig(params, cwdFromContext(ctx)), "POST", "/scrape", body, true, signal);
|
|
295
|
+
return { content: [{ type: "text" as const, text: truncateText(formatFirecrawlScrape(result, params.content_chars ?? 20000)) }], details: result };
|
|
296
|
+
} });
|
|
297
|
+
|
|
298
|
+
pi.registerTool({ name: "firecrawl_map", label: "Firecrawl Map", description: "Discover URLs from a site with Firecrawl map. Best before crawling or when you need candidate pages from a site/docs section.", promptSnippet: "Map site URLs", promptGuidelines: ["Use before crawling to identify relevant URLs and avoid unnecessary broad crawls.", "Keep limits small unless the user explicitly asks for broad site discovery.", "Use mapped URLs with firecrawl_scrape for targeted extraction."], parameters: Type.Object({ ...firecrawlControlSchema, url: Type.String(), limit: Type.Optional(Type.Number({ default: 100 })), include_subdomains: Type.Optional(Type.Boolean({ default: false })) }), async execute(_id, params: any, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
299
|
+
const result = await firecrawlRequest(loadFirecrawlConfig(params, cwdFromContext(ctx)), "POST", "/map", { url: params.url, limit: params.limit ?? 100, includeSubdomains: Boolean(params.include_subdomains) }, true, signal);
|
|
300
|
+
const urls = result.data || result.links || result.urls || [];
|
|
301
|
+
const text = Array.isArray(urls) ? urls.map((u: any) => u.url || u).join("\n") : JSON.stringify(result, null, 2);
|
|
302
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
303
|
+
} });
|
|
304
|
+
|
|
305
|
+
pi.registerTool({ name: "firecrawl_crawl", label: "Firecrawl Crawl", description: "Start a conservative Firecrawl site crawl and optionally poll for results. Best for small docs/site sections after mapping or when multiple pages are required.", promptSnippet: "Crawl a small site section", promptGuidelines: ["Use only when multiple pages from a site are needed; prefer firecrawl_map plus targeted scrape when possible.", "Keep limits conservative and use include/exclude paths to narrow scope.", "Avoid large crawls unless the user explicitly requests them and accepts runtime/load."], parameters: Type.Object({ ...firecrawlControlSchema, url: Type.String(), limit: Type.Optional(Type.Number({ default: 10 })), include_paths: Type.Optional(Type.String()), exclude_paths: Type.Optional(Type.String()), poll: Type.Optional(Type.Boolean({ default: false })) }), async execute(_id, params: any, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
306
|
+
const config = loadFirecrawlConfig(params, cwdFromContext(ctx));
|
|
307
|
+
let result = await firecrawlRequest(config, "POST", "/crawl", { url: params.url, limit: Math.min(10000, Math.max(1, params.limit ?? 10)), includePaths: params.include_paths ? String(params.include_paths).split(",").map((s) => s.trim()).filter(Boolean) : [], excludePaths: params.exclude_paths ? String(params.exclude_paths).split(",").map((s) => s.trim()).filter(Boolean) : [], scrapeOptions: { formats: ["markdown"], onlyMainContent: true } }, true, signal);
|
|
308
|
+
const id = result.id || result.data?.id;
|
|
309
|
+
if (params.poll && id && !Array.isArray(result.data)) {
|
|
310
|
+
for (let i = 0; i < 60; i++) { result = await firecrawlRequest(config, "GET", `/crawl/${id}`, undefined, true, signal); if (["completed", "failed", "cancelled"].includes(result.status || result.data?.status)) break; await abortableSleep(2000, signal); }
|
|
311
|
+
}
|
|
312
|
+
const pages = Array.isArray(result.data) ? result.data : result.data?.data || [];
|
|
313
|
+
const text = pages.length ? pages.map((p: any) => formatFirecrawlScrape({ data: p }, 12000)).join("\n\n---\n\n") : id ? `Crawl started: ${id}\nUse poll=true or check Firecrawl status/dashboard.` : JSON.stringify(result, null, 2);
|
|
314
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
315
|
+
} });
|
|
316
|
+
|
|
317
|
+
pi.registerTool({ name: "web_status", label: "Web Provider Status", description: "Show Brave, SearXNG, and Firecrawl configuration status without printing secrets.", promptSnippet: "Check web provider configuration", promptGuidelines: ["Use when web tools fail due to missing credentials/config or before comparing Brave, SearXNG, and Firecrawl availability.", "Never print API key values; this tool reports only presence and source."], parameters: Type.Object({}), async execute(_id, _params, _signal, _onUpdate, ctx: any) {
|
|
318
|
+
const cwd = cwdFromContext(ctx);
|
|
319
|
+
const braveKey = findEnvValue("BRAVE_API_KEY", cwd); const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd); const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd); const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd);
|
|
320
|
+
const status = { brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" }, searxng: { baseUrl: normalizeSearxngBaseUrl(searxngUrl.value), baseUrlSource: searxngUrl.source || "default local" }, firecrawl: { baseUrl: normalizeFirecrawlBaseUrl(fireUrl.value), apiUrlSource: fireUrl.source || "default hosted", apiKeyFound: Boolean(fireKey.value), apiKeySource: fireKey.value ? fireKey.source : "not set", hostedMode: !fireUrl.value || normalizeFirecrawlBaseUrl(fireUrl.value).startsWith(HOSTED_FIRECRAWL_BASE_URL) } };
|
|
321
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }], details: status };
|
|
322
|
+
} });
|
|
323
|
+
|
|
324
|
+
pi.registerCommand("web-status", { description: "Show web provider configuration status", handler: async (_args, ctx) => {
|
|
325
|
+
const cwd = cwdFromContext(ctx);
|
|
326
|
+
const braveKey = findEnvValue("BRAVE_API_KEY", cwd); const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd); const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd); const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd);
|
|
327
|
+
ctx.ui.notify(`Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}\nSearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})\nFirecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}\nFirecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`, "info");
|
|
328
|
+
} });
|
|
329
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bacnh85/pi-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for web search, page extraction, and Firecrawl scraping/crawling.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/bacnh85/skills#readme",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/bacnh85/skills.git",
|
|
14
|
+
"directory": "extensions/pi-web"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"web-search",
|
|
20
|
+
"brave-search",
|
|
21
|
+
"firecrawl",
|
|
22
|
+
"scraping"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"README.md",
|
|
26
|
+
"index.ts",
|
|
27
|
+
"types.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@mozilla/readability": "^0.6.0",
|
|
36
|
+
"jsdom": "^27.0.1",
|
|
37
|
+
"turndown": "^7.2.2",
|
|
38
|
+
"turndown-plugin-gfm": "^1.0.2"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"typebox": "*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/jsdom": "^28.0.3",
|
|
46
|
+
"@types/turndown": "^5.0.6"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/types.d.ts
ADDED