pi-firecrawl-lite 0.1.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/LICENSE +21 -0
- package/README.md +98 -0
- package/package.json +44 -0
- package/src/client.ts +219 -0
- package/src/index.ts +48 -0
- package/src/tools.ts +359 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Lindfors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# pi-firecrawl-lite
|
|
2
|
+
|
|
3
|
+
Firecrawl for Pi, with a small prompt footprint and the full v2 capability set.
|
|
4
|
+
|
|
5
|
+
`pi-firecrawl-lite` adds reliable web search and page scraping to Pi while keeping less-common crawl and site-mapping tools out of the model’s default tool list. When a task needs them, the model can call `firecrawl_load` to activate the relevant tools for the rest of the session.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Install the package in Pi’s normal extension directory or add it to a project:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:pi-firecrawl-lite
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For a project-local installation, add the package to `package.json` and use the extension entry already included in its `pi` manifest:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": ["./node_modules/pi-firecrawl-lite/src/index.ts"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The extension has no runtime dependencies. Pi and TypeBox are peer dependencies supplied by the host.
|
|
26
|
+
|
|
27
|
+
## Configure Firecrawl
|
|
28
|
+
|
|
29
|
+
The extension resolves configuration in this order:
|
|
30
|
+
|
|
31
|
+
1. An API URL configured explicitly in Pi.
|
|
32
|
+
2. `FIRECRAWL_API_URL`, with an optional `FIRECRAWL_API_KEY`.
|
|
33
|
+
3. A key without a URL uses Firecrawl Cloud at `https://api.firecrawl.dev/v2`.
|
|
34
|
+
4. With neither value, it uses a keyless local server at `http://localhost:3002/v2`.
|
|
35
|
+
|
|
36
|
+
Supported environment variables:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
export FIRECRAWL_API_URL=http://localhost:3002
|
|
40
|
+
export FIRECRAWL_API_KEY=fc-your-key # optional for local deployments
|
|
41
|
+
export FIRECRAWL_TIMEOUT_MS=120000 # optional
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The URL must be HTTP or HTTPS and must point to the host root or end in `/v2`. v1 URLs are rejected. The API key is never shown in tool output, errors, notifications, or saved response details.
|
|
45
|
+
|
|
46
|
+
Configuration can also be changed for the current Pi session with slash commands:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
/firecrawl status
|
|
50
|
+
/firecrawl url http://localhost:3002
|
|
51
|
+
/firecrawl key
|
|
52
|
+
/firecrawl timeout 120000
|
|
53
|
+
/firecrawl reset
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`/firecrawl key` opens an input prompt when no key follows it. Session command overrides are kept in memory and reset when the extension is reloaded; use environment variables for a durable configuration.
|
|
57
|
+
|
|
58
|
+
## Tools
|
|
59
|
+
|
|
60
|
+
Always active:
|
|
61
|
+
|
|
62
|
+
- `firecrawl_search` — web search with a query, optional result limit, advanced JSON options, and concise/raw output.
|
|
63
|
+
- `firecrawl_scrape` — scrape a URL in markdown or other Firecrawl formats.
|
|
64
|
+
- `firecrawl_load` — load deferred tools without removing Pi or other extension tools.
|
|
65
|
+
|
|
66
|
+
Deferred tools:
|
|
67
|
+
|
|
68
|
+
- `firecrawl_map` — discover URLs on a site.
|
|
69
|
+
- `firecrawl_crawl` — start a crawl job.
|
|
70
|
+
- `firecrawl_crawl_status` — inspect progress and returned documents.
|
|
71
|
+
|
|
72
|
+
Every model-facing schema is flat. `options_json` accepts an object for advanced Firecrawl features such as browser actions, extraction, headers, location, tags, search filters, webhooks, and nested scrape options. It rejects malformed JSON, arrays, primitive values, and prototype-pollution keys. Explicit top-level arguments always override advanced options.
|
|
73
|
+
|
|
74
|
+
Responses are concise by default. Set `response_mode` to `raw` when debugging or passing the full Firecrawl response to another step. Output is bounded at 50 KiB and 2,000 lines. A truncated response is saved in a private, session-specific temporary directory with restrictive permissions, and the tool reports its exact path and limits. Artifacts are removed when the session shuts down or the extension reloads.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
This repository uses Bun:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun install
|
|
82
|
+
bun run validate
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The validation command runs unit/integration-style mocked-fetch tests, strict typechecking, and the exact npm tarball allowlist check. The package intentionally publishes only `package.json`, `README.md`, `LICENSE`, and the three runtime files under `src/`; tests, workflows, lockfiles, `PLAN.md`, and local artifacts are excluded.
|
|
86
|
+
|
|
87
|
+
## Releases
|
|
88
|
+
|
|
89
|
+
Publishing is deliberately release-driven. Maintainers should create and publish a Gitea release whose tag exactly matches the package version, for example `v0.1.0`. The repository workflow then verifies tests, types, package contents, npm availability, and the tag/version match before publishing:
|
|
90
|
+
|
|
91
|
+
- stable releases use npm dist-tag `latest`;
|
|
92
|
+
- SemVer prereleases use npm dist-tag `next`.
|
|
93
|
+
|
|
94
|
+
Do not run `npm publish` manually. A tag push by itself does not publish anything.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT © James Lindfors
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-firecrawl-lite",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A lightweight Pi extension that brings Firecrawl search, scrape, map, and crawl tools to your agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "James Lindfors <lindforsdev@gmail.com>",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://git.jameslindfors.xyz/james/pi-firecrawl-lite.git"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./src/index.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"check:package": "npm --cache /tmp/pi-firecrawl-npm-cache pack --dry-run --json --ignore-scripts | node scripts/check-package.mjs",
|
|
27
|
+
"validate": "bun test && bun run typecheck && bun run check:package"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": ">=0.80.0",
|
|
31
|
+
"typebox": ">=1.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
35
|
+
"@types/bun": "latest",
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"typebox": "^1.3.34",
|
|
38
|
+
"typescript": "^5.7.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"registry": "https://registry.npmjs.org/",
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
7
|
+
export const DEFAULT_LOCAL_API_URL = "http://localhost:3002/v2";
|
|
8
|
+
export const DEFAULT_HOSTED_API_URL = "https://api.firecrawl.dev/v2";
|
|
9
|
+
|
|
10
|
+
export type FirecrawlEnv = Record<string, string | undefined>;
|
|
11
|
+
|
|
12
|
+
export interface FirecrawlConfig {
|
|
13
|
+
apiUrl: string;
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
source: "explicit" | "key" | "local";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FirecrawlOverrides {
|
|
20
|
+
apiUrl?: string;
|
|
21
|
+
apiKey?: string | null;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class FirecrawlError extends Error {
|
|
26
|
+
readonly kind: "configuration" | "connection" | "timeout" | "http" | "api" | "response";
|
|
27
|
+
readonly status?: number;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
kind: FirecrawlError["kind"],
|
|
31
|
+
message: string,
|
|
32
|
+
options?: { status?: number; cause?: unknown },
|
|
33
|
+
) {
|
|
34
|
+
super(message, options);
|
|
35
|
+
this.name = "FirecrawlError";
|
|
36
|
+
this.kind = kind;
|
|
37
|
+
this.status = options?.status;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseTimeout(value: string | undefined): number {
|
|
42
|
+
if (value === undefined || value.trim() === "") return DEFAULT_TIMEOUT_MS;
|
|
43
|
+
const timeout = Number(value);
|
|
44
|
+
if (!Number.isInteger(timeout) || timeout <= 0 || timeout > 86_400_000) {
|
|
45
|
+
throw new FirecrawlError("configuration", "FIRECRAWL_TIMEOUT_MS must be a positive integer no greater than 86400000.");
|
|
46
|
+
}
|
|
47
|
+
return timeout;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeApiUrl(value: string): string {
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
let parsed: URL;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(trimmed);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new FirecrawlError("configuration", "Firecrawl API URL must be an absolute HTTP or HTTPS URL.");
|
|
57
|
+
}
|
|
58
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
59
|
+
throw new FirecrawlError("configuration", "Firecrawl API URL must use HTTP or HTTPS.");
|
|
60
|
+
}
|
|
61
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
62
|
+
throw new FirecrawlError("configuration", "Firecrawl API URL must not contain credentials, a query string, or a fragment.");
|
|
63
|
+
}
|
|
64
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
65
|
+
if (pathname === "/v1" || pathname.startsWith("/v1/")) {
|
|
66
|
+
throw new FirecrawlError("configuration", "Firecrawl API v1 is not supported; configure the host root or a /v2 URL.");
|
|
67
|
+
}
|
|
68
|
+
if (pathname !== "" && pathname !== "/v2") {
|
|
69
|
+
throw new FirecrawlError("configuration", "Firecrawl API URL must point to the host root or end in /v2.");
|
|
70
|
+
}
|
|
71
|
+
parsed.pathname = pathname === "/v2" ? "/v2" : "/v2";
|
|
72
|
+
return parsed.toString().replace(/\/$/, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveConfig(env: FirecrawlEnv = process.env, overrides: FirecrawlOverrides = {}): FirecrawlConfig {
|
|
76
|
+
const hasUrlOverride = Object.prototype.hasOwnProperty.call(overrides, "apiUrl");
|
|
77
|
+
const hasKeyOverride = Object.prototype.hasOwnProperty.call(overrides, "apiKey");
|
|
78
|
+
const configuredUrl = hasUrlOverride ? overrides.apiUrl : env.FIRECRAWL_API_URL;
|
|
79
|
+
const configuredKey = hasKeyOverride ? overrides.apiKey : env.FIRECRAWL_API_KEY;
|
|
80
|
+
const apiKey = configuredKey?.trim() || undefined;
|
|
81
|
+
const apiUrl = configuredUrl?.trim()
|
|
82
|
+
? normalizeApiUrl(configuredUrl)
|
|
83
|
+
: apiKey
|
|
84
|
+
? DEFAULT_HOSTED_API_URL
|
|
85
|
+
: DEFAULT_LOCAL_API_URL;
|
|
86
|
+
const timeoutMs = overrides.timeoutMs ?? parseTimeout(env.FIRECRAWL_TIMEOUT_MS);
|
|
87
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 86_400_000) {
|
|
88
|
+
throw new FirecrawlError("configuration", "Timeout must be a positive integer no greater than 86400000.");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
apiUrl,
|
|
92
|
+
apiKey,
|
|
93
|
+
timeoutMs,
|
|
94
|
+
source: configuredUrl?.trim() ? "explicit" : apiKey ? "key" : "local",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
99
|
+
|
|
100
|
+
export class FirecrawlClient {
|
|
101
|
+
private overrides: FirecrawlOverrides = {};
|
|
102
|
+
|
|
103
|
+
constructor(private readonly env: FirecrawlEnv = process.env, private readonly fetchImpl: FetchLike = fetch) {}
|
|
104
|
+
|
|
105
|
+
setOverrides(overrides: FirecrawlOverrides): void {
|
|
106
|
+
const candidate = { ...this.overrides, ...overrides };
|
|
107
|
+
resolveConfig(this.env, candidate);
|
|
108
|
+
this.overrides = candidate;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
clearOverrides(): void {
|
|
112
|
+
this.overrides = {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getConfig(): FirecrawlConfig {
|
|
116
|
+
return resolveConfig(this.env, this.overrides);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async search(body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
120
|
+
return this.request("/search", { method: "POST", body: JSON.stringify(body) }, signal);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async scrape(body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
124
|
+
return this.request("/scrape", { method: "POST", body: JSON.stringify(body) }, signal);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async map(body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
128
|
+
return this.request("/map", { method: "POST", body: JSON.stringify(body) }, signal);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async crawl(body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
132
|
+
return this.request("/crawl", { method: "POST", body: JSON.stringify(body) }, signal);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async crawlStatus(jobId: string, signal?: AbortSignal): Promise<unknown> {
|
|
136
|
+
return this.request(`/crawl/${encodeURIComponent(jobId)}`, { method: "GET" }, signal);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async request(path: string, init: RequestInit, callerSignal?: AbortSignal): Promise<unknown> {
|
|
140
|
+
const config = this.getConfig();
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
const timeout = setTimeout(() => controller.abort(new Error("deadline exceeded")), config.timeoutMs);
|
|
143
|
+
const abortCaller = () => controller.abort(callerSignal?.reason);
|
|
144
|
+
callerSignal?.addEventListener("abort", abortCaller, { once: true });
|
|
145
|
+
if (callerSignal?.aborted) controller.abort(callerSignal.reason);
|
|
146
|
+
const headers = new Headers(init.headers);
|
|
147
|
+
headers.set("accept", "application/json");
|
|
148
|
+
if (init.body !== undefined) headers.set("content-type", "application/json");
|
|
149
|
+
if (config.apiKey) headers.set("authorization", `Bearer ${config.apiKey}`);
|
|
150
|
+
try {
|
|
151
|
+
const response = await this.fetchImpl(`${config.apiUrl}${path}`, { ...init, headers, signal: controller.signal });
|
|
152
|
+
const text = await response.text();
|
|
153
|
+
let payload: unknown;
|
|
154
|
+
try {
|
|
155
|
+
payload = text ? JSON.parse(text) : {};
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new FirecrawlError("response", "Firecrawl returned malformed JSON.", { cause: error });
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const message = redactSecret(extractApiMessage(payload) ?? `Firecrawl returned HTTP ${response.status}.`, config.apiKey);
|
|
161
|
+
throw new FirecrawlError("http", message, { status: response.status });
|
|
162
|
+
}
|
|
163
|
+
if (isApiFailure(payload)) {
|
|
164
|
+
throw new FirecrawlError("api", redactSecret(extractApiMessage(payload) ?? "Firecrawl reported an API failure.", config.apiKey));
|
|
165
|
+
}
|
|
166
|
+
return payload;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof FirecrawlError) throw error;
|
|
169
|
+
if (callerSignal?.aborted) throw new FirecrawlError("connection", "Firecrawl request was cancelled.", { cause: error });
|
|
170
|
+
if (controller.signal.aborted) throw new FirecrawlError("timeout", `Firecrawl request timed out after ${config.timeoutMs} ms.`, { cause: error });
|
|
171
|
+
const message = redactSecret(error instanceof Error ? error.message : String(error), config.apiKey);
|
|
172
|
+
throw new FirecrawlError("connection", `Could not connect to Firecrawl: ${message}.`, { cause: error });
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
callerSignal?.removeEventListener("abort", abortCaller);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isApiFailure(payload: unknown): boolean {
|
|
181
|
+
return typeof payload === "object" && payload !== null && "success" in payload && (payload as { success?: unknown }).success === false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractApiMessage(payload: unknown): string | undefined {
|
|
185
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
186
|
+
const value = payload as Record<string, unknown>;
|
|
187
|
+
for (const key of ["error", "message", "detail"]) {
|
|
188
|
+
if (typeof value[key] === "string" && value[key].trim()) return value[key].trim();
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function redactSecret(text: string, secret: string | undefined): string {
|
|
194
|
+
if (!secret) return text;
|
|
195
|
+
return text.split(secret).join("[REDACTED]");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class ArtifactStore {
|
|
199
|
+
private directory?: string;
|
|
200
|
+
|
|
201
|
+
async save(contents: string): Promise<string> {
|
|
202
|
+
if (!this.directory) {
|
|
203
|
+
this.directory = await mkdtemp(join(tmpdir(), "pi-firecrawl-"));
|
|
204
|
+
await chmod(this.directory, 0o700);
|
|
205
|
+
}
|
|
206
|
+
const filename = `response-${Date.now()}-${randomUUID()}.json`;
|
|
207
|
+
const path = join(this.directory, filename);
|
|
208
|
+
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
|
|
209
|
+
await chmod(path, 0o600);
|
|
210
|
+
return path;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async cleanup(): Promise<void> {
|
|
214
|
+
if (!this.directory) return;
|
|
215
|
+
const directory = this.directory;
|
|
216
|
+
this.directory = undefined;
|
|
217
|
+
await rm(directory, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { configureFromCommand, createRuntime, registerCrawlTools, registerMapTool, registerScrapeTool, registerSearchTool } from "./tools.js";
|
|
4
|
+
|
|
5
|
+
export default function firecrawlExtension(pi: ExtensionAPI): void {
|
|
6
|
+
const runtime = createRuntime();
|
|
7
|
+
registerSearchTool(pi, runtime);
|
|
8
|
+
registerScrapeTool(pi, runtime);
|
|
9
|
+
|
|
10
|
+
let mapLoaded = false;
|
|
11
|
+
let crawlLoaded = false;
|
|
12
|
+
pi.registerTool({
|
|
13
|
+
name: "firecrawl_load",
|
|
14
|
+
label: "Load Firecrawl Capability",
|
|
15
|
+
description: "Load deferred Firecrawl tools: crawl, map, or all.",
|
|
16
|
+
parameters: Type.Object({
|
|
17
|
+
capability: Type.String({ description: "Capability to load: crawl, map, or all." }),
|
|
18
|
+
}),
|
|
19
|
+
async execute(_id, params) {
|
|
20
|
+
const capability = String(params.capability).trim().toLowerCase();
|
|
21
|
+
if (capability !== "crawl" && capability !== "map" && capability !== "all") {
|
|
22
|
+
return { content: [{ type: "text" as const, text: "Firecrawl error: capability must be crawl, map, or all." }], details: { capability } } satisfies AgentToolResult<{ capability: string }>;
|
|
23
|
+
}
|
|
24
|
+
if ((capability === "map" || capability === "all") && !mapLoaded) {
|
|
25
|
+
registerMapTool(pi, runtime);
|
|
26
|
+
mapLoaded = true;
|
|
27
|
+
}
|
|
28
|
+
if ((capability === "crawl" || capability === "all") && !crawlLoaded) {
|
|
29
|
+
registerCrawlTools(pi, runtime);
|
|
30
|
+
crawlLoaded = true;
|
|
31
|
+
}
|
|
32
|
+
const loaded = capability === "all" ? "map, crawl, and crawl-status" : capability === "crawl" ? "crawl and crawl-status" : "map";
|
|
33
|
+
return { content: [{ type: "text" as const, text: `Loaded Firecrawl tools: ${loaded}.` }], details: { capability } };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.registerCommand("firecrawl", {
|
|
38
|
+
description: "Configure Firecrawl for this Pi session: /firecrawl status|url|key|timeout|reset",
|
|
39
|
+
handler: async (args, ctx) => configureFromCommand(args, runtime, ctx),
|
|
40
|
+
});
|
|
41
|
+
pi.on("session_start", () => {
|
|
42
|
+
const active = pi.getActiveTools();
|
|
43
|
+
pi.setActiveTools(active.filter((name) => name !== "firecrawl_map" && name !== "firecrawl_crawl" && name !== "firecrawl_crawl_status"));
|
|
44
|
+
mapLoaded = false;
|
|
45
|
+
crawlLoaded = false;
|
|
46
|
+
});
|
|
47
|
+
pi.on("session_shutdown", async () => runtime.artifacts.cleanup());
|
|
48
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ArtifactStore, FirecrawlClient, FirecrawlError, redactSecret } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
6
|
+
export const MAX_OUTPUT_LINES = 2_000;
|
|
7
|
+
const POLLUTION_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
8
|
+
|
|
9
|
+
type JsonObject = Record<string, unknown>;
|
|
10
|
+
type ResponseMode = "concise" | "raw";
|
|
11
|
+
type ToolDetails = { error?: string; responseMode?: ResponseMode };
|
|
12
|
+
|
|
13
|
+
export const searchParameters = Type.Object({
|
|
14
|
+
query: Type.String({ description: "Search query." }),
|
|
15
|
+
limit: Type.Optional(Type.Integer({ description: "Maximum number of results." })),
|
|
16
|
+
options_json: Type.Optional(Type.String({ description: "Advanced Firecrawl search options as a JSON object." })),
|
|
17
|
+
response_mode: Type.Optional(Type.String({ description: "Output mode: concise (default) or raw." })),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const scrapeParameters = Type.Object({
|
|
21
|
+
url: Type.String({ description: "Page URL to scrape." }),
|
|
22
|
+
formats: Type.Optional(Type.String({ description: "Comma-separated formats, default: markdown." })),
|
|
23
|
+
only_main_content: Type.Optional(Type.Boolean({ description: "Prefer the main page content." })),
|
|
24
|
+
wait_for_ms: Type.Optional(Type.Integer({ description: "Milliseconds to wait for page rendering." })),
|
|
25
|
+
options_json: Type.Optional(Type.String({ description: "Advanced Firecrawl scrape options as a JSON object." })),
|
|
26
|
+
response_mode: Type.Optional(Type.String({ description: "Output mode: concise (default) or raw." })),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const mapParameters = Type.Object({
|
|
30
|
+
url: Type.String({ description: "Website URL to map." }),
|
|
31
|
+
search: Type.Optional(Type.String({ description: "Optional map search term." })),
|
|
32
|
+
limit: Type.Optional(Type.Integer({ description: "Maximum number of discovered URLs." })),
|
|
33
|
+
options_json: Type.Optional(Type.String({ description: "Advanced Firecrawl map options as a JSON object." })),
|
|
34
|
+
response_mode: Type.Optional(Type.String({ description: "Output mode: concise (default) or raw." })),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const crawlParameters = Type.Object({
|
|
38
|
+
url: Type.String({ description: "Website URL to crawl." }),
|
|
39
|
+
limit: Type.Optional(Type.Integer({ description: "Maximum number of pages." })),
|
|
40
|
+
max_depth: Type.Optional(Type.Integer({ description: "Maximum crawl depth." })),
|
|
41
|
+
options_json: Type.Optional(Type.String({ description: "Advanced Firecrawl crawl options as a JSON object." })),
|
|
42
|
+
response_mode: Type.Optional(Type.String({ description: "Output mode: concise (default) or raw." })),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const crawlStatusParameters = Type.Object({
|
|
46
|
+
job_id: Type.String({ description: "Crawl job ID returned by firecrawl_crawl." }),
|
|
47
|
+
response_mode: Type.Optional(Type.String({ description: "Output mode: concise (default) or raw." })),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const loadParameters = Type.Object({
|
|
51
|
+
capability: Type.String({ description: "Capability to load: crawl, map, or all." }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function parseOptionsJson(value: string | undefined): JsonObject {
|
|
55
|
+
if (value === undefined || value.trim() === "") return {};
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(value);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(`options_json must be valid JSON: ${error instanceof Error ? error.message : "parse failed"}`);
|
|
61
|
+
}
|
|
62
|
+
if (!isPlainObject(parsed)) throw new Error("options_json must contain a JSON object, not an array or primitive.");
|
|
63
|
+
return cloneJsonObject(parsed, "options_json");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cloneJsonObject(value: JsonObject, path: string): JsonObject {
|
|
67
|
+
const result: JsonObject = {};
|
|
68
|
+
for (const [key, child] of Object.entries(value)) {
|
|
69
|
+
if (POLLUTION_KEYS.has(key)) throw new Error(`${path} contains a forbidden key: ${key}.`);
|
|
70
|
+
result[key] = cloneJsonValue(child, `${path}.${key}`);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cloneJsonValue(value: unknown, path: string): unknown {
|
|
76
|
+
if (Array.isArray(value)) return value.map((item, index) => cloneJsonValue(item, `${path}[${index}]`));
|
|
77
|
+
if (isPlainObject(value)) return cloneJsonObject(value, path);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isPlainObject(value: unknown): value is JsonObject {
|
|
82
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
83
|
+
const prototype = Object.getPrototypeOf(value);
|
|
84
|
+
return prototype === Object.prototype || prototype === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function mergeOptions(options: JsonObject, explicit: JsonObject, protectedKeys: string[]): JsonObject {
|
|
88
|
+
const result = cloneJsonObject(options, "options_json");
|
|
89
|
+
for (const key of protectedKeys) delete result[key];
|
|
90
|
+
for (const [key, value] of Object.entries(explicit)) {
|
|
91
|
+
if (value !== undefined) result[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireText(value: unknown, name: string): string {
|
|
97
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} must be a non-empty string.`);
|
|
98
|
+
return value.trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function optionalInteger(value: unknown, name: string): number | undefined {
|
|
102
|
+
if (value === undefined) return undefined;
|
|
103
|
+
if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative integer.`);
|
|
104
|
+
return value as number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function mode(value: unknown): ResponseMode {
|
|
108
|
+
if (value === undefined || value === "concise") return "concise";
|
|
109
|
+
if (value === "raw") return "raw";
|
|
110
|
+
throw new Error("response_mode must be concise or raw.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formats(value: unknown): string[] {
|
|
114
|
+
if (value === undefined) return ["markdown"];
|
|
115
|
+
if (typeof value !== "string") throw new Error("formats must be a comma-separated string.");
|
|
116
|
+
const parsed = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
117
|
+
if (parsed.length === 0) throw new Error("formats must contain at least one format.");
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildSearchBody(params: Record<string, unknown>): JsonObject {
|
|
122
|
+
const query = requireText(params.query, "query");
|
|
123
|
+
const limit = optionalInteger(params.limit, "limit");
|
|
124
|
+
return mergeOptions(parseOptionsJson(params.options_json as string | undefined), { query, limit }, ["query"]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function buildScrapeBody(params: Record<string, unknown>): JsonObject {
|
|
128
|
+
const url = requireText(params.url, "url");
|
|
129
|
+
const waitFor = optionalInteger(params.wait_for_ms, "wait_for_ms");
|
|
130
|
+
return mergeOptions(
|
|
131
|
+
parseOptionsJson(params.options_json as string | undefined),
|
|
132
|
+
{
|
|
133
|
+
url,
|
|
134
|
+
formats: formats(params.formats),
|
|
135
|
+
onlyMainContent: params.only_main_content,
|
|
136
|
+
waitFor,
|
|
137
|
+
},
|
|
138
|
+
["url"],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildMapBody(params: Record<string, unknown>): JsonObject {
|
|
143
|
+
const url = requireText(params.url, "url");
|
|
144
|
+
const limit = optionalInteger(params.limit, "limit");
|
|
145
|
+
return mergeOptions(parseOptionsJson(params.options_json as string | undefined), { url, search: params.search, limit }, ["url"]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function buildCrawlBody(params: Record<string, unknown>): JsonObject {
|
|
149
|
+
const url = requireText(params.url, "url");
|
|
150
|
+
const limit = optionalInteger(params.limit, "limit");
|
|
151
|
+
const maxDepth = optionalInteger(params.max_depth, "max_depth");
|
|
152
|
+
return mergeOptions(parseOptionsJson(params.options_json as string | undefined), { url, limit, maxDepth }, ["url"]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pretty(value: unknown): string {
|
|
156
|
+
return JSON.stringify(value, null, 2) ?? "null";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function arrayFrom(value: unknown): unknown[] {
|
|
160
|
+
if (Array.isArray(value)) return value;
|
|
161
|
+
if (isPlainObject(value)) {
|
|
162
|
+
for (const key of ["data", "links", "web", "results"]) {
|
|
163
|
+
if (Array.isArray(value[key])) return value[key] as unknown[];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function conciseSearch(payload: unknown): string {
|
|
170
|
+
const entries = arrayFrom(payload);
|
|
171
|
+
if (entries.length === 0) return "No search results.";
|
|
172
|
+
return entries.map((entry, index) => {
|
|
173
|
+
const item = isPlainObject(entry) ? entry : {};
|
|
174
|
+
const title = typeof item.title === "string" ? item.title : "Untitled result";
|
|
175
|
+
const url = typeof item.url === "string" ? item.url : "(no URL)";
|
|
176
|
+
const description = typeof item.description === "string" ? item.description : "";
|
|
177
|
+
return `${index + 1}. ${title}\n ${url}${description ? `\n ${description}` : ""}`;
|
|
178
|
+
}).join("\n\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function conciseScrape(payload: unknown): string {
|
|
182
|
+
const item = isPlainObject(payload) ? payload : {};
|
|
183
|
+
const metadata = isPlainObject(item.metadata) ? item.metadata : {};
|
|
184
|
+
const source = item.sourceURL ?? metadata.sourceURL ?? item.url;
|
|
185
|
+
const title = item.title ?? metadata.title;
|
|
186
|
+
const content = item.markdown ?? item.html ?? item.rawHtml ?? item.content ?? item.data;
|
|
187
|
+
const lines = [
|
|
188
|
+
typeof source === "string" ? `Source: ${source}` : undefined,
|
|
189
|
+
typeof title === "string" && title ? `Title: ${title}` : undefined,
|
|
190
|
+
typeof content === "string" ? content : content === undefined ? undefined : pretty(content),
|
|
191
|
+
];
|
|
192
|
+
return lines.filter((line): line is string => line !== undefined).join("\n\n") || "Scrape returned no content.";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function conciseMap(payload: unknown): string {
|
|
196
|
+
const entries = arrayFrom(payload);
|
|
197
|
+
if (entries.length === 0) return "No URLs found.";
|
|
198
|
+
return entries.map((entry, index) => {
|
|
199
|
+
if (typeof entry === "string") return `${index + 1}. ${entry}`;
|
|
200
|
+
const item = isPlainObject(entry) ? entry : {};
|
|
201
|
+
const url = typeof item.url === "string" ? item.url : "(no URL)";
|
|
202
|
+
const title = typeof item.title === "string" ? ` — ${item.title}` : "";
|
|
203
|
+
return `${index + 1}. ${url}${title}`;
|
|
204
|
+
}).join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function conciseCrawl(payload: unknown): string {
|
|
208
|
+
const item = isPlainObject(payload) ? payload : {};
|
|
209
|
+
const id = item.id ?? item.jobId;
|
|
210
|
+
const status = item.status;
|
|
211
|
+
const lines = [typeof id === "string" ? `Job ID: ${id}` : undefined, typeof status === "string" ? `Status: ${status}` : undefined];
|
|
212
|
+
return lines.filter((line): line is string => line !== undefined).join("\n") || pretty(payload);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function conciseCrawlStatus(payload: unknown): string {
|
|
216
|
+
const item = isPlainObject(payload) ? payload : {};
|
|
217
|
+
const summary = [
|
|
218
|
+
typeof item.status === "string" ? `Status: ${item.status}` : undefined,
|
|
219
|
+
item.completed !== undefined && item.total !== undefined ? `Progress: ${item.completed}/${item.total}` : undefined,
|
|
220
|
+
].filter((line): line is string => line !== undefined);
|
|
221
|
+
const documents = arrayFrom(item.data);
|
|
222
|
+
if (documents.length > 0) {
|
|
223
|
+
summary.push("\nDocuments:");
|
|
224
|
+
summary.push(documents.map((document, index) => {
|
|
225
|
+
const value = isPlainObject(document) ? document : {};
|
|
226
|
+
const url = value.url ?? value.sourceURL ?? `document ${index + 1}`;
|
|
227
|
+
const content = value.markdown ?? value.title;
|
|
228
|
+
return `${index + 1}. ${String(url)}${content ? `\n ${String(content)}` : ""}`;
|
|
229
|
+
}).join("\n\n"));
|
|
230
|
+
}
|
|
231
|
+
return summary.join("\n") || pretty(payload);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function formatPayload(kind: "search" | "scrape" | "map" | "crawl" | "crawl-status", payload: unknown, responseMode: ResponseMode): string {
|
|
235
|
+
if (responseMode === "raw") return pretty(payload);
|
|
236
|
+
switch (kind) {
|
|
237
|
+
case "search": return conciseSearch(payload);
|
|
238
|
+
case "scrape": return conciseScrape(payload);
|
|
239
|
+
case "map": return conciseMap(payload);
|
|
240
|
+
case "crawl": return conciseCrawl(payload);
|
|
241
|
+
case "crawl-status": return conciseCrawlStatus(payload);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function utf8Bytes(value: string): number {
|
|
246
|
+
return new TextEncoder().encode(value).byteLength;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
250
|
+
const encoded = new TextEncoder().encode(value);
|
|
251
|
+
if (encoded.byteLength <= maxBytes) return value;
|
|
252
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
253
|
+
let end = Math.min(maxBytes, encoded.byteLength);
|
|
254
|
+
while (end > 0) {
|
|
255
|
+
try {
|
|
256
|
+
return decoder.decode(encoded.subarray(0, end));
|
|
257
|
+
} catch {
|
|
258
|
+
end -= 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return "";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function limitOutput(text: string, rawResponse: string, artifacts: ArtifactStore): Promise<string> {
|
|
265
|
+
const bytes = utf8Bytes(text);
|
|
266
|
+
const lines = text === "" ? 1 : text.split("\n").length;
|
|
267
|
+
if (bytes <= MAX_OUTPUT_BYTES && lines <= MAX_OUTPUT_LINES) return text;
|
|
268
|
+
const path = await artifacts.save(rawResponse);
|
|
269
|
+
const notice = `[truncated: response was ${bytes} bytes across ${lines} lines; complete response saved to ${path}]`;
|
|
270
|
+
let body = text.split("\n").slice(0, MAX_OUTPUT_LINES - 1).join("\n").replace(/\n+$/u, "");
|
|
271
|
+
body = truncateUtf8(body, MAX_OUTPUT_BYTES - utf8Bytes(notice) - 1);
|
|
272
|
+
return `${body}\n${notice}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface ToolRuntime {
|
|
276
|
+
client: FirecrawlClient;
|
|
277
|
+
artifacts: ArtifactStore;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function errorResult(error: unknown, client: FirecrawlClient) {
|
|
281
|
+
const config = (() => { try { return client.getConfig(); } catch { return undefined; } })();
|
|
282
|
+
const message = error instanceof FirecrawlError ? `${error.kind}: ${error.message}` : error instanceof Error ? error.message : String(error);
|
|
283
|
+
const safe = redactSecret(message, config?.apiKey);
|
|
284
|
+
return { content: [{ type: "text" as const, text: `Firecrawl error: ${safe}` }], details: { error: safe } } satisfies AgentToolResult<ToolDetails>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function execute(runtime: ToolRuntime, kind: Parameters<typeof formatPayload>[0], responseMode: unknown, operation: () => Promise<unknown>): Promise<AgentToolResult<ToolDetails>> {
|
|
288
|
+
try {
|
|
289
|
+
const payload = await operation();
|
|
290
|
+
const raw = pretty(payload);
|
|
291
|
+
const text = await limitOutput(formatPayload(kind, payload, mode(responseMode)), raw, runtime.artifacts);
|
|
292
|
+
return { content: [{ type: "text" as const, text }], details: { responseMode: mode(responseMode) } };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return errorResult(error, runtime.client);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function registerSearchTool(pi: ExtensionAPI, runtime: ToolRuntime): void {
|
|
299
|
+
pi.registerTool({ name: "firecrawl_search", label: "Firecrawl Search", description: "Search the web through Firecrawl.", parameters: searchParameters, async execute(_id, params, signal) { return execute(runtime, "search", params.response_mode, () => runtime.client.search(buildSearchBody(params), signal)); } });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function registerScrapeTool(pi: ExtensionAPI, runtime: ToolRuntime): void {
|
|
303
|
+
pi.registerTool({ name: "firecrawl_scrape", label: "Firecrawl Scrape", description: "Scrape a web page through Firecrawl.", parameters: scrapeParameters, async execute(_id, params, signal) { return execute(runtime, "scrape", params.response_mode, () => runtime.client.scrape(buildScrapeBody(params), signal)); } });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function registerMapTool(pi: ExtensionAPI, runtime: ToolRuntime): void {
|
|
307
|
+
pi.registerTool({ name: "firecrawl_map", label: "Firecrawl Map", description: "Discover URLs on a website through Firecrawl.", parameters: mapParameters, async execute(_id, params, signal) { return execute(runtime, "map", params.response_mode, () => runtime.client.map(buildMapBody(params), signal)); } });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function registerCrawlTools(pi: ExtensionAPI, runtime: ToolRuntime): void {
|
|
311
|
+
pi.registerTool({ name: "firecrawl_crawl", label: "Firecrawl Crawl", description: "Start a Firecrawl site crawl.", parameters: crawlParameters, async execute(_id, params, signal) { return execute(runtime, "crawl", params.response_mode, () => runtime.client.crawl(buildCrawlBody(params), signal)); } });
|
|
312
|
+
pi.registerTool({ name: "firecrawl_crawl_status", label: "Firecrawl Crawl Status", description: "Read the status and documents for a Firecrawl crawl.", parameters: crawlStatusParameters, async execute(_id, params, signal) { return execute(runtime, "crawl-status", params.response_mode, () => runtime.client.crawlStatus(requireText(params.job_id, "job_id"), signal)); } });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function createRuntime(): ToolRuntime {
|
|
316
|
+
return { client: new FirecrawlClient(), artifacts: new ArtifactStore() };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function configureFromCommand(args: string, runtime: ToolRuntime, ctx: ExtensionContext): Promise<void> {
|
|
320
|
+
const trimmed = args.trim();
|
|
321
|
+
const [command, ...rest] = trimmed.split(/\s+/u);
|
|
322
|
+
const value = rest.join(" ").trim();
|
|
323
|
+
const notify = (message: string, level: "info" | "warning" = "info") => ctx.ui.notify(message, level);
|
|
324
|
+
try {
|
|
325
|
+
if (!command || command === "status") {
|
|
326
|
+
const config = runtime.client.getConfig();
|
|
327
|
+
notify(`Firecrawl endpoint: ${config.apiUrl}; authentication: ${config.apiKey ? "configured" : "not configured"}; timeout: ${config.timeoutMs} ms.`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (command === "reset") {
|
|
331
|
+
runtime.client.clearOverrides();
|
|
332
|
+
notify("Firecrawl command overrides cleared; environment configuration is active.");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (command === "url" || command === "key") {
|
|
336
|
+
const input = value || await ctx.ui.input(command === "url" ? "Firecrawl API URL" : "Firecrawl API key", command === "url" ? "http://localhost:3002" : "Enter a key (leave blank to clear)");
|
|
337
|
+
if (input === undefined) return;
|
|
338
|
+
if (command === "url") {
|
|
339
|
+
runtime.client.setOverrides({ apiUrl: input.trim() });
|
|
340
|
+
runtime.client.getConfig();
|
|
341
|
+
notify("Firecrawl API URL updated for this Pi session.");
|
|
342
|
+
} else {
|
|
343
|
+
runtime.client.setOverrides({ apiKey: input.trim() || null });
|
|
344
|
+
notify("Firecrawl API key updated for this Pi session (value hidden).");
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (command === "timeout") {
|
|
349
|
+
const timeout = Number(value);
|
|
350
|
+
if (!Number.isInteger(timeout) || timeout <= 0) throw new Error("Usage: /firecrawl timeout <positive milliseconds>");
|
|
351
|
+
runtime.client.setOverrides({ timeoutMs: timeout });
|
|
352
|
+
notify("Firecrawl timeout updated for this Pi session.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
notify("Usage: /firecrawl [status|url [URL]|key [KEY]|timeout MS|reset]", "warning");
|
|
356
|
+
} catch (error) {
|
|
357
|
+
notify(error instanceof Error ? error.message : String(error), "warning");
|
|
358
|
+
}
|
|
359
|
+
}
|