@bacnh85/pi-web 0.1.0 → 0.1.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.
- package/README.md +10 -0
- package/index.ts +27 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,14 @@ Pi extension for web search, readable page extraction, and Firecrawl scraping/cr
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
+
Install the published package from npm:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@bacnh85/pi-web
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
From this repository checkout, install only this extension package:
|
|
14
|
+
|
|
7
15
|
```bash
|
|
8
16
|
cd extensions/pi-web
|
|
9
17
|
npm install
|
|
@@ -14,6 +22,8 @@ pi install ./extensions/pi-web
|
|
|
14
22
|
pi -e ./extensions/pi-web
|
|
15
23
|
```
|
|
16
24
|
|
|
25
|
+
The package manifest points Pi directly at `./index.ts`, so published npm installs and local installs load the same extension entrypoint.
|
|
26
|
+
|
|
17
27
|
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
28
|
|
|
19
29
|
## Configuration
|
package/index.ts
CHANGED
|
@@ -18,8 +18,8 @@ function piConfigDirs(): string[] {
|
|
|
18
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
19
|
}
|
|
20
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")])];
|
|
21
|
+
function envFileCandidates(cwd = process.cwd(), includeCwd = true): string[] {
|
|
22
|
+
return [...(includeCwd ? [path.resolve(cwd, ".env.local"), path.resolve(cwd, ".env")] : []), ...piConfigDirs().flatMap((dir) => [path.join(dir, ".env.local"), path.join(dir, ".env")])];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function stripInlineComment(value: string): string {
|
|
@@ -58,9 +58,9 @@ function parseDotenvFile(file: string): Record<string, string> | null {
|
|
|
58
58
|
return values;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function findEnvValue(name: string, cwd = process.cwd()): { value?: string; source: string; checkedFiles: string[] } {
|
|
61
|
+
function findEnvValue(name: string, cwd = process.cwd(), includeCwd = true): { value?: string; source: string; checkedFiles: string[] } {
|
|
62
62
|
if (process.env[name]) return { value: process.env[name], source: "process.env", checkedFiles: [] };
|
|
63
|
-
const checkedFiles = envFileCandidates(cwd);
|
|
63
|
+
const checkedFiles = envFileCandidates(cwd, includeCwd);
|
|
64
64
|
for (const file of checkedFiles) {
|
|
65
65
|
const parsed = parseDotenvFile(file);
|
|
66
66
|
if (parsed?.[name]) return { value: parsed[name], source: file, checkedFiles };
|
|
@@ -80,6 +80,10 @@ function cwdFromContext(ctx: any): string {
|
|
|
80
80
|
return typeof ctx?.cwd === "string" && ctx.cwd ? ctx.cwd : process.cwd();
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function includeProjectEnv(ctx: any): boolean {
|
|
84
|
+
return typeof ctx?.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false;
|
|
85
|
+
}
|
|
86
|
+
|
|
83
87
|
function signalWithTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
84
88
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
85
89
|
if (!signal) return timeoutSignal;
|
|
@@ -159,8 +163,8 @@ function normalizeSearxngBaseUrl(raw?: string): string {
|
|
|
159
163
|
return value.replace(/\/+$/, "");
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
function loadSearxngConfig(params: any = {}, cwd = process.cwd()) {
|
|
163
|
-
const baseUrlEnv = findEnvValue("SEARXNG_BASE_URL", cwd);
|
|
166
|
+
function loadSearxngConfig(params: any = {}, cwd = process.cwd(), includeCwdEnv = false) {
|
|
167
|
+
const baseUrlEnv = findEnvValue("SEARXNG_BASE_URL", cwd, includeCwdEnv);
|
|
164
168
|
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
169
|
}
|
|
166
170
|
|
|
@@ -195,14 +199,18 @@ function normalizeFirecrawlBaseUrl(raw?: string): string {
|
|
|
195
199
|
return value;
|
|
196
200
|
}
|
|
197
201
|
|
|
198
|
-
function loadFirecrawlConfig(params: any = {}, cwd = process.cwd()) {
|
|
199
|
-
const
|
|
200
|
-
const
|
|
202
|
+
function loadFirecrawlConfig(params: any = {}, cwd = process.cwd(), includeCwdEnv = false) {
|
|
203
|
+
const apiUrlLookup = findEnvValue("FIRECRAWL_API_URL", cwd, includeCwdEnv);
|
|
204
|
+
const apiKeyLookup = findEnvValue("FIRECRAWL_API_KEY", cwd, includeCwdEnv);
|
|
205
|
+
const explicitApiKey = params.firecrawl_api_key || params.api_key;
|
|
206
|
+
const apiUrl = params.firecrawl_api_url || params.api_url || apiUrlLookup.value;
|
|
207
|
+
const apiKey = explicitApiKey || apiKeyLookup.value;
|
|
201
208
|
const timeoutValue = params.timeout_ms || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd).value;
|
|
202
209
|
const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
|
|
203
210
|
const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
204
211
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
205
212
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
213
|
+
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.");
|
|
206
214
|
if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
|
|
207
215
|
return { baseUrl, apiKey, isHosted, timeoutMs };
|
|
208
216
|
}
|
|
@@ -259,7 +267,7 @@ const searxngControlSchema = { searxng_base_url: Type.Optional(Type.String({ des
|
|
|
259
267
|
export default function piWebExtension(pi: ExtensionAPI) {
|
|
260
268
|
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
269
|
const cwd = cwdFromContext(ctx);
|
|
262
|
-
const apiKey = params.api_key || findEnvValue("BRAVE_API_KEY", cwd).value;
|
|
270
|
+
const apiKey = params.api_key || findEnvValue("BRAVE_API_KEY", cwd, includeProjectEnv(ctx)).value;
|
|
263
271
|
if (!apiKey) throw new Error("BRAVE_API_KEY is required.");
|
|
264
272
|
const count = Math.min(20, Math.max(1, params.count ?? 5));
|
|
265
273
|
const results = await fetchBraveResults(params.query, count, (params.country || "US").toUpperCase(), params.freshness || "", apiKey, signal);
|
|
@@ -275,13 +283,13 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
275
283
|
} });
|
|
276
284
|
|
|
277
285
|
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));
|
|
286
|
+
const config = loadSearxngConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
279
287
|
const result = await fetchSearxngResults(params, config.baseUrl, signal);
|
|
280
288
|
return { content: [{ type: "text" as const, text: truncateText(formatSearchResults(result.results || [])) }], details: { ...result, baseUrl: config.baseUrl } };
|
|
281
289
|
} });
|
|
282
290
|
|
|
283
291
|
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));
|
|
292
|
+
const config = loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
285
293
|
const sources = String(params.sources || "web").split(",").map((type) => ({ type: type.trim() })).filter((s) => s.type);
|
|
286
294
|
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
295
|
const result = await firecrawlRequest(config, "POST", "/search", body, true, signal);
|
|
@@ -291,19 +299,19 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
291
299
|
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
300
|
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
301
|
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);
|
|
302
|
+
const result = await firecrawlRequest(loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx)), "POST", "/scrape", body, true, signal);
|
|
295
303
|
return { content: [{ type: "text" as const, text: truncateText(formatFirecrawlScrape(result, params.content_chars ?? 20000)) }], details: result };
|
|
296
304
|
} });
|
|
297
305
|
|
|
298
306
|
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);
|
|
307
|
+
const result = await firecrawlRequest(loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx)), "POST", "/map", { url: params.url, limit: params.limit ?? 100, includeSubdomains: Boolean(params.include_subdomains) }, true, signal);
|
|
300
308
|
const urls = result.data || result.links || result.urls || [];
|
|
301
309
|
const text = Array.isArray(urls) ? urls.map((u: any) => u.url || u).join("\n") : JSON.stringify(result, null, 2);
|
|
302
310
|
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
303
311
|
} });
|
|
304
312
|
|
|
305
313
|
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));
|
|
314
|
+
const config = loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
307
315
|
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
316
|
const id = result.id || result.data?.id;
|
|
309
317
|
if (params.poll && id && !Array.isArray(result.data)) {
|
|
@@ -316,14 +324,16 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
316
324
|
|
|
317
325
|
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
326
|
const cwd = cwdFromContext(ctx);
|
|
319
|
-
const
|
|
327
|
+
const trusted = includeProjectEnv(ctx);
|
|
328
|
+
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted); const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted); const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted); const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
320
329
|
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
330
|
return { content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }], details: status };
|
|
322
331
|
} });
|
|
323
332
|
|
|
324
333
|
pi.registerCommand("web-status", { description: "Show web provider configuration status", handler: async (_args, ctx) => {
|
|
325
334
|
const cwd = cwdFromContext(ctx);
|
|
326
|
-
const
|
|
335
|
+
const trusted = includeProjectEnv(ctx);
|
|
336
|
+
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted); const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted); const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted); const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
327
337
|
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
338
|
} });
|
|
329
339
|
}
|