@bacnh85/pi-web 0.3.0 → 0.4.3
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 +171 -77
- package/extensions/.mocharc.yml +5 -0
- package/extensions/index.ts +474 -0
- package/extensions/lib/brave.ts +45 -0
- package/{lib → extensions/lib}/config.ts +8 -10
- package/{lib → extensions/lib}/crawl4ai.ts +11 -45
- package/extensions/lib/extract.ts +185 -0
- package/{lib → extensions/lib}/firecrawl.ts +7 -19
- package/{lib → extensions/lib}/format.ts +2 -71
- package/extensions/lib/retry.ts +29 -0
- package/extensions/lib/search.ts +240 -0
- package/extensions/lib/searxng.ts +69 -0
- package/extensions/package.json +3 -0
- package/extensions/test/unit/config.test.ts +309 -0
- package/extensions/test/unit/crawl4ai.test.ts +69 -0
- package/extensions/test/unit/extract.test.ts +134 -0
- package/extensions/test/unit/format.test.ts +194 -0
- package/extensions/test/unit/search.test.ts +147 -0
- package/package.json +11 -10
- package/skills/pi-web/SKILL.md +108 -0
- package/index.ts +0 -638
- package/lib/brave.ts +0 -40
- package/lib/retry.ts +0 -142
- package/lib/searxng.ts +0 -63
- /package/{lib → extensions/lib}/content.ts +0 -0
- /package/{types.d.ts → extensions/types.d.ts} +0 -0
package/index.ts
DELETED
|
@@ -1,638 +0,0 @@
|
|
|
1
|
-
/// <reference path="./types.d.ts" />
|
|
2
|
-
|
|
3
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Type } from "typebox";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
findEnvValue,
|
|
8
|
-
cwdFromContext,
|
|
9
|
-
includeProjectEnv,
|
|
10
|
-
normalizeSearxngBaseUrl,
|
|
11
|
-
normalizeFirecrawlBaseUrl,
|
|
12
|
-
normalizeCrawl4aiBaseUrl,
|
|
13
|
-
loadSearxngConfig,
|
|
14
|
-
loadFirecrawlConfig,
|
|
15
|
-
loadCrawl4aiConfig,
|
|
16
|
-
HOSTED_FIRECRAWL_BASE_URL,
|
|
17
|
-
DEFAULT_CRAWL4AI_BASE_URL,
|
|
18
|
-
} from "./lib/config";
|
|
19
|
-
import {
|
|
20
|
-
truncateText,
|
|
21
|
-
formatSearchResults,
|
|
22
|
-
formatFirecrawlScrape,
|
|
23
|
-
formatFirecrawlSearch,
|
|
24
|
-
formatCrawl4aiResult,
|
|
25
|
-
formatCrawl4aiStream,
|
|
26
|
-
} from "./lib/format";
|
|
27
|
-
import { fetchReadableContent } from "./lib/content";
|
|
28
|
-
import { fetchBraveResults } from "./lib/brave";
|
|
29
|
-
import { fetchSearxngResults } from "./lib/searxng";
|
|
30
|
-
import { firecrawlRequest, type FirecrawlResult } from "./lib/firecrawl";
|
|
31
|
-
import {
|
|
32
|
-
fetchCrawl4aiMarkdown,
|
|
33
|
-
fetchCrawl4aiCrawl,
|
|
34
|
-
fetchCrawl4aiStream,
|
|
35
|
-
fetchCrawl4aiScreenshot,
|
|
36
|
-
fetchCrawl4aiPdf,
|
|
37
|
-
fetchCrawl4aiHealth,
|
|
38
|
-
} from "./lib/crawl4ai";
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// Schema fragments shared across tool registrations
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
const controlSchema = {
|
|
45
|
-
api_key: Type.Optional(Type.String({ description: "Provider API key override. Prefer env vars." })),
|
|
46
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const firecrawlControlSchema = {
|
|
50
|
-
firecrawl_api_key: Type.Optional(Type.String({ description: "Firecrawl API key override. Prefer FIRECRAWL_API_KEY." })),
|
|
51
|
-
firecrawl_api_url: Type.Optional(Type.String({ description: "Firecrawl API URL override. Defaults to FIRECRAWL_API_URL or hosted." })),
|
|
52
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const searxngControlSchema = {
|
|
56
|
-
searxng_base_url: Type.Optional(Type.String({ description: "SearXNG base URL override. Defaults to SEARXNG_BASE_URL or http://172.30.55.22:8888." })),
|
|
57
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const crawl4aiControlSchema = {
|
|
61
|
-
crawl4ai_base_url: Type.Optional(Type.String({ description: "Crawl4AI base URL override. Defaults to CRAWL4AI_BASE_URL or http://172.30.55.22:11235." })),
|
|
62
|
-
crawl4ai_api_token: Type.Optional(Type.String({ description: "Crawl4AI API token override. Prefer CRAWL4AI_API_TOKEN." })),
|
|
63
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Extension entry point
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
export default function piWebExtension(pi: ExtensionAPI) {
|
|
71
|
-
// ── Brave Search ──────────────────────────────────────────────────────
|
|
72
|
-
pi.registerTool({
|
|
73
|
-
name: "brave_search",
|
|
74
|
-
label: "Brave Search",
|
|
75
|
-
description:
|
|
76
|
-
"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.",
|
|
77
|
-
promptSnippet: "Search current web results with Brave",
|
|
78
|
-
promptGuidelines: [
|
|
79
|
-
"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.",
|
|
80
|
-
"Use include_content only for a small number of results to avoid rate limits and extra page fetches.",
|
|
81
|
-
"Cite result URLs when web results materially support the answer; if Brave is rate-limited, try SearXNG or Firecrawl search.",
|
|
82
|
-
],
|
|
83
|
-
parameters: Type.Object({
|
|
84
|
-
...controlSchema,
|
|
85
|
-
query: Type.String(),
|
|
86
|
-
count: Type.Optional(Type.Number({ default: 5 })),
|
|
87
|
-
country: Type.Optional(Type.String({ default: "US" })),
|
|
88
|
-
freshness: Type.Optional(Type.String()),
|
|
89
|
-
include_content: Type.Optional(Type.Boolean({ default: false })),
|
|
90
|
-
content_chars: Type.Optional(Type.Number({ default: 5000 })),
|
|
91
|
-
}),
|
|
92
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
93
|
-
const cwd = cwdFromContext(ctx);
|
|
94
|
-
const apiKey = (params.api_key as string) || findEnvValue("BRAVE_API_KEY", cwd, includeProjectEnv(ctx)).value;
|
|
95
|
-
if (!apiKey) throw new Error("BRAVE_API_KEY is required.");
|
|
96
|
-
const count = Math.min(20, Math.max(1, (params.count as number) ?? 5));
|
|
97
|
-
const results = await fetchBraveResults(
|
|
98
|
-
params.query as string,
|
|
99
|
-
count,
|
|
100
|
-
((params.country as string) || "US").toUpperCase(),
|
|
101
|
-
(params.freshness as string) || "",
|
|
102
|
-
apiKey,
|
|
103
|
-
signal,
|
|
104
|
-
);
|
|
105
|
-
if (params.include_content) {
|
|
106
|
-
await Promise.all(
|
|
107
|
-
results.map(async (r) => {
|
|
108
|
-
try {
|
|
109
|
-
const content = await fetchReadableContent(r.url, (params.timeout_ms as number) || 10000, signal);
|
|
110
|
-
r.content = content.markdown.slice(0, (params.content_chars as number) ?? 5000);
|
|
111
|
-
} catch (e: any) {
|
|
112
|
-
r.content = `(Fetch error for ${r.url}: ${e?.message || String(e)})`;
|
|
113
|
-
}
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
return { content: [{ type: "text" as const, text: truncateText(formatSearchResults(results)) }], details: results };
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// ── Web Content (readable extraction) ─────────────────────────────────
|
|
122
|
-
pi.registerTool({
|
|
123
|
-
name: "web_content",
|
|
124
|
-
label: "Web Content",
|
|
125
|
-
description:
|
|
126
|
-
"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.",
|
|
127
|
-
promptSnippet: "Extract readable webpage content as markdown",
|
|
128
|
-
promptGuidelines: [
|
|
129
|
-
"Use for known URLs when simple readable markdown extraction is enough.",
|
|
130
|
-
"Prefer Firecrawl scrape for dynamic pages, pages that block direct fetching, or when links/structured extraction are needed.",
|
|
131
|
-
"Cite the source URL when using extracted content in an answer.",
|
|
132
|
-
],
|
|
133
|
-
parameters: Type.Object({
|
|
134
|
-
url: Type.String(),
|
|
135
|
-
timeout_ms: Type.Optional(Type.Number({ default: 15000 })),
|
|
136
|
-
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
137
|
-
}),
|
|
138
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal) {
|
|
139
|
-
const parsed = new URL(params.url as string);
|
|
140
|
-
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("URL must use http or https.");
|
|
141
|
-
const article = await fetchReadableContent(parsed.href, (params.timeout_ms as number) || 15000, signal);
|
|
142
|
-
const text = `${article.title ? `# ${article.title}\n\n` : ""}${article.markdown.slice(0, (params.content_chars as number) ?? 20000)}`;
|
|
143
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { url: parsed.href, ...article } };
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
// ── SearXNG Search ───────────────────────────────────────────────────
|
|
148
|
-
pi.registerTool({
|
|
149
|
-
name: "searxng_search",
|
|
150
|
-
label: "SearXNG Search",
|
|
151
|
-
description:
|
|
152
|
-
"Search the web through the configured self-hosted SearXNG metasearch instance. Best for free, self-hosted source discovery without hosted API keys.",
|
|
153
|
-
promptSnippet: "Search web results with self-hosted SearXNG",
|
|
154
|
-
promptGuidelines: [
|
|
155
|
-
"Use SearXNG for self-hosted metasearch and source discovery, especially when Brave is unavailable or avoiding hosted search APIs.",
|
|
156
|
-
"Use SearXNG before browser/scrape tools when the task starts with finding URLs or sources.",
|
|
157
|
-
"Cite result URLs when SearXNG results materially support the answer.",
|
|
158
|
-
],
|
|
159
|
-
parameters: Type.Object({
|
|
160
|
-
...searxngControlSchema,
|
|
161
|
-
query: Type.String(),
|
|
162
|
-
count: Type.Optional(Type.Number({ default: 5 })),
|
|
163
|
-
pageno: Type.Optional(Type.Number({ default: 1 })),
|
|
164
|
-
categories: Type.Optional(Type.String({ description: "Comma-separated SearXNG categories, e.g. general,science." })),
|
|
165
|
-
engines: Type.Optional(Type.String({ description: "Comma-separated SearXNG engines, e.g. brave,wikipedia." })),
|
|
166
|
-
language: Type.Optional(Type.String()),
|
|
167
|
-
time_range: Type.Optional(Type.Union([Type.Literal("day"), Type.Literal("month"), Type.Literal("year")])),
|
|
168
|
-
safesearch: Type.Optional(Type.Number({ description: "SearXNG safesearch level: 0, 1, or 2." })),
|
|
169
|
-
}),
|
|
170
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
171
|
-
const config = loadSearxngConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
172
|
-
const result = await fetchSearxngResults(params, config.baseUrl, signal);
|
|
173
|
-
return { content: [{ type: "text" as const, text: truncateText(formatSearchResults(result.results || [])) }], details: { ...result, baseUrl: config.baseUrl } };
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// ── Firecrawl Search ─────────────────────────────────────────────────
|
|
178
|
-
pi.registerTool({
|
|
179
|
-
name: "firecrawl_search",
|
|
180
|
-
label: "Firecrawl Search",
|
|
181
|
-
description:
|
|
182
|
-
"Search web/news/images through Firecrawl, optionally scraping result markdown. Best when SearXNG/Brave are unavailable or when Firecrawl-backed search/scrape is preferred.",
|
|
183
|
-
promptSnippet: "Search with Firecrawl",
|
|
184
|
-
promptGuidelines: [
|
|
185
|
-
"Use when SearXNG and Brave are missing/rate-limited/insufficient, when Firecrawl search is preferred, or when you need scraped markdown from results.",
|
|
186
|
-
"Keep limits conservative; scraped search is slower and heavier than plain search.",
|
|
187
|
-
"Cite result URLs and verify scraped content before relying on it.",
|
|
188
|
-
],
|
|
189
|
-
parameters: Type.Object({
|
|
190
|
-
...firecrawlControlSchema,
|
|
191
|
-
query: Type.String(),
|
|
192
|
-
limit: Type.Optional(Type.Number({ default: 5 })),
|
|
193
|
-
sources: Type.Optional(Type.String({ description: "Comma-separated sources: web,news,images", default: "web" })),
|
|
194
|
-
country: Type.Optional(Type.String()),
|
|
195
|
-
location: Type.Optional(Type.String()),
|
|
196
|
-
tbs: Type.Optional(Type.String()),
|
|
197
|
-
scrape: Type.Optional(Type.Boolean({ default: false })),
|
|
198
|
-
content_chars: Type.Optional(Type.Number({ default: 5000 })),
|
|
199
|
-
}),
|
|
200
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
201
|
-
const config = loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
202
|
-
const sources = String(params.sources || "web")
|
|
203
|
-
.split(",")
|
|
204
|
-
.map((type: string) => ({ type: type.trim() }))
|
|
205
|
-
.filter((s: { type: string }) => s.type);
|
|
206
|
-
const body: Record<string, unknown> = {
|
|
207
|
-
query: params.query,
|
|
208
|
-
limit: Math.min(100, Math.max(1, (params.limit as number) ?? 5)),
|
|
209
|
-
sources,
|
|
210
|
-
...(params.country ? { country: String(params.country).toUpperCase() } : {}),
|
|
211
|
-
...(params.location ? { location: params.location } : {}),
|
|
212
|
-
...(params.tbs ? { tbs: params.tbs } : {}),
|
|
213
|
-
...(params.scrape ? { scrapeOptions: { formats: ["markdown"], onlyMainContent: true } } : {}),
|
|
214
|
-
};
|
|
215
|
-
const result = await firecrawlRequest(config, "POST", "/search", body, true, signal);
|
|
216
|
-
return { content: [{ type: "text" as const, text: truncateText(formatFirecrawlSearch((result as FirecrawlResult) as Record<string, unknown>, (params.content_chars as number) ?? 5000)) }], details: result };
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
// ── Firecrawl Scrape ─────────────────────────────────────────────────
|
|
221
|
-
pi.registerTool({
|
|
222
|
-
name: "firecrawl_scrape",
|
|
223
|
-
label: "Firecrawl Scrape",
|
|
224
|
-
description:
|
|
225
|
-
"Scrape a URL through Firecrawl as markdown/html/links/summary/json. Best for dynamic pages, higher-quality extraction, links, or structured JSON extraction.",
|
|
226
|
-
promptSnippet: "Scrape a URL with Firecrawl",
|
|
227
|
-
promptGuidelines: [
|
|
228
|
-
"Use for known URLs when extraction quality matters, pages are dynamic, or markdown/links/JSON extraction is needed.",
|
|
229
|
-
"Use prompt/schema only for explicit structured extraction tasks.",
|
|
230
|
-
"Prefer web_content for simple fast known-URL extraction when Firecrawl is unnecessary.",
|
|
231
|
-
],
|
|
232
|
-
parameters: Type.Object({
|
|
233
|
-
...firecrawlControlSchema,
|
|
234
|
-
url: Type.String(),
|
|
235
|
-
formats: Type.Optional(Type.String({ default: "markdown" })),
|
|
236
|
-
prompt: Type.Optional(Type.String()),
|
|
237
|
-
schema: Type.Optional(Type.Any()),
|
|
238
|
-
wait_for: Type.Optional(Type.Number()),
|
|
239
|
-
timeout: Type.Optional(Type.Number()),
|
|
240
|
-
mobile: Type.Optional(Type.Boolean()),
|
|
241
|
-
country: Type.Optional(Type.String()),
|
|
242
|
-
actions: Type.Optional(Type.Any()),
|
|
243
|
-
only_main_content: Type.Optional(Type.Boolean({ default: true })),
|
|
244
|
-
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
245
|
-
}),
|
|
246
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
247
|
-
const formats = String(params.formats || "markdown")
|
|
248
|
-
.split(",")
|
|
249
|
-
.map((f: string) => f.trim())
|
|
250
|
-
.filter(Boolean)
|
|
251
|
-
.map((f: string) =>
|
|
252
|
-
f === "json" && (params.prompt || params.schema)
|
|
253
|
-
? { type: "json", ...(params.prompt ? { prompt: params.prompt } : {}), ...(params.schema ? { schema: params.schema } : {}) }
|
|
254
|
-
: f,
|
|
255
|
-
);
|
|
256
|
-
const body: Record<string, unknown> = {
|
|
257
|
-
url: params.url,
|
|
258
|
-
formats,
|
|
259
|
-
onlyMainContent: params.only_main_content !== false,
|
|
260
|
-
...(params.wait_for ? { waitFor: params.wait_for } : {}),
|
|
261
|
-
...(params.timeout ? { timeout: params.timeout } : {}),
|
|
262
|
-
...(params.mobile ? { mobile: true } : {}),
|
|
263
|
-
...(params.country ? { location: { country: String(params.country).toUpperCase() } } : {}),
|
|
264
|
-
...(params.actions ? { actions: params.actions } : {}),
|
|
265
|
-
};
|
|
266
|
-
const result = await firecrawlRequest(loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx)), "POST", "/scrape", body, true, signal);
|
|
267
|
-
return { content: [{ type: "text" as const, text: truncateText(formatFirecrawlScrape((result as FirecrawlResult) as Record<string, unknown>, (params.content_chars as number) ?? 20000)) }], details: result };
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// ── Firecrawl Map ────────────────────────────────────────────────────
|
|
272
|
-
pi.registerTool({
|
|
273
|
-
name: "firecrawl_map",
|
|
274
|
-
label: "Firecrawl Map",
|
|
275
|
-
description:
|
|
276
|
-
"Discover URLs from a site with Firecrawl map. Best before crawling or when you need candidate pages from a site/docs section.",
|
|
277
|
-
promptSnippet: "Map site URLs",
|
|
278
|
-
promptGuidelines: [
|
|
279
|
-
"Use before crawling to identify relevant URLs and avoid unnecessary broad crawls.",
|
|
280
|
-
"Keep limits small unless the user explicitly asks for broad site discovery.",
|
|
281
|
-
"Use mapped URLs with firecrawl_scrape for targeted extraction.",
|
|
282
|
-
],
|
|
283
|
-
parameters: Type.Object({
|
|
284
|
-
...firecrawlControlSchema,
|
|
285
|
-
url: Type.String(),
|
|
286
|
-
limit: Type.Optional(Type.Number({ default: 100 })),
|
|
287
|
-
include_subdomains: Type.Optional(Type.Boolean({ default: false })),
|
|
288
|
-
search: Type.Optional(Type.String({ description: "Optional search query to guide URL discovery (semantic map)." })),
|
|
289
|
-
sitemap: Type.Optional(Type.Union([Type.Literal("only"), Type.Literal("include"), Type.Literal("skip")], { description: "Sitemap mode: include (default), only (sitemap-only), or skip." })),
|
|
290
|
-
use_index: Type.Optional(Type.Boolean({ default: true, description: "Whether to use the Firecrawl search index for URL discovery." })),
|
|
291
|
-
ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached map results." })),
|
|
292
|
-
}),
|
|
293
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
294
|
-
const body: Record<string, unknown> = {
|
|
295
|
-
url: params.url,
|
|
296
|
-
limit: (params.limit as number) ?? 100,
|
|
297
|
-
includeSubdomains: Boolean(params.include_subdomains),
|
|
298
|
-
};
|
|
299
|
-
if (params.search !== undefined) body.search = params.search;
|
|
300
|
-
if (params.sitemap !== undefined) body.sitemap = params.sitemap;
|
|
301
|
-
if (params.use_index !== undefined) body.useIndex = params.use_index;
|
|
302
|
-
if (params.ignore_cache !== undefined) body.ignoreCache = params.ignore_cache;
|
|
303
|
-
const result = await firecrawlRequest(
|
|
304
|
-
loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx)),
|
|
305
|
-
"POST",
|
|
306
|
-
"/map",
|
|
307
|
-
body,
|
|
308
|
-
true,
|
|
309
|
-
signal,
|
|
310
|
-
);
|
|
311
|
-
const urls = result.data || result.links || result.urls || [];
|
|
312
|
-
const text = Array.isArray(urls) && urls.length > 0
|
|
313
|
-
? (urls as Array<Record<string, unknown> | string>).map((u: any) => u.url || u).join("\n")
|
|
314
|
-
: JSON.stringify(result, null, 2);
|
|
315
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// ── Firecrawl Crawl ──────────────────────────────────────────────────
|
|
320
|
-
pi.registerTool({
|
|
321
|
-
name: "firecrawl_crawl",
|
|
322
|
-
label: "Firecrawl Crawl",
|
|
323
|
-
description:
|
|
324
|
-
"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.",
|
|
325
|
-
promptSnippet: "Crawl a small site section",
|
|
326
|
-
promptGuidelines: [
|
|
327
|
-
"Use only when multiple pages from a site are needed; prefer firecrawl_map plus targeted scrape when possible.",
|
|
328
|
-
"Keep limits conservative and use include/exclude paths to narrow scope.",
|
|
329
|
-
"Avoid large crawls unless the user explicitly requests them and accepts runtime/load.",
|
|
330
|
-
],
|
|
331
|
-
parameters: Type.Object({
|
|
332
|
-
...firecrawlControlSchema,
|
|
333
|
-
url: Type.String(),
|
|
334
|
-
limit: Type.Optional(Type.Number({ default: 10 })),
|
|
335
|
-
include_paths: Type.Optional(Type.String()),
|
|
336
|
-
exclude_paths: Type.Optional(Type.String()),
|
|
337
|
-
poll: Type.Optional(Type.Boolean({ default: false })),
|
|
338
|
-
}),
|
|
339
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
340
|
-
const config = loadFirecrawlConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
341
|
-
let result = await firecrawlRequest(
|
|
342
|
-
config,
|
|
343
|
-
"POST",
|
|
344
|
-
"/crawl",
|
|
345
|
-
{
|
|
346
|
-
url: params.url,
|
|
347
|
-
limit: Math.min(10000, Math.max(1, (params.limit as number) ?? 10)),
|
|
348
|
-
includePaths: params.include_paths
|
|
349
|
-
? String(params.include_paths)
|
|
350
|
-
.split(",")
|
|
351
|
-
.map((s: string) => s.trim())
|
|
352
|
-
.filter(Boolean)
|
|
353
|
-
: [],
|
|
354
|
-
excludePaths: params.exclude_paths
|
|
355
|
-
? String(params.exclude_paths)
|
|
356
|
-
.split(",")
|
|
357
|
-
.map((s: string) => s.trim())
|
|
358
|
-
.filter(Boolean)
|
|
359
|
-
: [],
|
|
360
|
-
scrapeOptions: { formats: ["markdown"], onlyMainContent: true },
|
|
361
|
-
},
|
|
362
|
-
true,
|
|
363
|
-
signal,
|
|
364
|
-
);
|
|
365
|
-
const id = result.id || (result.data as Record<string, unknown> | undefined)?.id;
|
|
366
|
-
if (params.poll && id && !Array.isArray(result.data)) {
|
|
367
|
-
const { abortableSleep } = await import("./lib/retry");
|
|
368
|
-
for (let i = 0; i < 60; i++) {
|
|
369
|
-
result = await firecrawlRequest(config, "GET", `/crawl/${id}`, undefined, true, signal);
|
|
370
|
-
if (["completed", "failed", "cancelled"].includes(String(result.status || (result.data as Record<string, unknown> | undefined)?.status || ""))) break;
|
|
371
|
-
await abortableSleep(2000, signal);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
const pages = Array.isArray(result.data)
|
|
375
|
-
? (result.data as Record<string, unknown>[])
|
|
376
|
-
: ((result.data as Record<string, unknown>)?.data as Record<string, unknown>[]) || [];
|
|
377
|
-
const text = pages.length
|
|
378
|
-
? pages
|
|
379
|
-
.map((p: Record<string, unknown>) => formatFirecrawlScrape({ data: p } as Record<string, unknown>, 12000))
|
|
380
|
-
.join("\n\n---\n\n")
|
|
381
|
-
: id
|
|
382
|
-
? `Crawl started: ${id}\nUse poll=true or check Firecrawl status/dashboard.`
|
|
383
|
-
: JSON.stringify(result, null, 2);
|
|
384
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
// ── Crawl4AI Scrape ──────────────────────────────────────────────────
|
|
389
|
-
pi.registerTool({
|
|
390
|
-
name: "crawl4ai_scrape",
|
|
391
|
-
label: "Crawl4AI Scrape",
|
|
392
|
-
description:
|
|
393
|
-
"Extract clean LLM-ready markdown from a URL using Crawl4AI's headless browser engine, with optional content filtering (fit, raw, bm25, llm). Best for JavaScript-rendered pages and when you need high-quality markdown for AI consumption.",
|
|
394
|
-
promptSnippet: "Scrape with Crawl4AI",
|
|
395
|
-
promptGuidelines: [
|
|
396
|
-
"Prefer crawl4ai_scrape over web_content for dynamic/JS-rendered pages.",
|
|
397
|
-
"Use filter=raw for full DOM-to-markdown, filter=fit (default) for Readability-based clean content.",
|
|
398
|
-
"Use filter=bm25 with a query for relevance-ranked content extraction.",
|
|
399
|
-
],
|
|
400
|
-
parameters: Type.Object({
|
|
401
|
-
...crawl4aiControlSchema,
|
|
402
|
-
url: Type.String(),
|
|
403
|
-
filter: Type.Optional(Type.Union([Type.Literal("fit"), Type.Literal("raw"), Type.Literal("bm25"), Type.Literal("llm")], { default: "fit", description: "Content-filter strategy: fit (default), raw, bm25, or llm." })),
|
|
404
|
-
query: Type.Optional(Type.String({ description: "Query string used by BM25 or LLM filters." })),
|
|
405
|
-
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
406
|
-
}),
|
|
407
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
408
|
-
const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
409
|
-
const filter = (params.filter as string) || "fit";
|
|
410
|
-
const query = params.query as string | undefined;
|
|
411
|
-
const result = await fetchCrawl4aiMarkdown(config, params.url as string, filter, query, signal);
|
|
412
|
-
const text = `# Crawl4AI Markdown (filter: ${filter})\nURL: ${params.url}\n\n${result.markdown.slice(0, (params.content_chars as number) ?? 20000)}`;
|
|
413
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
// ── Crawl4AI Crawl ──────────────────────────────────────────────────
|
|
418
|
-
pi.registerTool({
|
|
419
|
-
name: "crawl4ai_crawl",
|
|
420
|
-
label: "Crawl4AI Crawl",
|
|
421
|
-
description:
|
|
422
|
-
"Crawl one or more URLs with Crawl4AI's headless browser, returning full crawl data including markdown, HTML, links, and media. Supports up to 100 URLs per request.",
|
|
423
|
-
promptSnippet: "Crawl with Crawl4AI",
|
|
424
|
-
promptGuidelines: [
|
|
425
|
-
"Use crawl4ai_crawl when you need full crawl data (HTML, links, media) from multiple URLs.",
|
|
426
|
-
"For single-URL markdown-only extraction, prefer crawl4ai_scrape.",
|
|
427
|
-
"Keep to 10 or fewer URLs per request for reasonable response times.",
|
|
428
|
-
],
|
|
429
|
-
parameters: Type.Object({
|
|
430
|
-
...crawl4aiControlSchema,
|
|
431
|
-
urls: Type.Array(Type.String(), { minItems: 1, maxItems: 100, description: "URLs to crawl (1-100)." }),
|
|
432
|
-
browser_config: Type.Optional(Type.Any({ description: "Optional BrowserConfig JSON object." })),
|
|
433
|
-
crawler_config: Type.Optional(Type.Any({ description: "Optional CrawlerRunConfig JSON object." })),
|
|
434
|
-
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
435
|
-
}),
|
|
436
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
437
|
-
const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
438
|
-
const urls = params.urls as string[];
|
|
439
|
-
const browserConfig = params.browser_config as Record<string, unknown> | undefined;
|
|
440
|
-
const crawlerConfig = params.crawler_config as Record<string, unknown> | undefined;
|
|
441
|
-
const result = await fetchCrawl4aiCrawl(config, urls, browserConfig, crawlerConfig, signal);
|
|
442
|
-
const text = formatCrawl4aiResult(result as unknown as Record<string, unknown>, (params.content_chars as number) ?? 20000);
|
|
443
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
444
|
-
},
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
// ── Crawl4AI Stream ─────────────────────────────────────────────────
|
|
448
|
-
pi.registerTool({
|
|
449
|
-
name: "crawl4ai_stream",
|
|
450
|
-
label: "Crawl4AI Stream",
|
|
451
|
-
description:
|
|
452
|
-
"Crawl one or more URLs with Crawl4AI using streaming response (SSE-style JSON lines). Results arrive as each URL is processed, useful for long-running multi-URL crawls.",
|
|
453
|
-
promptSnippet: "Stream crawl with Crawl4AI",
|
|
454
|
-
promptGuidelines: [
|
|
455
|
-
"Use crawl4ai_stream for large batch crawls where you want results incrementally.",
|
|
456
|
-
"The response is newline-delimited JSON; each line is a complete CrawlResult.",
|
|
457
|
-
"The stream ends with a line containing {\"status\": \"completed\"}.",
|
|
458
|
-
],
|
|
459
|
-
parameters: Type.Object({
|
|
460
|
-
...crawl4aiControlSchema,
|
|
461
|
-
urls: Type.Array(Type.String(), { minItems: 1, maxItems: 100, description: "URLs to crawl (1-100)." }),
|
|
462
|
-
browser_config: Type.Optional(Type.Any({ description: "Optional BrowserConfig JSON object." })),
|
|
463
|
-
crawler_config: Type.Optional(Type.Any({ description: "Optional CrawlerRunConfig JSON object." })),
|
|
464
|
-
}),
|
|
465
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
466
|
-
const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
467
|
-
const urls = params.urls as string[];
|
|
468
|
-
const browserConfig = params.browser_config as Record<string, unknown> | undefined;
|
|
469
|
-
const crawlerConfig = params.crawler_config as Record<string, unknown> | undefined;
|
|
470
|
-
const raw = await fetchCrawl4aiStream(config, urls, browserConfig, crawlerConfig, signal);
|
|
471
|
-
const text = formatCrawl4aiStream(raw);
|
|
472
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { raw_length: raw.length } };
|
|
473
|
-
},
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
// ── Crawl4AI Screenshot ─────────────────────────────────────────────
|
|
477
|
-
pi.registerTool({
|
|
478
|
-
name: "crawl4ai_screenshot",
|
|
479
|
-
label: "Crawl4AI Screenshot",
|
|
480
|
-
description:
|
|
481
|
-
"Capture a full-page PNG screenshot of a URL using Crawl4AI's headless browser. Returns a base64-encoded screenshot image.",
|
|
482
|
-
promptSnippet: "Screenshot with Crawl4AI",
|
|
483
|
-
promptGuidelines: [
|
|
484
|
-
"Use crawl4ai_screenshot when you need a visual snapshot of a rendered page.",
|
|
485
|
-
"The screenshot is returned as a base64-encoded PNG string.",
|
|
486
|
-
"Use the wait_for parameter to delay capture for dynamic content to render.",
|
|
487
|
-
],
|
|
488
|
-
parameters: Type.Object({
|
|
489
|
-
...crawl4aiControlSchema,
|
|
490
|
-
url: Type.String(),
|
|
491
|
-
wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capturing screenshot." })),
|
|
492
|
-
wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images to load before capture." })),
|
|
493
|
-
}),
|
|
494
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
495
|
-
const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
496
|
-
const result = await fetchCrawl4aiScreenshot(config, params.url as string, params.wait_for as number | undefined, params.wait_for_images as boolean | undefined, signal);
|
|
497
|
-
const screenshot = result.screenshot as string | undefined;
|
|
498
|
-
const artifactUrl = result.url as string | undefined;
|
|
499
|
-
const mime = result.mime as string | undefined;
|
|
500
|
-
const size = result.size as number | undefined;
|
|
501
|
-
let text = `Screenshot: ${params.url}\n`;
|
|
502
|
-
if (screenshot) text += `Data: base64 PNG (${screenshot.length} chars)\n`;
|
|
503
|
-
if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
|
|
504
|
-
if (mime) text += `MIME: ${mime}\n`;
|
|
505
|
-
if (size) text += `Size: ${size} bytes\n`;
|
|
506
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
|
|
507
|
-
},
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
// ── Crawl4AI PDF ────────────────────────────────────────────────────
|
|
511
|
-
pi.registerTool({
|
|
512
|
-
name: "crawl4ai_pdf",
|
|
513
|
-
label: "Crawl4AI PDF",
|
|
514
|
-
description:
|
|
515
|
-
"Generate a PDF document of a URL using Crawl4AI's headless browser. Returns a base64-encoded PDF suitable for saving or further processing.",
|
|
516
|
-
promptSnippet: "PDF with Crawl4AI",
|
|
517
|
-
promptGuidelines: [
|
|
518
|
-
"Use crawl4ai_pdf when you need a printable or archivable snapshot of a page.",
|
|
519
|
-
"The PDF is returned as a base64-encoded string.",
|
|
520
|
-
],
|
|
521
|
-
parameters: Type.Object({
|
|
522
|
-
...crawl4aiControlSchema,
|
|
523
|
-
url: Type.String(),
|
|
524
|
-
}),
|
|
525
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
526
|
-
const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
527
|
-
const result = await fetchCrawl4aiPdf(config, params.url as string, signal);
|
|
528
|
-
const pdf = result.pdf as string | undefined;
|
|
529
|
-
const artifactUrl = result.url as string | undefined;
|
|
530
|
-
const size = result.size as number | undefined;
|
|
531
|
-
let text = `PDF: ${params.url}\n`;
|
|
532
|
-
if (pdf) text += `Data: base64 PDF (${pdf.length} chars)\n`;
|
|
533
|
-
if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
|
|
534
|
-
if (size) text += `Size: ${size} bytes\n`;
|
|
535
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
|
|
536
|
-
},
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
// ── Web Status ───────────────────────────────────────────────────────
|
|
540
|
-
pi.registerTool({
|
|
541
|
-
name: "web_status",
|
|
542
|
-
label: "Web Provider Status",
|
|
543
|
-
description:
|
|
544
|
-
"Show Brave, SearXNG, Firecrawl, and Crawl4AI configuration status without printing secrets.",
|
|
545
|
-
promptSnippet: "Check web provider configuration",
|
|
546
|
-
promptGuidelines: [
|
|
547
|
-
"Use when web tools fail due to missing credentials/config or before comparing Brave, SearXNG, Firecrawl, and Crawl4AI availability.",
|
|
548
|
-
"Never print API key values; this tool reports only presence and source.",
|
|
549
|
-
],
|
|
550
|
-
parameters: Type.Object({}),
|
|
551
|
-
async execute(_id: string, _params: Record<string, unknown>, _signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
552
|
-
const cwd = cwdFromContext(ctx);
|
|
553
|
-
const trusted = includeProjectEnv(ctx);
|
|
554
|
-
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
|
|
555
|
-
const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
|
|
556
|
-
const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
|
|
557
|
-
const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
558
|
-
const c4aiUrl = findEnvValue("CRAWL4AI_BASE_URL", cwd, trusted);
|
|
559
|
-
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
560
|
-
const status = {
|
|
561
|
-
brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" },
|
|
562
|
-
searxng: {
|
|
563
|
-
baseUrl: normalizeSearxngBaseUrl(searxngUrl.value),
|
|
564
|
-
baseUrlSource: searxngUrl.source || "default local",
|
|
565
|
-
},
|
|
566
|
-
firecrawl: {
|
|
567
|
-
baseUrl: normalizeFirecrawlBaseUrl(fireUrl.value),
|
|
568
|
-
apiUrlSource: fireUrl.source || "default hosted",
|
|
569
|
-
apiKeyFound: Boolean(fireKey.value),
|
|
570
|
-
apiKeySource: fireKey.value ? fireKey.source : "not set",
|
|
571
|
-
hostedMode: !fireUrl.value || normalizeFirecrawlBaseUrl(fireUrl.value).startsWith(HOSTED_FIRECRAWL_BASE_URL),
|
|
572
|
-
},
|
|
573
|
-
crawl4ai: {
|
|
574
|
-
baseUrl: normalizeCrawl4aiBaseUrl(c4aiUrl.value),
|
|
575
|
-
baseUrlSource: c4aiUrl.source || "default",
|
|
576
|
-
apiTokenFound: Boolean(c4aiToken.value),
|
|
577
|
-
apiTokenSource: c4aiToken.value ? c4aiToken.source : "not set",
|
|
578
|
-
},
|
|
579
|
-
};
|
|
580
|
-
return { content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }], details: status };
|
|
581
|
-
},
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
// ── Crawl4AI Status ─────────────────────────────────────────────────
|
|
585
|
-
pi.registerTool({
|
|
586
|
-
name: "crawl4ai_status",
|
|
587
|
-
label: "Crawl4AI Server Status",
|
|
588
|
-
description:
|
|
589
|
-
"Check Crawl4AI server health and version. Connects to the configured Crawl4AI endpoint to verify availability.",
|
|
590
|
-
promptSnippet: "Check Crawl4AI server status",
|
|
591
|
-
promptGuidelines: [
|
|
592
|
-
"Use crawl4ai_status when Crawl4AI tools return connection errors.",
|
|
593
|
-
"Shows server version, response timestamp, and whether auth is configured.",
|
|
594
|
-
],
|
|
595
|
-
parameters: Type.Object({
|
|
596
|
-
...crawl4aiControlSchema,
|
|
597
|
-
}),
|
|
598
|
-
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
599
|
-
const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
600
|
-
const health = await fetchCrawl4aiHealth(config, signal);
|
|
601
|
-
const text = [
|
|
602
|
-
`Crawl4AI Server Status`,
|
|
603
|
-
`Endpoint: ${config.baseUrl}`,
|
|
604
|
-
`Status: ${health.status || "unknown"}`,
|
|
605
|
-
`Version: ${health.version || "unknown"}`,
|
|
606
|
-
`Timestamp: ${health.timestamp ? new Date(health.timestamp * 1000).toISOString() : "unknown"}`,
|
|
607
|
-
`Auth token: ${config.apiToken ? "configured" : "not set (loopback only)"}`,
|
|
608
|
-
].join("\n");
|
|
609
|
-
return { content: [{ type: "text" as const, text: text }], details: { config: { baseUrl: config.baseUrl, apiTokenConfigured: Boolean(config.apiToken) }, health } };
|
|
610
|
-
},
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
// ── /web-status command ──────────────────────────────────────────────
|
|
614
|
-
pi.registerCommand("web-status", {
|
|
615
|
-
description: "Show web provider configuration status",
|
|
616
|
-
handler: async (_args: string, ctx: any) => {
|
|
617
|
-
const cwd = cwdFromContext(ctx);
|
|
618
|
-
const trusted = includeProjectEnv(ctx);
|
|
619
|
-
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
|
|
620
|
-
const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
|
|
621
|
-
const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
|
|
622
|
-
const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
623
|
-
const c4aiUrl = findEnvValue("CRAWL4AI_BASE_URL", cwd, trusted);
|
|
624
|
-
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
625
|
-
ctx.ui.notify(
|
|
626
|
-
[
|
|
627
|
-
`Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
|
|
628
|
-
`SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
|
|
629
|
-
`Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
|
|
630
|
-
`Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
|
|
631
|
-
`Crawl4AI base URL: ${normalizeCrawl4aiBaseUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
|
|
632
|
-
`Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
|
|
633
|
-
].join("\n"),
|
|
634
|
-
"info",
|
|
635
|
-
);
|
|
636
|
-
},
|
|
637
|
-
});
|
|
638
|
-
}
|