@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
|
@@ -0,0 +1,474 @@
|
|
|
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
|
+
normalizeCrawl4aiApiUrl,
|
|
13
|
+
loadFirecrawlConfig,
|
|
14
|
+
loadCrawl4aiConfig,
|
|
15
|
+
HOSTED_FIRECRAWL_BASE_URL,
|
|
16
|
+
} from "./lib/config";
|
|
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";
|
|
24
|
+
import { firecrawlRequest, type FirecrawlResult } from "./lib/firecrawl";
|
|
25
|
+
import {
|
|
26
|
+
fetchCrawl4aiCrawl,
|
|
27
|
+
fetchCrawl4aiScreenshot,
|
|
28
|
+
fetchCrawl4aiPdf,
|
|
29
|
+
fetchCrawl4aiHealth,
|
|
30
|
+
} from "./lib/crawl4ai";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Shared schema fragment
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const sharedControlSchema = {
|
|
37
|
+
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const firecrawlControlSchema = {
|
|
41
|
+
firecrawl_api_key: Type.Optional(Type.String({ description: "Firecrawl API key override. Prefer FIRECRAWL_API_KEY." })),
|
|
42
|
+
firecrawl_api_url: Type.Optional(Type.String({ description: "Firecrawl API URL override. Defaults to FIRECRAWL_API_URL or hosted." })),
|
|
43
|
+
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
44
|
+
};
|
|
45
|
+
|
|
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." })),
|
|
49
|
+
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
50
|
+
};
|
|
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
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Extension entry point
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export default function piWebExtension(pi: ExtensionAPI) {
|
|
79
|
+
// ── web_search ────────────────────────────────────────────────────────
|
|
80
|
+
pi.registerTool({
|
|
81
|
+
name: "web_search",
|
|
82
|
+
label: "Web Search",
|
|
83
|
+
description:
|
|
84
|
+
"Search the web. Auto-selects backends adaptively: SearXNG, Brave, Firecrawl.",
|
|
85
|
+
promptSnippet: "Search current web results",
|
|
86
|
+
promptGuidelines: [
|
|
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.",
|
|
95
|
+
],
|
|
96
|
+
parameters: Type.Object({
|
|
97
|
+
query: Type.String(),
|
|
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'." })),
|
|
100
|
+
country: Type.Optional(Type.String({ default: "US" })),
|
|
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." })),
|
|
107
|
+
content_chars: Type.Optional(Type.Number({ default: 5000 })),
|
|
108
|
+
...sharedControlSchema,
|
|
109
|
+
}),
|
|
110
|
+
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
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,
|
|
121
|
+
signal,
|
|
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 };
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── web_extract ──────────────────────────────────────────────────────
|
|
131
|
+
pi.registerTool({
|
|
132
|
+
name: "web_extract",
|
|
133
|
+
label: "Web Content Extraction",
|
|
134
|
+
description:
|
|
135
|
+
"Extract readable content from a URL. Auto backend: static \u2192 dynamic \u2192 full.",
|
|
136
|
+
promptSnippet: "Extract readable webpage content as markdown",
|
|
137
|
+
promptGuidelines: [
|
|
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.",
|
|
146
|
+
"Cite the source URL when using extracted content in an answer.",
|
|
147
|
+
],
|
|
148
|
+
parameters: Type.Object({
|
|
149
|
+
url: Type.String(),
|
|
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)." })),
|
|
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,
|
|
160
|
+
}),
|
|
161
|
+
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
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 } };
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── web_map ──────────────────────────────────────────────────────────
|
|
182
|
+
pi.registerTool({
|
|
183
|
+
name: "web_map",
|
|
184
|
+
label: "Site URL Discovery",
|
|
185
|
+
description:
|
|
186
|
+
"Discover URLs from a site using Firecrawl Map.",
|
|
187
|
+
promptSnippet: "Map site URLs",
|
|
188
|
+
promptGuidelines: [
|
|
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.",
|
|
194
|
+
],
|
|
195
|
+
parameters: Type.Object({
|
|
196
|
+
url: Type.String(),
|
|
197
|
+
limit: Type.Optional(Type.Number({ default: 100 })),
|
|
198
|
+
include_subdomains: Type.Optional(Type.Boolean({ default: false })),
|
|
199
|
+
search: Type.Optional(Type.String({ description: "Optional search query to guide URL discovery (semantic map)." })),
|
|
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." })),
|
|
201
|
+
use_index: Type.Optional(Type.Boolean({ default: true, description: "Whether to use the Firecrawl search index for URL discovery." })),
|
|
202
|
+
ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached map results." })),
|
|
203
|
+
...firecrawlControlSchema,
|
|
204
|
+
}),
|
|
205
|
+
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
206
|
+
const body: Record<string, unknown> = {
|
|
207
|
+
url: params.url,
|
|
208
|
+
limit: (params.limit as number) ?? 100,
|
|
209
|
+
includeSubdomains: Boolean(params.include_subdomains),
|
|
210
|
+
};
|
|
211
|
+
if (params.search !== undefined) body.search = params.search;
|
|
212
|
+
if (params.sitemap !== undefined) body.sitemap = params.sitemap;
|
|
213
|
+
if (params.use_index !== undefined) body.useIndex = params.use_index;
|
|
214
|
+
if (params.ignore_cache !== undefined) body.ignoreCache = params.ignore_cache;
|
|
215
|
+
const config = loadFirecrawlConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
216
|
+
const result = await firecrawlRequest(config, "POST", "/map", body, signal);
|
|
217
|
+
const urls = result.data || result.links || result.urls || [];
|
|
218
|
+
const text = Array.isArray(urls) && urls.length > 0
|
|
219
|
+
? (urls as Array<Record<string, unknown> | string>).map((u: any) => u.url || u).join("\n")
|
|
220
|
+
: JSON.stringify(result, null, 2);
|
|
221
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── web_crawl ────────────────────────────────────────────────────────
|
|
226
|
+
pi.registerTool({
|
|
227
|
+
name: "web_crawl",
|
|
228
|
+
label: "Site Crawl",
|
|
229
|
+
description:
|
|
230
|
+
"Crawl site pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
|
|
231
|
+
promptSnippet: "Crawl a small site section",
|
|
232
|
+
promptGuidelines: [
|
|
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.",
|
|
240
|
+
],
|
|
241
|
+
parameters: Type.Object({
|
|
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)." })),
|
|
245
|
+
limit: Type.Optional(Type.Number({ default: 10 })),
|
|
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,
|
|
254
|
+
}),
|
|
255
|
+
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
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);
|
|
276
|
+
let result = await firecrawlRequest(
|
|
277
|
+
fcConfig,
|
|
278
|
+
"POST",
|
|
279
|
+
"/crawl",
|
|
280
|
+
{
|
|
281
|
+
url: params.url as string,
|
|
282
|
+
limit: Math.min(10000, Math.max(1, (params.limit as number) ?? 10)),
|
|
283
|
+
includePaths: params.include_paths
|
|
284
|
+
? String(params.include_paths).split(",").map((s: string) => s.trim()).filter(Boolean)
|
|
285
|
+
: [],
|
|
286
|
+
excludePaths: params.exclude_paths
|
|
287
|
+
? String(params.exclude_paths).split(",").map((s: string) => s.trim()).filter(Boolean)
|
|
288
|
+
: [],
|
|
289
|
+
scrapeOptions: { formats: ["markdown"], onlyMainContent: true },
|
|
290
|
+
},
|
|
291
|
+
signal,
|
|
292
|
+
);
|
|
293
|
+
const id = result.id || (result.data as Record<string, unknown> | undefined)?.id;
|
|
294
|
+
if (params.poll && id && !Array.isArray(result.data)) {
|
|
295
|
+
const { abortableSleep } = await import("./lib/retry");
|
|
296
|
+
for (let i = 0; i < 60; i++) {
|
|
297
|
+
result = await firecrawlRequest(fcConfig, "GET", `/crawl/${id}`, undefined, signal);
|
|
298
|
+
if (["completed", "failed", "cancelled"].includes(
|
|
299
|
+
String(result.status || (result.data as Record<string, unknown> | undefined)?.status || ""),
|
|
300
|
+
)) break;
|
|
301
|
+
await abortableSleep(2000, signal);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const pages = Array.isArray(result.data)
|
|
305
|
+
? (result.data as Record<string, unknown>[])
|
|
306
|
+
: ((result.data as Record<string, unknown>)?.data as Record<string, unknown>[]) || [];
|
|
307
|
+
const text = pages.length
|
|
308
|
+
? pages.map((p: Record<string, unknown>) => formatFirecrawlScrape({ data: p } as Record<string, unknown>, maxChars)).join("\n\n---\n\n")
|
|
309
|
+
: id
|
|
310
|
+
? `Crawl started: ${id}\nUse poll=true or check Firecrawl status/dashboard.`
|
|
311
|
+
: JSON.stringify(result, null, 2);
|
|
312
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ── web_screenshot ───────────────────────────────────────────────────
|
|
317
|
+
pi.registerTool({
|
|
318
|
+
name: "web_screenshot",
|
|
319
|
+
label: "Web Page Screenshot",
|
|
320
|
+
description:
|
|
321
|
+
"Capture a full-page PNG screenshot via Crawl4AI headless browser.",
|
|
322
|
+
promptSnippet: "Screenshot a webpage",
|
|
323
|
+
promptGuidelines: [
|
|
324
|
+
"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.",
|
|
325
|
+
"The screenshot is returned as a base64-encoded PNG string.",
|
|
326
|
+
"Use wait_for to delay capture for dynamic content to render (default 2 seconds).",
|
|
327
|
+
"Use wait_for_images to ensure images are loaded before capture.",
|
|
328
|
+
],
|
|
329
|
+
parameters: Type.Object({
|
|
330
|
+
url: Type.String(),
|
|
331
|
+
wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capturing screenshot." })),
|
|
332
|
+
wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images to load before capture." })),
|
|
333
|
+
...crawl4aiControlSchema,
|
|
334
|
+
}),
|
|
335
|
+
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
336
|
+
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
337
|
+
const result = await fetchCrawl4aiScreenshot(
|
|
338
|
+
config,
|
|
339
|
+
params.url as string,
|
|
340
|
+
params.wait_for as number | undefined,
|
|
341
|
+
params.wait_for_images as boolean | undefined,
|
|
342
|
+
signal,
|
|
343
|
+
);
|
|
344
|
+
const screenshot = result.screenshot as string | undefined;
|
|
345
|
+
const artifactUrl = result.url as string | undefined;
|
|
346
|
+
const mime = result.mime as string | undefined;
|
|
347
|
+
const size = result.size as number | undefined;
|
|
348
|
+
let text = `Screenshot: ${params.url}\n`;
|
|
349
|
+
if (screenshot) text += `Data: base64 PNG (${screenshot.length} chars)\n`;
|
|
350
|
+
if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
|
|
351
|
+
if (mime) text += `MIME: ${mime}\n`;
|
|
352
|
+
if (size) text += `Size: ${size} bytes\n`;
|
|
353
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ── web_pdf ──────────────────────────────────────────────────────────
|
|
358
|
+
pi.registerTool({
|
|
359
|
+
name: "web_pdf",
|
|
360
|
+
label: "Web Page PDF",
|
|
361
|
+
description:
|
|
362
|
+
"Generate a PDF document via Crawl4AI headless browser.",
|
|
363
|
+
promptSnippet: "PDF a webpage",
|
|
364
|
+
promptGuidelines: [
|
|
365
|
+
"Use web_pdf when a printable or archivable snapshot of a page is needed.",
|
|
366
|
+
"The PDF is returned as a base64-encoded string.",
|
|
367
|
+
],
|
|
368
|
+
parameters: Type.Object({
|
|
369
|
+
url: Type.String(),
|
|
370
|
+
...crawl4aiControlSchema,
|
|
371
|
+
}),
|
|
372
|
+
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
373
|
+
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
374
|
+
const result = await fetchCrawl4aiPdf(config, params.url as string, signal);
|
|
375
|
+
const pdf = result.pdf as string | undefined;
|
|
376
|
+
const artifactUrl = result.url as string | undefined;
|
|
377
|
+
const size = result.size as number | undefined;
|
|
378
|
+
let text = `PDF: ${params.url}\n`;
|
|
379
|
+
if (pdf) text += `Data: base64 PDF (${pdf.length} chars)\n`;
|
|
380
|
+
if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
|
|
381
|
+
if (size) text += `Size: ${size} bytes\n`;
|
|
382
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ── web_status ───────────────────────────────────────────────────────
|
|
387
|
+
pi.registerTool({
|
|
388
|
+
name: "web_status",
|
|
389
|
+
label: "Web Provider Status",
|
|
390
|
+
description:
|
|
391
|
+
"Show web provider configuration status without printing secrets.",
|
|
392
|
+
promptSnippet: "Check web provider configuration and server status",
|
|
393
|
+
promptGuidelines: [
|
|
394
|
+
"Use web_status when web tools fail due to missing credentials/config or to verify which backends are available.",
|
|
395
|
+
"The output shows which backends are configured; if a backend is missing, configure its env vars.",
|
|
396
|
+
"Never print API key values; this tool reports only presence and source.",
|
|
397
|
+
"Shows Crawl4AI server health, version, and auth status when the server is reachable.",
|
|
398
|
+
"For Firecrawl, `apiKeyFound: false` is normal for self-hosted instances without auth. Use the `ready` field to check whether Firecrawl is actually usable.",
|
|
399
|
+
],
|
|
400
|
+
parameters: Type.Object({}),
|
|
401
|
+
async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
402
|
+
const cwd = cwdFromContext(ctx);
|
|
403
|
+
const trusted = includeProjectEnv(ctx);
|
|
404
|
+
|
|
405
|
+
// Provider config status
|
|
406
|
+
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
|
|
407
|
+
const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
|
|
408
|
+
const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
|
|
409
|
+
const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
410
|
+
const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
|
|
411
|
+
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
412
|
+
|
|
413
|
+
const fcBaseUrl = normalizeFirecrawlBaseUrl(fireUrl.value);
|
|
414
|
+
const fcHosted = !fireUrl.value || fcBaseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
415
|
+
|
|
416
|
+
const status: Record<string, unknown> = {
|
|
417
|
+
brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" },
|
|
418
|
+
searxng: { baseUrl: normalizeSearxngBaseUrl(searxngUrl.value), baseUrlSource: searxngUrl.source || "default local" },
|
|
419
|
+
firecrawl: {
|
|
420
|
+
baseUrl: fcBaseUrl,
|
|
421
|
+
apiUrlSource: fireUrl.source || "default hosted",
|
|
422
|
+
apiKeyFound: Boolean(fireKey.value),
|
|
423
|
+
apiKeySource: fireKey.value ? fireKey.source : "not set",
|
|
424
|
+
hostedMode: fcHosted,
|
|
425
|
+
ready: fcHosted ? Boolean(fireKey.value) : Boolean(fireUrl.value?.trim()),
|
|
426
|
+
},
|
|
427
|
+
crawl4ai: {
|
|
428
|
+
baseUrl: normalizeCrawl4aiApiUrl(c4aiUrl.value),
|
|
429
|
+
baseUrlSource: c4aiUrl.source || "default",
|
|
430
|
+
apiTokenFound: Boolean(c4aiToken.value),
|
|
431
|
+
apiTokenSource: c4aiToken.value ? c4aiToken.source : "not set",
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Crawl4AI health check
|
|
436
|
+
let c4aiHealth: Record<string, unknown> | undefined;
|
|
437
|
+
try {
|
|
438
|
+
const c4aiCfg = loadCrawl4aiConfig({}, cwd, trusted);
|
|
439
|
+
c4aiHealth = await fetchCrawl4aiHealth(c4aiCfg, signal);
|
|
440
|
+
} catch (e: any) {
|
|
441
|
+
c4aiHealth = { status: "unreachable", error: e?.message ?? String(e) };
|
|
442
|
+
}
|
|
443
|
+
status.crawl4ai = { ...(status.crawl4ai as Record<string, unknown>), health: c4aiHealth };
|
|
444
|
+
|
|
445
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }], details: status };
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ── /web-status command ──────────────────────────────────────────────
|
|
450
|
+
pi.registerCommand("web-status", {
|
|
451
|
+
description: "Show web provider configuration status",
|
|
452
|
+
handler: async (_args: string, ctx: any) => {
|
|
453
|
+
const cwd = cwdFromContext(ctx);
|
|
454
|
+
const trusted = includeProjectEnv(ctx);
|
|
455
|
+
const braveKey = findEnvValue("BRAVE_API_KEY", cwd, trusted);
|
|
456
|
+
const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
|
|
457
|
+
const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
|
|
458
|
+
const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
|
|
459
|
+
const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
|
|
460
|
+
const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
|
|
461
|
+
ctx.ui.notify(
|
|
462
|
+
[
|
|
463
|
+
`Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
|
|
464
|
+
`SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
|
|
465
|
+
`Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
|
|
466
|
+
`Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
|
|
467
|
+
`Crawl4AI API URL: ${normalizeCrawl4aiApiUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
|
|
468
|
+
`Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
|
|
469
|
+
].join("\n"),
|
|
470
|
+
"info",
|
|
471
|
+
);
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Brave Search API client.
|
|
2
|
+
|
|
3
|
+
import { sanitizeSnippet, type SearchResultItem } from "./format";
|
|
4
|
+
|
|
5
|
+
export interface BraveResult extends SearchResultItem {
|
|
6
|
+
age: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function fetchBraveResults(
|
|
10
|
+
query: string,
|
|
11
|
+
count: number,
|
|
12
|
+
country: string,
|
|
13
|
+
freshness: string,
|
|
14
|
+
apiKey: string,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
): Promise<BraveResult[]> {
|
|
17
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
const params = new URLSearchParams({ q: query, count: String(count), country });
|
|
20
|
+
if (freshness) params.append("freshness", freshness);
|
|
21
|
+
const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
|
|
22
|
+
headers: {
|
|
23
|
+
Accept: "application/json",
|
|
24
|
+
"Accept-Encoding": "gzip",
|
|
25
|
+
"X-Subscription-Token": apiKey,
|
|
26
|
+
},
|
|
27
|
+
signal,
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}\n${await response.text()}`);
|
|
30
|
+
const data: any = await response.json();
|
|
31
|
+
return (data.web?.results || [])
|
|
32
|
+
.slice(0, count)
|
|
33
|
+
.map((r: any) => ({
|
|
34
|
+
title: sanitizeSnippet(r.title || ""),
|
|
35
|
+
url: r.url || "",
|
|
36
|
+
snippet: sanitizeSnippet(r.description || ""),
|
|
37
|
+
age: sanitizeSnippet(r.age || r.page_age || ""),
|
|
38
|
+
}));
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (attempt === 2) throw e;
|
|
41
|
+
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error("unreachable");
|
|
45
|
+
}
|
|
@@ -8,11 +8,9 @@ import path from "node:path";
|
|
|
8
8
|
// Constants
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
export const OUTPUT_MAX_BYTES = 50 * 1024;
|
|
12
|
-
export const OUTPUT_MAX_LINES = 2_000;
|
|
13
11
|
export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
|
|
14
12
|
export const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
|
|
15
|
-
export const
|
|
13
|
+
export const DEFAULT_CRAWL4AI_API_URL = "http://172.30.55.22:11235";
|
|
16
14
|
|
|
17
15
|
// ---------------------------------------------------------------------------
|
|
18
16
|
// Environment loading
|
|
@@ -126,8 +124,8 @@ export interface Crawl4aiConfig {
|
|
|
126
124
|
timeoutMs: number;
|
|
127
125
|
}
|
|
128
126
|
|
|
129
|
-
export function
|
|
130
|
-
let value = (raw ||
|
|
127
|
+
export function normalizeCrawl4aiApiUrl(raw?: string): string {
|
|
128
|
+
let value = (raw || DEFAULT_CRAWL4AI_API_URL).trim();
|
|
131
129
|
if (!/^https?:\/\//i.test(value)) {
|
|
132
130
|
value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
|
|
133
131
|
? `http://${value}`
|
|
@@ -137,15 +135,15 @@ export function normalizeCrawl4aiBaseUrl(raw?: string): string {
|
|
|
137
135
|
}
|
|
138
136
|
|
|
139
137
|
export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): Crawl4aiConfig {
|
|
140
|
-
const
|
|
138
|
+
const apiUrlLookup = findEnvValue("CRAWL4AI_API_URL", cwd, includeCwdEnv);
|
|
141
139
|
const apiTokenLookup = findEnvValue("CRAWL4AI_API_TOKEN", cwd, includeCwdEnv);
|
|
142
|
-
const
|
|
140
|
+
const explicitApiUrl = params.crawl4ai_api_url as string | undefined;
|
|
143
141
|
const explicitApiToken = params.crawl4ai_api_token as string | undefined;
|
|
144
|
-
const baseUrl =
|
|
142
|
+
const baseUrl = normalizeCrawl4aiApiUrl(explicitApiUrl || apiUrlLookup.value);
|
|
145
143
|
const apiToken = explicitApiToken || apiTokenLookup.value || "";
|
|
146
|
-
const timeoutValue = (params.timeout_ms as number) || findEnvValue("
|
|
144
|
+
const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_API_TIMEOUT_MS", cwd, includeCwdEnv).value;
|
|
147
145
|
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
148
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("
|
|
146
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("CRAWL4AI_API_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
149
147
|
return { baseUrl, apiToken, timeoutMs };
|
|
150
148
|
}
|
|
151
149
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Endpoints: /crawl, /crawl/stream, /md, /screenshot, /pdf, /health
|
|
4
4
|
|
|
5
5
|
import type { Crawl4aiConfig } from "./config";
|
|
6
|
-
import { signalWithTimeout
|
|
6
|
+
import { signalWithTimeout } from "./retry";
|
|
7
7
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Error
|
|
@@ -52,9 +52,15 @@ export async function crawl4aiRequest(
|
|
|
52
52
|
body?: unknown,
|
|
53
53
|
signal?: AbortSignal,
|
|
54
54
|
): Promise<Record<string, unknown>> {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
56
|
+
try {
|
|
57
|
+
return await crawl4aiRequestJson(config, method, endpoint, body, signal);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (attempt === 2) throw e;
|
|
60
|
+
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new Error("unreachable");
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
// ---------------------------------------------------------------------------
|
|
@@ -142,36 +148,6 @@ export async function fetchCrawl4aiCrawl(
|
|
|
142
148
|
};
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
/**
|
|
146
|
-
* Crawl one or more URLs via POST /crawl/stream.
|
|
147
|
-
* Returns newline-delimited JSON lines as a string.
|
|
148
|
-
*/
|
|
149
|
-
export async function fetchCrawl4aiStream(
|
|
150
|
-
config: Crawl4aiConfig,
|
|
151
|
-
urls: string[],
|
|
152
|
-
browserConfig?: Record<string, unknown>,
|
|
153
|
-
crawlerConfig?: Record<string, unknown>,
|
|
154
|
-
signal?: AbortSignal,
|
|
155
|
-
): Promise<string> {
|
|
156
|
-
const body: Record<string, unknown> = { urls };
|
|
157
|
-
if (browserConfig) body.browser_config = browserConfig;
|
|
158
|
-
if (crawlerConfig) body.crawler_config = crawlerConfig;
|
|
159
|
-
const headers: Record<string, string> = { Accept: "application/json" };
|
|
160
|
-
if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
|
|
161
|
-
headers["Content-Type"] = "application/json";
|
|
162
|
-
const response = await fetch(`${config.baseUrl}/crawl/stream`, {
|
|
163
|
-
method: "POST",
|
|
164
|
-
headers,
|
|
165
|
-
body: JSON.stringify(body),
|
|
166
|
-
signal: signalWithTimeout(config.timeoutMs, signal),
|
|
167
|
-
});
|
|
168
|
-
if (!response.ok) {
|
|
169
|
-
const text = await response.text();
|
|
170
|
-
throw new Crawl4aiHttpError(response.status, response.statusText, text);
|
|
171
|
-
}
|
|
172
|
-
return await response.text();
|
|
173
|
-
}
|
|
174
|
-
|
|
175
151
|
/**
|
|
176
152
|
* Capture a screenshot via POST /screenshot.
|
|
177
153
|
*/
|
|
@@ -206,17 +182,7 @@ export async function fetchCrawl4aiHealth(
|
|
|
206
182
|
config: Crawl4aiConfig,
|
|
207
183
|
signal?: AbortSignal,
|
|
208
184
|
): Promise<{ status?: string; version?: string; timestamp?: number }> {
|
|
209
|
-
const
|
|
210
|
-
if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
|
|
211
|
-
const timeoutMs = Math.min(config.timeoutMs, 10000);
|
|
212
|
-
const response = await fetch(`${config.baseUrl}/health`, {
|
|
213
|
-
method: "GET",
|
|
214
|
-
headers,
|
|
215
|
-
signal: signalWithTimeout(timeoutMs, signal),
|
|
216
|
-
});
|
|
217
|
-
const text = await response.text();
|
|
218
|
-
if (!response.ok) throw new Crawl4aiHttpError(response.status, response.statusText, text);
|
|
219
|
-
const data = text ? JSON.parse(text) : {};
|
|
185
|
+
const data = await crawl4aiRequest({ ...config, timeoutMs: Math.min(config.timeoutMs, 10000) }, "GET", "/health", undefined, signal);
|
|
220
186
|
return {
|
|
221
187
|
status: data.status as string | undefined,
|
|
222
188
|
version: data.version as string | undefined,
|