@bacnh85/pi-web 0.2.1 → 0.4.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/index.ts CHANGED
@@ -9,22 +9,31 @@ import {
9
9
  includeProjectEnv,
10
10
  normalizeSearxngBaseUrl,
11
11
  normalizeFirecrawlBaseUrl,
12
- loadSearxngConfig,
12
+ normalizeCrawl4aiApiUrl,
13
13
  loadFirecrawlConfig,
14
+ loadCrawl4aiConfig,
14
15
  HOSTED_FIRECRAWL_BASE_URL,
15
16
  } from "./lib/config";
16
- import { truncateText, formatSearchResults, formatFirecrawlScrape, formatFirecrawlSearch } from "./lib/format";
17
- import { fetchReadableContent } from "./lib/content";
18
- import { fetchBraveResults } from "./lib/brave";
19
- import { fetchSearxngResults } from "./lib/searxng";
17
+ import {
18
+ truncateText,
19
+ formatFirecrawlScrape,
20
+ formatCrawl4aiResult,
21
+ } from "./lib/format";
22
+ import { searchWithDiagnostics, type SearchResult as UnifiedSearchResult } from "./lib/search";
23
+ import { extractWithDiagnostics, type ExtractMode } from "./lib/extract";
20
24
  import { firecrawlRequest, type FirecrawlResult } from "./lib/firecrawl";
25
+ import {
26
+ fetchCrawl4aiCrawl,
27
+ fetchCrawl4aiScreenshot,
28
+ fetchCrawl4aiPdf,
29
+ fetchCrawl4aiHealth,
30
+ } from "./lib/crawl4ai";
21
31
 
22
32
  // ---------------------------------------------------------------------------
23
- // Schema fragments shared across tool registrations
33
+ // Shared schema fragment
24
34
  // ---------------------------------------------------------------------------
25
35
 
26
- const controlSchema = {
27
- api_key: Type.Optional(Type.String({ description: "Provider API key override. Prefer env vars." })),
36
+ const sharedControlSchema = {
28
37
  timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
29
38
  };
30
39
 
@@ -34,230 +43,156 @@ const firecrawlControlSchema = {
34
43
  timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
35
44
  };
36
45
 
37
- const searxngControlSchema = {
38
- searxng_base_url: Type.Optional(Type.String({ description: "SearXNG base URL override. Defaults to SEARXNG_BASE_URL or http://172.30.55.22:8888." })),
46
+ const crawl4aiControlSchema = {
47
+ crawl4ai_api_url: Type.Optional(Type.String({ description: "Crawl4AI API URL override. Defaults to CRAWL4AI_API_URL or http://172.30.55.22:11235." })),
48
+ crawl4ai_api_token: Type.Optional(Type.String({ description: "Crawl4AI API token override. Prefer CRAWL4AI_API_TOKEN." })),
39
49
  timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
40
50
  };
41
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // Helper: format unified search results for text output
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function formatUnifiedSearchResults(results: UnifiedSearchResult[]): string {
57
+ if (!results.length) return "No results found.";
58
+ return results
59
+ .map((r, i) =>
60
+ [
61
+ `--- Result ${i + 1} (backend: ${r.backend}) ---`,
62
+ `Title: ${r.title || ""}`,
63
+ `Link: ${r.url || ""}`,
64
+ r.age ? `Age: ${r.age}` : "",
65
+ r.snippet ? `Snippet: ${r.snippet}` : "",
66
+ r.content ? `Content:\n${r.content}` : "",
67
+ ]
68
+ .filter(Boolean)
69
+ .join("\n"),
70
+ )
71
+ .join("\n\n");
72
+ }
73
+
42
74
  // ---------------------------------------------------------------------------
43
75
  // Extension entry point
44
76
  // ---------------------------------------------------------------------------
45
77
 
46
78
  export default function piWebExtension(pi: ExtensionAPI) {
47
- // ── Brave Search ──────────────────────────────────────────────────────
79
+ // ── web_search ────────────────────────────────────────────────────────
48
80
  pi.registerTool({
49
- name: "brave_search",
50
- label: "Brave Search",
81
+ name: "web_search",
82
+ label: "Web Search",
51
83
  description:
52
- "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.",
53
- promptSnippet: "Search current web results with Brave",
84
+ "Search the web with adaptive backend selection. Uses SearXNG (self-hosted) for broad discovery, Brave (hosted API) for precision-sensitive queries and include_content, and Firecrawl as a last resort. Use backend to force a provider.",
85
+ promptSnippet: "Search current web results",
54
86
  promptGuidelines: [
55
- "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.",
56
- "Use include_content only for a small number of results to avoid rate limits and extra page fetches.",
57
- "Cite result URLs when web results materially support the answer; if Brave is rate-limited, try SearXNG or Firecrawl search.",
87
+ "Use web_search for source discovery, documentation lookup, current facts, and general web search.",
88
+ "Backend auto-selection is adaptive: broad discovery uses SearXNG first; precision queries, site: searches, docs/source lookups, and include_content use Brave first; Firecrawl is last resort.",
89
+ "If auto-selection returns poor-quality results, force backend: 'brave' or backend: 'searxng' for a different search index.",
90
+ "Use the backend parameter to force a specific search engine (e.g., backend: 'brave').",
91
+ "Use engines parameter for SearXNG tuning (e.g., engines: 'google,github' for technical queries).",
92
+ "include_content prefers Brave automatically because only Brave search fetches inline page content; for other result URLs, use web_extract.",
93
+ "Note: Firecrawl Search has poor semantic accuracy on domain-specific/ambiguous queries; prefer SearXNG or Brave for precision.",
94
+ "Cite result URLs when web results materially support the answer.",
58
95
  ],
59
96
  parameters: Type.Object({
60
- ...controlSchema,
61
97
  query: Type.String(),
62
98
  count: Type.Optional(Type.Number({ default: 5 })),
99
+ freshness: Type.Optional(Type.String({ description: "Time filter: e.g., 'pw' (past week), 'pm' (past month), 'py' (past year), or a date range like '2024-01-01to2024-06-30'." })),
63
100
  country: Type.Optional(Type.String({ default: "US" })),
64
- freshness: Type.Optional(Type.String()),
65
- include_content: Type.Optional(Type.Boolean({ default: false })),
101
+ backend: Type.Optional(Type.Union(
102
+ [Type.Literal("auto"), Type.Literal("searxng"), Type.Literal("brave"), Type.Literal("firecrawl")],
103
+ { default: "auto", description: "Search backend: auto (default), searxng, brave, or firecrawl." },
104
+ )),
105
+ engines: Type.Optional(Type.String({ description: "SearXNG engine override (e.g., 'google,github' for technical queries). Only used when backend is searxng or auto." })),
106
+ include_content: Type.Optional(Type.Boolean({ default: false, description: "Fetch page content alongside search results. Slower but provides inline content." })),
66
107
  content_chars: Type.Optional(Type.Number({ default: 5000 })),
108
+ ...sharedControlSchema,
67
109
  }),
68
110
  async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
69
- const cwd = cwdFromContext(ctx);
70
- const apiKey = (params.api_key as string) || findEnvValue("BRAVE_API_KEY", cwd, includeProjectEnv(ctx)).value;
71
- if (!apiKey) throw new Error("BRAVE_API_KEY is required.");
72
- const count = Math.min(20, Math.max(1, (params.count as number) ?? 5));
73
- const results = await fetchBraveResults(
74
- params.query as string,
75
- count,
76
- ((params.country as string) || "US").toUpperCase(),
77
- (params.freshness as string) || "",
78
- apiKey,
111
+ const diagnostics = await searchWithDiagnostics({
112
+ query: params.query as string,
113
+ count: params.count as number | undefined,
114
+ freshness: params.freshness as string | undefined,
115
+ country: params.country as string | undefined,
116
+ backend: params.backend as "auto" | "searxng" | "brave" | "firecrawl" | undefined,
117
+ engines: params.engines as string | undefined,
118
+ include_content: params.include_content as boolean | undefined,
119
+ content_chars: params.content_chars as number | undefined,
120
+ timeout_ms: params.timeout_ms as number | undefined,
79
121
  signal,
80
- );
81
- if (params.include_content) {
82
- await Promise.all(
83
- results.map(async (r) => {
84
- try {
85
- const content = await fetchReadableContent(r.url, (params.timeout_ms as number) || 10000, signal);
86
- r.content = content.markdown.slice(0, (params.content_chars as number) ?? 5000);
87
- } catch (e: any) {
88
- r.content = `(Fetch error for ${r.url}: ${e?.message || String(e)})`;
89
- }
90
- }),
91
- );
92
- }
93
- return { content: [{ type: "text" as const, text: truncateText(formatSearchResults(results)) }], details: results };
122
+ _ctx: ctx,
123
+ });
124
+ const attempts = diagnostics.attempts.map((a) => `${a.backend}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
125
+ const text = `${formatUnifiedSearchResults(diagnostics.results)}\n\n--- Search diagnostics ---\nSelected backend: ${diagnostics.selectedBackend}\n${attempts}`;
126
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: diagnostics };
94
127
  },
95
128
  });
96
129
 
97
- // ── Web Content (readable extraction) ─────────────────────────────────
130
+ // ── web_extract ──────────────────────────────────────────────────────
98
131
  pi.registerTool({
99
- name: "web_content",
100
- label: "Web Content",
132
+ name: "web_extract",
133
+ label: "Web Content Extraction",
101
134
  description:
102
- "Fetch a URL and extract readable markdown content using JSDOM + Readability + Turndown (does not use the Brave API). Best for fast known-URL article/docs extraction.",
135
+ "Extract readable content from a URL with automatic backend selection. Tries static extraction (JSDOM+Readability, no API key) first, then dynamic extraction (Firecrawl Scrape, JS rendering), then full browser extraction (Crawl4AI, headless browser). Use the mode parameter for explicit control. If all modes fail, try web_screenshot for a visual snapshot.",
103
136
  promptSnippet: "Extract readable webpage content as markdown",
104
137
  promptGuidelines: [
105
- "Use for known URLs when simple readable markdown extraction is enough.",
106
- "Prefer Firecrawl scrape for dynamic pages, pages that block direct fetching, or when links/structured extraction are needed.",
138
+ "Use web_extract to get clean markdown content from a known URL.",
139
+ "Use mode: 'static' for simple static pages (fast, no API key needed, uses JSDOM+Readability).",
140
+ "Use mode: 'dynamic' for JavaScript-rendered pages (uses Firecrawl Scrape).",
141
+ "Use mode: 'full' for pages that need Crawl4AI rendering when other modes fail (resource-intensive).",
142
+ "Default mode 'auto' tries static → dynamic → full. Successive fallbacks are noted in output.",
143
+ "⚠️ Dynamic mode (Firecrawl Scrape) may fail on bot-protected sites (Ansible docs, CDN-backed doc sites). Full mode (Crawl4AI) handles more sites but is heavier.",
144
+ "If all extraction modes fail, try web_screenshot for a visual snapshot, or the page may require interactive login.",
145
+ "Use the prompt and schema parameters for structured JSON extraction (dynamic mode only); structured JSON is returned in details and text when available.",
107
146
  "Cite the source URL when using extracted content in an answer.",
108
147
  ],
109
148
  parameters: Type.Object({
110
149
  url: Type.String(),
111
- timeout_ms: Type.Optional(Type.Number({ default: 15000 })),
112
- content_chars: Type.Optional(Type.Number({ default: 20000 })),
113
- }),
114
- async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal) {
115
- const parsed = new URL(params.url as string);
116
- if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("URL must use http or https.");
117
- const article = await fetchReadableContent(parsed.href, (params.timeout_ms as number) || 15000, signal);
118
- const text = `${article.title ? `# ${article.title}\n\n` : ""}${article.markdown.slice(0, (params.content_chars as number) ?? 20000)}`;
119
- return { content: [{ type: "text" as const, text: truncateText(text) }], details: { url: parsed.href, ...article } };
120
- },
121
- });
122
-
123
- // ── SearXNG Search ───────────────────────────────────────────────────
124
- pi.registerTool({
125
- name: "searxng_search",
126
- label: "SearXNG Search",
127
- description:
128
- "Search the web through the configured self-hosted SearXNG metasearch instance. Best for free, self-hosted source discovery without hosted API keys.",
129
- promptSnippet: "Search web results with self-hosted SearXNG",
130
- promptGuidelines: [
131
- "Use SearXNG for self-hosted metasearch and source discovery, especially when Brave is unavailable or avoiding hosted search APIs.",
132
- "Use SearXNG before browser/scrape tools when the task starts with finding URLs or sources.",
133
- "Cite result URLs when SearXNG results materially support the answer.",
134
- ],
135
- parameters: Type.Object({
136
- ...searxngControlSchema,
137
- query: Type.String(),
138
- count: Type.Optional(Type.Number({ default: 5 })),
139
- pageno: Type.Optional(Type.Number({ default: 1 })),
140
- categories: Type.Optional(Type.String({ description: "Comma-separated SearXNG categories, e.g. general,science." })),
141
- engines: Type.Optional(Type.String({ description: "Comma-separated SearXNG engines, e.g. brave,wikipedia." })),
142
- language: Type.Optional(Type.String()),
143
- time_range: Type.Optional(Type.Union([Type.Literal("day"), Type.Literal("month"), Type.Literal("year")])),
144
- safesearch: Type.Optional(Type.Number({ description: "SearXNG safesearch level: 0, 1, or 2." })),
145
- }),
146
- async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
147
- const config = loadSearxngConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
148
- const result = await fetchSearxngResults(params, config.baseUrl, signal);
149
- return { content: [{ type: "text" as const, text: truncateText(formatSearchResults(result.results || [])) }], details: { ...result, baseUrl: config.baseUrl } };
150
- },
151
- });
152
-
153
- // ── Firecrawl Search ─────────────────────────────────────────────────
154
- pi.registerTool({
155
- name: "firecrawl_search",
156
- label: "Firecrawl Search",
157
- description:
158
- "Search web/news/images through Firecrawl, optionally scraping result markdown. Best when SearXNG/Brave are unavailable or when Firecrawl-backed search/scrape is preferred.",
159
- promptSnippet: "Search with Firecrawl",
160
- promptGuidelines: [
161
- "Use when SearXNG and Brave are missing/rate-limited/insufficient, when Firecrawl search is preferred, or when you need scraped markdown from results.",
162
- "Keep limits conservative; scraped search is slower and heavier than plain search.",
163
- "Cite result URLs and verify scraped content before relying on it.",
164
- ],
165
- parameters: Type.Object({
166
- ...firecrawlControlSchema,
167
- query: Type.String(),
168
- limit: Type.Optional(Type.Number({ default: 5 })),
169
- sources: Type.Optional(Type.String({ description: "Comma-separated sources: web,news,images", default: "web" })),
170
- country: Type.Optional(Type.String()),
171
- location: Type.Optional(Type.String()),
172
- tbs: Type.Optional(Type.String()),
173
- scrape: Type.Optional(Type.Boolean({ default: false })),
174
- content_chars: Type.Optional(Type.Number({ default: 5000 })),
175
- }),
176
- async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
177
- const config = loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
178
- const sources = String(params.sources || "web")
179
- .split(",")
180
- .map((type: string) => ({ type: type.trim() }))
181
- .filter((s: { type: string }) => s.type);
182
- const body: Record<string, unknown> = {
183
- query: params.query,
184
- limit: Math.min(100, Math.max(1, (params.limit as number) ?? 5)),
185
- sources,
186
- ...(params.country ? { country: String(params.country).toUpperCase() } : {}),
187
- ...(params.location ? { location: params.location } : {}),
188
- ...(params.tbs ? { tbs: params.tbs } : {}),
189
- ...(params.scrape ? { scrapeOptions: { formats: ["markdown"], onlyMainContent: true } } : {}),
190
- };
191
- const result = await firecrawlRequest(config, "POST", "/search", body, true, signal);
192
- return { content: [{ type: "text" as const, text: truncateText(formatFirecrawlSearch((result as FirecrawlResult) as Record<string, unknown>, (params.content_chars as number) ?? 5000)) }], details: result };
193
- },
194
- });
195
-
196
- // ── Firecrawl Scrape ─────────────────────────────────────────────────
197
- pi.registerTool({
198
- name: "firecrawl_scrape",
199
- label: "Firecrawl Scrape",
200
- description:
201
- "Scrape a URL through Firecrawl as markdown/html/links/summary/json. Best for dynamic pages, higher-quality extraction, links, or structured JSON extraction.",
202
- promptSnippet: "Scrape a URL with Firecrawl",
203
- promptGuidelines: [
204
- "Use for known URLs when extraction quality matters, pages are dynamic, or markdown/links/JSON extraction is needed.",
205
- "Use prompt/schema only for explicit structured extraction tasks.",
206
- "Prefer web_content for simple fast known-URL extraction when Firecrawl is unnecessary.",
207
- ],
208
- parameters: Type.Object({
209
- ...firecrawlControlSchema,
210
- url: Type.String(),
211
- formats: Type.Optional(Type.String({ default: "markdown" })),
212
- prompt: Type.Optional(Type.String()),
213
- schema: Type.Optional(Type.Any()),
214
- wait_for: Type.Optional(Type.Number()),
215
- timeout: Type.Optional(Type.Number()),
216
- mobile: Type.Optional(Type.Boolean()),
217
- country: Type.Optional(Type.String()),
218
- actions: Type.Optional(Type.Any()),
219
- only_main_content: Type.Optional(Type.Boolean({ default: true })),
150
+ mode: Type.Optional(Type.Union(
151
+ [Type.Literal("auto"), Type.Literal("static"), Type.Literal("dynamic"), Type.Literal("full")],
152
+ { default: "auto", description: "Extraction mode: auto (default), static, dynamic, or full." },
153
+ )),
154
+ prompt: Type.Optional(Type.String({ description: "Prompt for structured JSON extraction (dynamic mode only)." })),
155
+ schema: Type.Optional(Type.Any({ description: "JSON schema for structured extraction (dynamic mode only)." })),
220
156
  content_chars: Type.Optional(Type.Number({ default: 20000 })),
157
+ wait_for: Type.Optional(Type.Number({ description: "Milliseconds to wait for Firecrawl dynamic rendering before extraction. Full mode uses Crawl4AI /md and may ignore this." })),
158
+ mobile: Type.Optional(Type.Boolean({ default: false, description: "Emulate mobile viewport (dynamic mode only)." })),
159
+ ...sharedControlSchema,
221
160
  }),
222
161
  async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
223
- const formats = String(params.formats || "markdown")
224
- .split(",")
225
- .map((f: string) => f.trim())
226
- .filter(Boolean)
227
- .map((f: string) =>
228
- f === "json" && (params.prompt || params.schema)
229
- ? { type: "json", ...(params.prompt ? { prompt: params.prompt } : {}), ...(params.schema ? { schema: params.schema } : {}) }
230
- : f,
231
- );
232
- const body: Record<string, unknown> = {
233
- url: params.url,
234
- formats,
235
- onlyMainContent: params.only_main_content !== false,
236
- ...(params.wait_for ? { waitFor: params.wait_for } : {}),
237
- ...(params.timeout ? { timeout: params.timeout } : {}),
238
- ...(params.mobile ? { mobile: true } : {}),
239
- ...(params.country ? { location: { country: String(params.country).toUpperCase() } } : {}),
240
- ...(params.actions ? { actions: params.actions } : {}),
241
- };
242
- const result = await firecrawlRequest(loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx)), "POST", "/scrape", body, true, signal);
243
- return { content: [{ type: "text" as const, text: truncateText(formatFirecrawlScrape((result as FirecrawlResult) as Record<string, unknown>, (params.content_chars as number) ?? 20000)) }], details: result };
162
+ const diagnostics = await extractWithDiagnostics({
163
+ url: params.url as string,
164
+ mode: params.mode as ExtractMode | undefined,
165
+ prompt: params.prompt as string | undefined,
166
+ schema: params.schema,
167
+ content_chars: params.content_chars as number | undefined,
168
+ timeout_ms: params.timeout_ms as number | undefined,
169
+ wait_for: params.wait_for as number | undefined,
170
+ mobile: params.mobile as boolean | undefined,
171
+ signal,
172
+ _ctx: ctx,
173
+ });
174
+ const result = diagnostics.result;
175
+ const attempts = diagnostics.attempts.map((a) => `${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
176
+ const text = `${result.title ? `# ${result.title}\n\n` : ""}${result.markdown}\n\n--- Extraction diagnostics ---\nSelected mode: ${diagnostics.selectedMode}\nFallback used: ${diagnostics.fallbackUsed}\n${attempts}`;
177
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: { url: params.url, ...diagnostics } };
244
178
  },
245
179
  });
246
180
 
247
- // ── Firecrawl Map ────────────────────────────────────────────────────
181
+ // ── web_map ──────────────────────────────────────────────────────────
248
182
  pi.registerTool({
249
- name: "firecrawl_map",
250
- label: "Firecrawl Map",
183
+ name: "web_map",
184
+ label: "Site URL Discovery",
251
185
  description:
252
- "Discover URLs from a site with Firecrawl map. Best before crawling or when you need candidate pages from a site/docs section.",
186
+ "Discover URLs from a site using Firecrawl Map. Best for finding candidate pages from a site or docs section before targeted extraction. Works best on base domains; may return empty results on sub-paths.",
253
187
  promptSnippet: "Map site URLs",
254
188
  promptGuidelines: [
255
- "Use before crawling to identify relevant URLs and avoid unnecessary broad crawls.",
256
- "Keep limits small unless the user explicitly asks for broad site discovery.",
257
- "Use mapped URLs with firecrawl_scrape for targeted extraction.",
189
+ "Use web_map to discover URLs from a site before crawling or targeted extraction.",
190
+ "Works best on base domains (e.g., https://example.com). Returns fewer results on sub-paths; use sitemap: 'only' for sitemap-only discovery on sub-paths.",
191
+ "Use sitemap: 'only' for sitemap-only discovery.",
192
+ "Use mapped URLs with web_extract for targeted extraction instead of web_crawl when only a few pages are needed.",
193
+ "Keep limits small unless broad site discovery is explicitly requested.",
258
194
  ],
259
195
  parameters: Type.Object({
260
- ...firecrawlControlSchema,
261
196
  url: Type.String(),
262
197
  limit: Type.Optional(Type.Number({ default: 100 })),
263
198
  include_subdomains: Type.Optional(Type.Boolean({ default: false })),
@@ -265,6 +200,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
265
200
  sitemap: Type.Optional(Type.Union([Type.Literal("only"), Type.Literal("include"), Type.Literal("skip")], { description: "Sitemap mode: include (default), only (sitemap-only), or skip." })),
266
201
  use_index: Type.Optional(Type.Boolean({ default: true, description: "Whether to use the Firecrawl search index for URL discovery." })),
267
202
  ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached map results." })),
203
+ ...firecrawlControlSchema,
268
204
  }),
269
205
  async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
270
206
  const body: Record<string, unknown> = {
@@ -276,14 +212,8 @@ export default function piWebExtension(pi: ExtensionAPI) {
276
212
  if (params.sitemap !== undefined) body.sitemap = params.sitemap;
277
213
  if (params.use_index !== undefined) body.useIndex = params.use_index;
278
214
  if (params.ignore_cache !== undefined) body.ignoreCache = params.ignore_cache;
279
- const result = await firecrawlRequest(
280
- loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx)),
281
- "POST",
282
- "/map",
283
- body,
284
- true,
285
- signal,
286
- );
215
+ const config = loadFirecrawlConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
216
+ const result = await firecrawlRequest(config, "POST", "/map", body, true, signal);
287
217
  const urls = result.data || result.links || result.urls || [];
288
218
  const text = Array.isArray(urls) && urls.length > 0
289
219
  ? (urls as Array<Record<string, unknown> | string>).map((u: any) => u.url || u).join("\n")
@@ -292,46 +222,69 @@ export default function piWebExtension(pi: ExtensionAPI) {
292
222
  },
293
223
  });
294
224
 
295
- // ── Firecrawl Crawl ──────────────────────────────────────────────────
225
+ // ── web_crawl ────────────────────────────────────────────────────────
296
226
  pi.registerTool({
297
- name: "firecrawl_crawl",
298
- label: "Firecrawl Crawl",
227
+ name: "web_crawl",
228
+ label: "Site Crawl",
299
229
  description:
300
- "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.",
230
+ "Crawl one or more pages from a site. Uses Firecrawl Crawl in 'light' mode (conservative, doc-focused) or Crawl4AI in 'full' mode (headless browser, rendered data, media, links). Accepts a single URL for Firecrawl-style crawling or multiple URLs for Crawl4AI-style crawling.",
301
231
  promptSnippet: "Crawl a small site section",
302
232
  promptGuidelines: [
303
- "Use only when multiple pages from a site are needed; prefer firecrawl_map plus targeted scrape when possible.",
304
- "Keep limits conservative and use include/exclude paths to narrow scope.",
305
- "Avoid large crawls unless the user explicitly requests them and accepts runtime/load.",
233
+ "Use web_crawl only when multiple pages from a site are truly needed; prefer web_map + web_extract for small numbers of pages.",
234
+ "Use mode: 'light' (default) for conservative Firecrawl crawling of docs/sites.",
235
+ "Use mode: 'full' for Crawl4AI crawling with full rendered data (JS, media, links). More resource-intensive.",
236
+ "For Firecrawl mode ('light'), use the url parameter with optional include_paths/exclude_paths.",
237
+ "For Crawl4AI mode ('full'), use the urls (array) parameter for batch crawling up to 100 URLs.",
238
+ "Keep limits conservative (default 10) unless the user explicitly requests large crawls.",
239
+ "Try web_map first to identify candidate URLs and pass them to web_extract before resorting to a full crawl.",
306
240
  ],
307
241
  parameters: Type.Object({
308
- ...firecrawlControlSchema,
309
- url: Type.String(),
242
+ url: Type.Optional(Type.String({ description: "URL for Firecrawl-style single-page crawl (mode: 'light')." })),
243
+ urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for Crawl4AI-style multi-URL crawl (mode: 'full'), up to 100." })),
244
+ mode: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("full")], { default: "light", description: "Crawl mode: 'light' (Firecrawl, conservative) or 'full' (Crawl4AI, rendered data)." })),
310
245
  limit: Type.Optional(Type.Number({ default: 10 })),
311
- include_paths: Type.Optional(Type.String()),
312
- exclude_paths: Type.Optional(Type.String()),
313
- poll: Type.Optional(Type.Boolean({ default: false })),
246
+ include_paths: Type.Optional(Type.String({ description: "Comma-separated path patterns to include (Firecrawl mode only)." })),
247
+ exclude_paths: Type.Optional(Type.String({ description: "Comma-separated path patterns to exclude (Firecrawl mode only)." })),
248
+ poll: Type.Optional(Type.Boolean({ default: false, description: "Poll for crawl completion (Firecrawl mode only)." })),
249
+ browser_config: Type.Optional(Type.Any({ description: "Optional BrowserConfig JSON object (Crawl4AI mode only)." })),
250
+ crawler_config: Type.Optional(Type.Any({ description: "Optional CrawlerRunConfig JSON object (Crawl4AI mode only)." })),
251
+ content_chars: Type.Optional(Type.Number({ default: 20000 })),
252
+ ...firecrawlControlSchema,
253
+ ...crawl4aiControlSchema,
314
254
  }),
315
255
  async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
316
- const config = loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
256
+ const mode = (params.mode as string) || "light";
257
+ const cwd = cwdFromContext(ctx);
258
+ const trusted = includeProjectEnv(ctx);
259
+ const maxChars = (params.content_chars as number) ?? 20000;
260
+
261
+ if (mode === "full") {
262
+ // Crawl4AI mode
263
+ const urls = (params.urls as string[]) || (params.url ? [params.url as string] : []);
264
+ if (!urls.length) throw new Error("Either url or urls parameter is required for crawl.");
265
+ const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwd, trusted);
266
+ const browserConfig = params.browser_config as Record<string, unknown> | undefined;
267
+ const crawlerConfig = params.crawler_config as Record<string, unknown> | undefined;
268
+ const result = await fetchCrawl4aiCrawl(config, urls, browserConfig, crawlerConfig, signal);
269
+ const text = formatCrawl4aiResult(result as unknown as Record<string, unknown>, maxChars);
270
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
271
+ }
272
+
273
+ // Firecrawl mode ("light")
274
+ if (!params.url) throw new Error("The url parameter is required for Firecrawl mode ('light').");
275
+ const fcConfig = loadFirecrawlConfig(params as Record<string, unknown>, cwd, trusted);
317
276
  let result = await firecrawlRequest(
318
- config,
277
+ fcConfig,
319
278
  "POST",
320
279
  "/crawl",
321
280
  {
322
- url: params.url,
281
+ url: params.url as string,
323
282
  limit: Math.min(10000, Math.max(1, (params.limit as number) ?? 10)),
324
283
  includePaths: params.include_paths
325
- ? String(params.include_paths)
326
- .split(",")
327
- .map((s: string) => s.trim())
328
- .filter(Boolean)
284
+ ? String(params.include_paths).split(",").map((s: string) => s.trim()).filter(Boolean)
329
285
  : [],
330
286
  excludePaths: params.exclude_paths
331
- ? String(params.exclude_paths)
332
- .split(",")
333
- .map((s: string) => s.trim())
334
- .filter(Boolean)
287
+ ? String(params.exclude_paths).split(",").map((s: string) => s.trim()).filter(Boolean)
335
288
  : [],
336
289
  scrapeOptions: { formats: ["markdown"], onlyMainContent: true },
337
290
  },
@@ -342,8 +295,10 @@ export default function piWebExtension(pi: ExtensionAPI) {
342
295
  if (params.poll && id && !Array.isArray(result.data)) {
343
296
  const { abortableSleep } = await import("./lib/retry");
344
297
  for (let i = 0; i < 60; i++) {
345
- result = await firecrawlRequest(config, "GET", `/crawl/${id}`, undefined, true, signal);
346
- if (["completed", "failed", "cancelled"].includes(String(result.status || (result.data as Record<string, unknown> | undefined)?.status || ""))) break;
298
+ result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined, true, signal);
299
+ if (["completed", "failed", "cancelled"].includes(
300
+ String(result.status || (result.data as Record<string, unknown> | undefined)?.status || ""),
301
+ )) break;
347
302
  await abortableSleep(2000, signal);
348
303
  }
349
304
  }
@@ -351,9 +306,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
351
306
  ? (result.data as Record<string, unknown>[])
352
307
  : ((result.data as Record<string, unknown>)?.data as Record<string, unknown>[]) || [];
353
308
  const text = pages.length
354
- ? pages
355
- .map((p: Record<string, unknown>) => formatFirecrawlScrape({ data: p } as Record<string, unknown>, 12000))
356
- .join("\n\n---\n\n")
309
+ ? pages.map((p: Record<string, unknown>) => formatFirecrawlScrape({ data: p } as Record<string, unknown>, maxChars)).join("\n\n---\n\n")
357
310
  : id
358
311
  ? `Crawl started: ${id}\nUse poll=true or check Firecrawl status/dashboard.`
359
312
  : JSON.stringify(result, null, 2);
@@ -361,31 +314,105 @@ export default function piWebExtension(pi: ExtensionAPI) {
361
314
  },
362
315
  });
363
316
 
364
- // ── Web Status ───────────────────────────────────────────────────────
317
+ // ── web_screenshot ───────────────────────────────────────────────────
318
+ pi.registerTool({
319
+ name: "web_screenshot",
320
+ label: "Web Page Screenshot",
321
+ description:
322
+ "Capture a full-page PNG screenshot of a URL using Crawl4AI's headless browser. Returns a base64-encoded PNG image suitable for visual inspection.",
323
+ promptSnippet: "Screenshot a webpage",
324
+ promptGuidelines: [
325
+ "Use web_screenshot when a visual snapshot of a rendered page is needed, or when web_extract fails on a heavily JS-dependent or bot-protected page.",
326
+ "The screenshot is returned as a base64-encoded PNG string.",
327
+ "Use wait_for to delay capture for dynamic content to render (default 2 seconds).",
328
+ "Use wait_for_images to ensure images are loaded before capture.",
329
+ ],
330
+ parameters: Type.Object({
331
+ url: Type.String(),
332
+ wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capturing screenshot." })),
333
+ wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images to load before capture." })),
334
+ ...crawl4aiControlSchema,
335
+ }),
336
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
337
+ const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
338
+ const result = await fetchCrawl4aiScreenshot(
339
+ config,
340
+ params.url as string,
341
+ params.wait_for as number | undefined,
342
+ params.wait_for_images as boolean | undefined,
343
+ signal,
344
+ );
345
+ const screenshot = result.screenshot as string | undefined;
346
+ const artifactUrl = result.url as string | undefined;
347
+ const mime = result.mime as string | undefined;
348
+ const size = result.size as number | undefined;
349
+ let text = `Screenshot: ${params.url}\n`;
350
+ if (screenshot) text += `Data: base64 PNG (${screenshot.length} chars)\n`;
351
+ if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
352
+ if (mime) text += `MIME: ${mime}\n`;
353
+ if (size) text += `Size: ${size} bytes\n`;
354
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
355
+ },
356
+ });
357
+
358
+ // ── web_pdf ──────────────────────────────────────────────────────────
359
+ pi.registerTool({
360
+ name: "web_pdf",
361
+ label: "Web Page PDF",
362
+ description:
363
+ "Generate a PDF document of a URL using Crawl4AI's headless browser. Returns a base64-encoded PDF suitable for saving or archiving.",
364
+ promptSnippet: "PDF a webpage",
365
+ promptGuidelines: [
366
+ "Use web_pdf when a printable or archivable snapshot of a page is needed.",
367
+ "The PDF is returned as a base64-encoded string.",
368
+ ],
369
+ parameters: Type.Object({
370
+ url: Type.String(),
371
+ ...crawl4aiControlSchema,
372
+ }),
373
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
374
+ const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
375
+ const result = await fetchCrawl4aiPdf(config, params.url as string, signal);
376
+ const pdf = result.pdf as string | undefined;
377
+ const artifactUrl = result.url as string | undefined;
378
+ const size = result.size as number | undefined;
379
+ let text = `PDF: ${params.url}\n`;
380
+ if (pdf) text += `Data: base64 PDF (${pdf.length} chars)\n`;
381
+ if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
382
+ if (size) text += `Size: ${size} bytes\n`;
383
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
384
+ },
385
+ });
386
+
387
+ // ── web_status ───────────────────────────────────────────────────────
365
388
  pi.registerTool({
366
389
  name: "web_status",
367
390
  label: "Web Provider Status",
368
391
  description:
369
- "Show Brave, SearXNG, and Firecrawl configuration status without printing secrets.",
370
- promptSnippet: "Check web provider configuration",
392
+ "Show all web provider configuration status (Brave, SearXNG, Firecrawl, Crawl4AI) without printing secrets. Includes Crawl4AI server health check. Use when web tools fail due to missing credentials or configuration issues.",
393
+ promptSnippet: "Check web provider configuration and server status",
371
394
  promptGuidelines: [
372
- "Use when web tools fail due to missing credentials/config or before comparing Brave, SearXNG, and Firecrawl availability.",
395
+ "Use web_status when web tools fail due to missing credentials/config or to verify which backends are available.",
396
+ "The output shows which backends are configured; if a backend is missing, configure its env vars.",
373
397
  "Never print API key values; this tool reports only presence and source.",
398
+ "Shows Crawl4AI server health, version, and auth status when the server is reachable.",
374
399
  ],
375
400
  parameters: Type.Object({}),
376
- async execute(_id: string, _params: Record<string, unknown>, _signal: AbortSignal, _onUpdate: unknown, ctx: any) {
401
+ async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
377
402
  const cwd = cwdFromContext(ctx);
378
403
  const trusted = includeProjectEnv(ctx);
404
+
405
+ // Provider config status
379
406
  const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
380
407
  const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
381
408
  const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
382
409
  const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
383
- const status = {
410
+ const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
411
+ const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
412
+
413
+ const status: Record<string, unknown> = {
384
414
  brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" },
385
- searxng: {
386
- baseUrl: normalizeSearxngBaseUrl(searxngUrl.value),
387
- baseUrlSource: searxngUrl.source || "default local",
388
- },
415
+ searxng: { baseUrl: normalizeSearxngBaseUrl(searxngUrl.value), baseUrlSource: searxngUrl.source || "default local" },
389
416
  firecrawl: {
390
417
  baseUrl: normalizeFirecrawlBaseUrl(fireUrl.value),
391
418
  apiUrlSource: fireUrl.source || "default hosted",
@@ -393,7 +420,24 @@ export default function piWebExtension(pi: ExtensionAPI) {
393
420
  apiKeySource: fireKey.value ? fireKey.source : "not set",
394
421
  hostedMode: !fireUrl.value || normalizeFirecrawlBaseUrl(fireUrl.value).startsWith(HOSTED_FIRECRAWL_BASE_URL),
395
422
  },
423
+ crawl4ai: {
424
+ baseUrl: normalizeCrawl4aiApiUrl(c4aiUrl.value),
425
+ baseUrlSource: c4aiUrl.source || "default",
426
+ apiTokenFound: Boolean(c4aiToken.value),
427
+ apiTokenSource: c4aiToken.value ? c4aiToken.source : "not set",
428
+ },
396
429
  };
430
+
431
+ // Crawl4AI health check
432
+ let c4aiHealth: Record<string, unknown> | undefined;
433
+ try {
434
+ const c4aiCfg = loadCrawl4aiConfig({}, cwd, trusted);
435
+ c4aiHealth = await fetchCrawl4aiHealth(c4aiCfg, signal);
436
+ } catch (e: any) {
437
+ c4aiHealth = { status: "unreachable", error: e?.message ?? String(e) };
438
+ }
439
+ status.crawl4ai = { ...(status.crawl4ai as Record<string, unknown>), health: c4aiHealth };
440
+
397
441
  return { content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }], details: status };
398
442
  },
399
443
  });
@@ -408,8 +452,17 @@ export default function piWebExtension(pi: ExtensionAPI) {
408
452
  const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
409
453
  const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
410
454
  const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
455
+ const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
456
+ const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
411
457
  ctx.ui.notify(
412
- `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"}`,
458
+ [
459
+ `Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
460
+ `SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
461
+ `Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
462
+ `Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
463
+ `Crawl4AI API URL: ${normalizeCrawl4aiApiUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
464
+ `Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
465
+ ].join("\n"),
413
466
  "info",
414
467
  );
415
468
  },