@mrclrchtr/supi-web 11.1.0 → 11.3.0
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/CHANGELOG.md +6 -0
- package/README.md +63 -10
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/child-web-tools.ts +110 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +11 -0
- package/package.json +3 -2
- package/src/child.ts +48 -0
- package/src/config.ts +10 -0
- package/src/convert.ts +35 -19
- package/src/extension.ts +2 -0
- package/src/fetch.ts +10 -5
- package/src/settings-registration.ts +24 -2
- package/src/tool/web_fetch_md/execute.ts +37 -13
- package/src/tool/web_fetch_md/fetch.ts +253 -0
- package/src/tool/web_fetch_md/guidance.ts +1 -1
- package/src/tool/web_fetch_md/input.ts +21 -4
- package/src/tool/web_fetch_md/register.ts +7 -2
- package/src/tool/web_fetch_md/render.ts +11 -1
- package/src/tool/web_fetch_md/result-schema.ts +14 -1
- package/src/tool/web_fetch_md/result.ts +24 -6
package/CHANGELOG.md
ADDED
package/README.md
CHANGED
|
@@ -14,14 +14,14 @@ Adds web fetch, public web search, and Context7 documentation tools to the [Pi c
|
|
|
14
14
|
|
|
15
15
|
After installation, keep asking Pi normal questions. The agent can:
|
|
16
16
|
|
|
17
|
-
- **Read public web pages as
|
|
17
|
+
- **Read public web pages as best-effort Markdown** — prefer main content, but results may include page clutter or omit content.
|
|
18
18
|
- **Prefer source Markdown when available** — detect Markdown responses and common Markdown siblings before converting HTML.
|
|
19
19
|
- **Handle plain-text and source files** — wrap them in fenced code blocks with a language hint when the URL provides one.
|
|
20
20
|
- **Look up current library docs** — search Context7 for the right project and version, then retrieve documentation focused on the task at hand.
|
|
21
21
|
- **Search public web sources** — return source titles, URLs, and source excerpts without generating an answer.
|
|
22
22
|
- **Protect the context window** — return small results inline, move long pages to temporary files, and preserve full output when model-visible text must be truncated.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
When converting HTML, relative links and image sources become absolute by default. Native Markdown is returned unchanged.
|
|
25
25
|
|
|
26
26
|
## Example requests
|
|
27
27
|
|
|
@@ -52,14 +52,14 @@ All four tools declare PI-native `outputSchema` contracts. Direct calls still re
|
|
|
52
52
|
|
|
53
53
|
| Tool | Structured value |
|
|
54
54
|
|---|---|
|
|
55
|
-
| `web_fetch_md` | `mode: "inline"` with
|
|
55
|
+
| `web_fetch_md` | `mode: "inline"` with the full returned `markdown`, or `mode: "file"` with `filePath`; both include source `url`, `chars`, and `lines` |
|
|
56
56
|
| `web_search` | Complete `sources` (`title`, `url`, `excerpts`, optional `sourceDate`), `sourceCount`, and `excerptCount` |
|
|
57
57
|
| `web_docs_search` | All `results` with IDs, names, complete descriptions/versions, scores, and snippet counts; `count` is the number returned by Context7 |
|
|
58
58
|
| `web_docs_fetch` | `libraryId`, `chars`, `lines`, and either `format: "markdown"` with complete `markdown`, or `format: "snippets"` with SDK-normalized `snippets` (`title`, `content`, `source`) for `raw: true` |
|
|
59
59
|
|
|
60
60
|
Inline/data results include `modelOutput.truncated` and, when text is truncated, `modelOutput.fullOutputPath`. These describe the **direct-call text**, not missing structured data. Library search also reports `modelOutput.omittedResults`: matches excluded by the table's top-10 row limit before line/byte truncation. Structured descriptions and version lists are not shortened. File-mode page fetches retain the existing disk delivery instead of duplicating the body in structured data.
|
|
61
61
|
|
|
62
|
-
Results describe the evidence returned by the provider, not exhaustive coverage of the web or a library. Empty search arrays and empty snippet arrays are successful outcomes. Authentication, network, malformed web-search data, and cancellation failures still throw; codemode calls reject rather than returning a successful empty result. No tool currently returns a schema-defined failure or paginated result.
|
|
62
|
+
Results describe the evidence returned by the provider, not exhaustive coverage of a page, the web, or a library. Empty search arrays and empty snippet arrays are successful outcomes. Authentication, network, malformed web-search data, and cancellation failures still throw; codemode calls reject rather than returning a successful empty result. No tool currently returns a schema-defined failure or paginated result.
|
|
63
63
|
|
|
64
64
|
```javascript
|
|
65
65
|
const libraries = await tools.web_docs_search({
|
|
@@ -79,6 +79,21 @@ return [];
|
|
|
79
79
|
|
|
80
80
|
Structured values can be larger than their text previews. Select the fields you need before printing them. The package's `./api` export provides `WebFetchMdOutput`, `WebSearchOutput`, `WebDocsSearchOutput`, and `WebDocsFetchOutput` types. Active Pi sessions need `/reload` or a restart to load extension changes.
|
|
81
81
|
|
|
82
|
+
HTTP fetch failures include the requested URL and response status in the error message. For independent batch fetches, catch each call so a missing page does not stop the remaining work:
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
for (const url of urls) {
|
|
86
|
+
try {
|
|
87
|
+
const page = await tools.web_fetch_md({ url, output_mode: "file" });
|
|
88
|
+
text({ url, filePath: page.filePath });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
text({ url, error: String(error) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Uncaught failures stop the codemode script; `store()` writes are committed only when the script succeeds. The fetch tool does not discover relocated pages automatically.
|
|
96
|
+
|
|
82
97
|
## Web Search
|
|
83
98
|
|
|
84
99
|
`web_search` uses the installed `bx` executable. The tool is enabled by default, but it is available only when `bx` is on `PATH`. Search freshness accepts `pd`, `pw`, `pm`, `py`, or an ordered `YYYY-MM-DDtoYYYY-MM-DD` range.
|
|
@@ -87,20 +102,57 @@ Structured values can be larger than their text previews. Select the fields you
|
|
|
87
102
|
|
|
88
103
|
Install `bx` and configure it with the [Brave Search CLI instructions](https://github.com/brave/brave-search-cli). Start Pi from the shell where `command -v bx` finds the executable, then verify it with `bx --version`. `bx` handles its own credentials and configuration. Do not put credentials in Pi settings, prompts, or repository files.
|
|
89
104
|
|
|
90
|
-
To enable or disable the tool, open `/supi-settings`, choose **Web
|
|
105
|
+
To enable or disable the tool, open `/supi-settings`, choose **Web**, and change **Web Search tool**. Run `/reload` after the change. The setting applies only when the extension starts or reloads. If `bx` was not available, install or configure it, make sure it is on `PATH`, and run `/reload`.
|
|
91
106
|
|
|
92
107
|
Search results are provider-reported source material. A source date can be a publication or modification date and is not a publication-date guarantee.
|
|
93
108
|
|
|
94
109
|
## How page fetching works
|
|
95
110
|
|
|
96
|
-
`web_fetch_md`
|
|
111
|
+
`web_fetch_md` tries these representations in order:
|
|
97
112
|
|
|
98
113
|
1. Use Markdown returned directly by the server.
|
|
99
114
|
2. Detect Markdown or plain text from the response content.
|
|
100
115
|
3. Check common `.md`, `.markdown`, `index.md`, and `README.md` alternatives.
|
|
101
116
|
4. For HTML, extract the primary readable content and convert it to GitHub-flavored Markdown.
|
|
102
117
|
|
|
103
|
-
It follows redirects, removes scripts and styles
|
|
118
|
+
It follows redirects and, for HTML, removes scripts and styles while preserving headings, links, lists, code blocks, and tables.
|
|
119
|
+
|
|
120
|
+
Native Markdown is returned unchanged, including relative links and code examples. For HTML conversion, `abs_links` defaults to `true`; set it to `false` to preserve original link and image destinations. Unsafe URL schemes are removed in either HTML mode.
|
|
121
|
+
|
|
122
|
+
### Optional Firecrawl retrieval
|
|
123
|
+
|
|
124
|
+
Direct fetching remains the default and needs no API key. To allow Firecrawl, open `/supi-settings`, choose **Web**, and enable **Firecrawl** in project or global scope. It is off by default. Changes apply to subsequent fetch calls without `/reload`; enabling it permits agent-selected retrieval, not automatic URL sharing or fallback.
|
|
125
|
+
|
|
126
|
+
Export `FIRECRAWL_API_KEY` before starting Pi. The key stays in the environment, not settings. Enabling without a key saves the preference and shows a warning; direct fetching remains available.
|
|
127
|
+
|
|
128
|
+
The agent can select one `fetch_mode`:
|
|
129
|
+
|
|
130
|
+
| Mode | Behavior |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `direct` (default) | Use the local negotiation and conversion pipeline; never contact Firecrawl |
|
|
133
|
+
| `firecrawl_fallback` | Fetch directly, then use Firecrawl only after terminal HTTP 403 |
|
|
134
|
+
| `firecrawl` | Skip direct fetching and use Firecrawl immediately, useful for JS-rendered pages or poor local extraction even after HTTP 200 |
|
|
135
|
+
|
|
136
|
+
Both Firecrawl modes require the setting to be enabled. While disabled, explicit Firecrawl calls fail before DNS or HTTP requests.
|
|
137
|
+
|
|
138
|
+
For a public page blocked with HTTP 403:
|
|
139
|
+
|
|
140
|
+
```javascript
|
|
141
|
+
const page = await tools.web_fetch_md({
|
|
142
|
+
url: "https://example.com/article",
|
|
143
|
+
fetch_mode: "firecrawl_fallback",
|
|
144
|
+
output_mode: "file",
|
|
145
|
+
});
|
|
146
|
+
return page;
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
For JS-rendered content, select `fetch_mode: "firecrawl"` instead. The key is read only when Firecrawl is actually needed and is not returned in results. Firecrawl receives the requested URL and may consume credits, including for cached results. The tool requests Markdown only, with no structured-extraction add-ons or client-side retries. Firecrawl manages its own retrieval pipeline.
|
|
150
|
+
|
|
151
|
+
`firecrawl_fallback` runs only after the direct pipeline ends with HTTP 403—not for authentication errors (401), missing pages, rate limits, network failures, or cancellation. Neither mode retries Firecrawl failures through another provider. Both modes reject credential-bearing URLs, local/reserved hosts, and hosts resolving to non-public addresses before sharing them. Do not select Firecrawl for signed URLs or login-protected, private, or paywalled pages. These checks are not a sandbox or proof that a page is publicly accessible.
|
|
152
|
+
|
|
153
|
+
Firecrawl results retain the requested source URL and include `retrieval.provider: "firecrawl"`, plus `cacheState` and `cachedAt` when supplied. Both inline/file responses disclose retrieval provenance. [Firecrawl caching](https://docs.firecrawl.dev/features/scrape#caching-and-freshness) allows cached content up to two days old by default; missing cache metadata does not establish freshness. Empty, unsuccessful, warning-bearing, and recognizable access-block responses are failures, not usable article content. A null or absent warning is accepted. Firecrawl can still return incomplete or noisy content, so inspect it before relying on it.
|
|
154
|
+
|
|
155
|
+
`timeout_ms` applies to each direct HTTP request and to the complete Firecrawl attempt, including DNS validation and response reading; it is not a whole-tool deadline. The provider timeout is clamped to its supported 1–300 second range, while the local deadline still enforces shorter budgets. Cancellation is forwarded to Firecrawl. Native/Firecrawl Markdown remains unchanged, so `abs_links` applies only to local HTML conversion.
|
|
104
156
|
|
|
105
157
|
### Large pages
|
|
106
158
|
|
|
@@ -131,7 +183,7 @@ export CONTEXT7_API_KEY="ctx7sk-..."
|
|
|
131
183
|
pi
|
|
132
184
|
```
|
|
133
185
|
|
|
134
|
-
`web_fetch_md` does not use Context7
|
|
186
|
+
`web_fetch_md` does not use Context7. Direct fetching needs no API key; optional Firecrawl retrieval uses `FIRECRAWL_API_KEY`. Credentials are resolved when needed, not while the extension loads.
|
|
135
187
|
|
|
136
188
|
`raw: true` requests JSON and normalizes both provider code snippets and information snippets into `{ title, content, source }` objects. It does not expose the untouched REST envelope or provider ranking metadata. Default mode retains Context7's formatted text.
|
|
137
189
|
|
|
@@ -139,7 +191,8 @@ Requests make one attempt, forward cancellation, and add no SDK timeout. The exp
|
|
|
139
191
|
|
|
140
192
|
## Boundaries
|
|
141
193
|
|
|
142
|
-
- `web_fetch_md` is for public `http://` and `https://` sources. It does not authenticate to login-protected, private, or paywalled pages.
|
|
143
|
-
-
|
|
194
|
+
- `web_fetch_md` is intended for public `http://` and `https://` sources. It does not authenticate to login-protected, private, or paywalled pages.
|
|
195
|
+
- Direct fetching is not network-isolated and does not enforce a public-address restriction. The private-target guard runs only before forwarding a URL to Firecrawl, in either Firecrawl mode.
|
|
196
|
+
- Direct fetching does not run a browser, page JavaScript, or interactive flows; client-rendered pages may expose little useful content. Explicit Firecrawl selection delegates retrieval to that external service.
|
|
144
197
|
- When the `gh` CLI is available, Pi is guided to use it instead for GitHub URLs.
|
|
145
198
|
- Fetched pages and search excerpts are external, untrusted content. Treat them as source material, not as repository instructions.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Session-provided Web tools for SuPi child sessions (Agent Runs and Reviewer Sessions).
|
|
2
|
+
//
|
|
3
|
+
// The loaded supi-web extension answers a synchronous request on the parent event bus.
|
|
4
|
+
// Consumers never depend on supi-web; when it is absent or provides no tools, the
|
|
5
|
+
// request returns undefined and the child simply lacks Web tools.
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
/** Web tools that child sessions may receive when the containing session provides them. */
|
|
10
|
+
export const CHILD_WEB_TOOL_NAMES = [
|
|
11
|
+
"web_fetch_md",
|
|
12
|
+
"web_search",
|
|
13
|
+
"web_docs_search",
|
|
14
|
+
"web_docs_fetch",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
/** One session-provided Web tool name. */
|
|
18
|
+
export type ChildWebToolName = (typeof CHILD_WEB_TOOL_NAMES)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Web tools currently callable in the containing session plus a child-only extension
|
|
22
|
+
* factory that registers them. The factory must not capture the parent session; it may
|
|
23
|
+
* capture plain data such as the containing session's configuration directory.
|
|
24
|
+
*/
|
|
25
|
+
export interface ChildWebToolProvision {
|
|
26
|
+
readonly toolNames: readonly ChildWebToolName[];
|
|
27
|
+
/** Register the requested subset of `toolNames` in a child session. */
|
|
28
|
+
readonly createFactory: (toolNames: readonly ChildWebToolName[]) => ExtensionFactory;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Plain containing-session data supplied with a child Web tool request. */
|
|
32
|
+
export interface ChildWebToolsRequestContext {
|
|
33
|
+
/**
|
|
34
|
+
* Working directory whose SuPi configuration governs Web settings such as Firecrawl.
|
|
35
|
+
* Children with a different cwd (for example a frozen Review Workspace) keep this policy.
|
|
36
|
+
*/
|
|
37
|
+
readonly configCwd: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type EventBusLike = Pick<ExtensionAPI["events"], "on" | "emit">;
|
|
41
|
+
|
|
42
|
+
const REQUEST_CHANNEL = "supi:child-web-tools:request";
|
|
43
|
+
const RESPONSE_CHANNEL = "supi:child-web-tools:response";
|
|
44
|
+
|
|
45
|
+
interface ChildWebToolsRequest extends ChildWebToolsRequestContext {
|
|
46
|
+
readonly requestId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ChildWebToolsResponse {
|
|
50
|
+
readonly requestId: string;
|
|
51
|
+
readonly provision: ChildWebToolProvision;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let nextRequestId = 0;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Answer child Web tool requests on the parent event bus. The callback must be synchronous
|
|
58
|
+
* because Pi's event bus does not await listeners. Returns an unsubscribe function.
|
|
59
|
+
*/
|
|
60
|
+
export function provideChildWebTools(
|
|
61
|
+
events: EventBusLike,
|
|
62
|
+
resolve: (context: ChildWebToolsRequestContext) => ChildWebToolProvision | undefined,
|
|
63
|
+
): () => void {
|
|
64
|
+
return events.on(REQUEST_CHANNEL, (data) => {
|
|
65
|
+
const request = data as Partial<ChildWebToolsRequest> | undefined;
|
|
66
|
+
if (typeof request?.requestId !== "string" || typeof request.configCwd !== "string") return;
|
|
67
|
+
const { requestId, configCwd } = request;
|
|
68
|
+
const provision = resolve({ configCwd });
|
|
69
|
+
if (!provision || provision.toolNames.length === 0) return;
|
|
70
|
+
events.emit(RESPONSE_CHANNEL, { requestId, provision } satisfies ChildWebToolsResponse);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Capture the Web tools the containing session currently provides, if any. */
|
|
75
|
+
export function requestChildWebTools(
|
|
76
|
+
events: EventBusLike,
|
|
77
|
+
context: ChildWebToolsRequestContext,
|
|
78
|
+
): ChildWebToolProvision | undefined {
|
|
79
|
+
const requestId = `child-web-${process.pid}-${++nextRequestId}`;
|
|
80
|
+
let provision: ChildWebToolProvision | undefined;
|
|
81
|
+
const unsubscribe = events.on(RESPONSE_CHANNEL, (data) => {
|
|
82
|
+
const response = data as Partial<ChildWebToolsResponse> | undefined;
|
|
83
|
+
if (response?.requestId === requestId && response.provision) provision = response.provision;
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
events.emit(REQUEST_CHANNEL, { requestId, ...context } satisfies ChildWebToolsRequest);
|
|
87
|
+
} finally {
|
|
88
|
+
unsubscribe();
|
|
89
|
+
}
|
|
90
|
+
return provision;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Return the Web tools callable in a session under Pi's exposure rules: active `direct`
|
|
95
|
+
* tools, plus registered `codemode` and `deferred` tools. `model-only` and `hidden`
|
|
96
|
+
* tools are not callable.
|
|
97
|
+
*/
|
|
98
|
+
export function callableChildWebToolNames(
|
|
99
|
+
pi: Pick<ExtensionAPI, "getAllTools" | "getActiveTools">,
|
|
100
|
+
): ChildWebToolName[] {
|
|
101
|
+
const active = new Set(pi.getActiveTools());
|
|
102
|
+
const exposures = new Map(
|
|
103
|
+
pi.getAllTools().map((tool) => [tool.name, tool.exposure ?? "direct"] as const),
|
|
104
|
+
);
|
|
105
|
+
return CHILD_WEB_TOOL_NAMES.filter((name) => {
|
|
106
|
+
const exposure = exposures.get(name);
|
|
107
|
+
if (exposure === "direct") return active.has(name);
|
|
108
|
+
return exposure === "codemode" || exposure === "deferred";
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// supi-core session domain — session utilities and registries.
|
|
2
2
|
|
|
3
|
+
export type {
|
|
4
|
+
ChildWebToolName,
|
|
5
|
+
ChildWebToolProvision,
|
|
6
|
+
ChildWebToolsRequestContext,
|
|
7
|
+
} from "./child-web-tools.ts";
|
|
8
|
+
export {
|
|
9
|
+
CHILD_WEB_TOOL_NAMES,
|
|
10
|
+
callableChildWebToolNames,
|
|
11
|
+
provideChildWebTools,
|
|
12
|
+
requestChildWebTools,
|
|
13
|
+
} from "./child-web-tools.ts";
|
|
3
14
|
export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
|
|
4
15
|
export type {
|
|
5
16
|
SessionCapabilitySkill,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-web",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.0",
|
|
4
4
|
"description": "Public web pages, web search sources, and focused Context7 docs",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"type": "module",
|
|
31
31
|
"files": [
|
|
32
|
+
"CHANGELOG.md",
|
|
32
33
|
"src/**/*.ts",
|
|
33
34
|
"README.md"
|
|
34
35
|
],
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"@mozilla/readability": "^0.6.0",
|
|
39
40
|
"turndown": "^7.2.0",
|
|
40
41
|
"turndown-plugin-gfm": "^1.0.2",
|
|
41
|
-
"@mrclrchtr/supi-core": "11.
|
|
42
|
+
"@mrclrchtr/supi-core": "11.3.0"
|
|
42
43
|
},
|
|
43
44
|
"bundledDependencies": [
|
|
44
45
|
"@mrclrchtr/supi-core"
|
package/src/child.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Child-session provision of Web tools for SuPi Agent Runs and Reviewer Sessions. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
type ChildWebToolName,
|
|
6
|
+
callableChildWebToolNames,
|
|
7
|
+
provideChildWebTools,
|
|
8
|
+
} from "@mrclrchtr/supi-core/session";
|
|
9
|
+
import { registerWebDocsFetchTool } from "./tool/web_docs_fetch/register.ts";
|
|
10
|
+
import { WEB_DOCS_FETCH_TOOL_NAME } from "./tool/web_docs_fetch/spec.ts";
|
|
11
|
+
import { registerWebDocsSearchTool } from "./tool/web_docs_search/register.ts";
|
|
12
|
+
import { WEB_DOCS_SEARCH_TOOL_NAME } from "./tool/web_docs_search/spec.ts";
|
|
13
|
+
import { registerWebFetchMdTool } from "./tool/web_fetch_md/register.ts";
|
|
14
|
+
import { WEB_FETCH_MD_TOOL_NAME } from "./tool/web_fetch_md/spec.ts";
|
|
15
|
+
import { registerWebSearchTool } from "./tool/web_search/register.ts";
|
|
16
|
+
import { WEB_SEARCH_TOOL_NAME } from "./tool/web_search/spec.ts";
|
|
17
|
+
|
|
18
|
+
type Registrar = (pi: ExtensionAPI, configCwd: string) => void;
|
|
19
|
+
|
|
20
|
+
const registrars: Record<ChildWebToolName, Registrar> = {
|
|
21
|
+
[WEB_FETCH_MD_TOOL_NAME]: registerWebFetchMdTool,
|
|
22
|
+
[WEB_SEARCH_TOOL_NAME]: (pi) => registerWebSearchTool(pi),
|
|
23
|
+
[WEB_DOCS_SEARCH_TOOL_NAME]: (pi) => registerWebDocsSearchTool(pi),
|
|
24
|
+
[WEB_DOCS_FETCH_TOOL_NAME]: (pi) => registerWebDocsFetchTool(pi),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a child-only factory that registers the selected Web tools without settings,
|
|
29
|
+
* availability checks, or warnings. Availability was decided in the containing session,
|
|
30
|
+
* and Web settings keep resolving from `configCwd`, the containing session's directory.
|
|
31
|
+
*/
|
|
32
|
+
export function createChildWebToolsFactory(
|
|
33
|
+
toolNames: readonly ChildWebToolName[],
|
|
34
|
+
configCwd: string,
|
|
35
|
+
): ExtensionFactory {
|
|
36
|
+
const selected = [...new Set(toolNames)];
|
|
37
|
+
return (pi) => {
|
|
38
|
+
for (const name of selected) registrars[name](pi, configCwd);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Answer child Web tool requests with the tools currently callable in this session. */
|
|
43
|
+
export function registerChildWebToolProvider(pi: ExtensionAPI): void {
|
|
44
|
+
provideChildWebTools(pi.events, ({ configCwd }) => ({
|
|
45
|
+
toolNames: callableChildWebToolNames(pi),
|
|
46
|
+
createFactory: (toolNames) => createChildWebToolsFactory(toolNames, configCwd),
|
|
47
|
+
}));
|
|
48
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -6,15 +6,21 @@ export const WEB_CONFIG_SECTION = "web";
|
|
|
6
6
|
/** Persisted key for the Web Search tool. */
|
|
7
7
|
export const WEB_SEARCH_ENABLED_KEY = "webSearchEnabled";
|
|
8
8
|
|
|
9
|
+
/** Persisted opt-in for external Firecrawl retrieval. */
|
|
10
|
+
export const FIRECRAWL_ENABLED_KEY = "firecrawlEnabled";
|
|
11
|
+
|
|
9
12
|
/** Web extension configuration. */
|
|
10
13
|
export interface WebConfig extends Record<string, unknown> {
|
|
11
14
|
/** Enable the web_search tool. */
|
|
12
15
|
webSearchEnabled: boolean;
|
|
16
|
+
/** Allow explicitly selected Firecrawl fetches; ordinary calls still fetch directly. */
|
|
17
|
+
firecrawlEnabled: boolean;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/** Default Web extension configuration. */
|
|
16
21
|
export const WEB_DEFAULTS: WebConfig = {
|
|
17
22
|
webSearchEnabled: true,
|
|
23
|
+
firecrawlEnabled: false,
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
/** Load the merged Web extension configuration. */
|
|
@@ -25,5 +31,9 @@ export function loadWebConfig(cwd: string, homeDir?: string): WebConfig {
|
|
|
25
31
|
typeof raw.webSearchEnabled === "boolean"
|
|
26
32
|
? raw.webSearchEnabled
|
|
27
33
|
: WEB_DEFAULTS.webSearchEnabled,
|
|
34
|
+
firecrawlEnabled:
|
|
35
|
+
typeof raw.firecrawlEnabled === "boolean"
|
|
36
|
+
? raw.firecrawlEnabled
|
|
37
|
+
: WEB_DEFAULTS.firecrawlEnabled,
|
|
28
38
|
};
|
|
29
39
|
}
|
package/src/convert.ts
CHANGED
|
@@ -7,12 +7,15 @@ import { JSDOM, VirtualConsole } from "jsdom";
|
|
|
7
7
|
import Turndown from "turndown";
|
|
8
8
|
import { guessLanguage } from "./fetch.ts";
|
|
9
9
|
|
|
10
|
+
const ORIGINAL_URL_ATTRIBUTE = "data-supi-original-url";
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* Convert HTML to clean Markdown.
|
|
12
14
|
*
|
|
13
15
|
* @param html - Raw HTML string.
|
|
14
16
|
* @param baseUrl - Base URL for resolving relative links.
|
|
15
|
-
* @param options.absLinks - Whether to absolutize links and image sources.
|
|
17
|
+
* @param options.absLinks - Whether to absolutize HTML links and image sources. False preserves
|
|
18
|
+
* original destinations; unsafe URL schemes are removed in either mode.
|
|
16
19
|
* @returns Clean Markdown string.
|
|
17
20
|
*/
|
|
18
21
|
export async function htmlToMarkdown(
|
|
@@ -28,6 +31,7 @@ export async function htmlToMarkdown(
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
const doc = createDocument(html, baseUrl);
|
|
34
|
+
if (!absLinks) rememberOriginalUrls(doc);
|
|
31
35
|
|
|
32
36
|
// Remove script/style/noscript
|
|
33
37
|
for (const tag of ["script", "style", "noscript"]) {
|
|
@@ -49,9 +53,7 @@ export async function htmlToMarkdown(
|
|
|
49
53
|
const contentDoc = createDocument(`<html><body>${contentHtml}</body></html>`, baseUrl);
|
|
50
54
|
const body = contentDoc.body;
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
absolutizeLinks(body, baseUrl);
|
|
54
|
-
}
|
|
56
|
+
normalizeLinks(body, baseUrl, absLinks);
|
|
55
57
|
|
|
56
58
|
const turndown = await createTurndown();
|
|
57
59
|
let markdown = turndown.turndown(body);
|
|
@@ -91,23 +93,37 @@ function createDocument(html: string, url: string): Document {
|
|
|
91
93
|
return new JSDOM(html, { url, virtualConsole }).window.document;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Readability rewrites URLs during extraction. DOM attributes survive its cloning, so
|
|
98
|
+
* remember original destinations there, discarding any input-supplied markers first.
|
|
99
|
+
*/
|
|
100
|
+
function rememberOriginalUrls(doc: Document): void {
|
|
101
|
+
for (const element of doc.querySelectorAll(`[${ORIGINAL_URL_ATTRIBUTE}]`)) {
|
|
102
|
+
element.removeAttribute(ORIGINAL_URL_ATTRIBUTE);
|
|
103
|
+
}
|
|
104
|
+
for (const element of doc.querySelectorAll("a[href], img[src]")) {
|
|
105
|
+
const attribute = element.localName === "a" ? "href" : "src";
|
|
106
|
+
element.setAttribute(ORIGINAL_URL_ATTRIBUTE, element.getAttribute(attribute) ?? "");
|
|
102
107
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Restore original destinations when requested and filter unsafe URLs in both modes. */
|
|
111
|
+
function normalizeLinks(root: Element, baseUrl: string, absLinks: boolean): void {
|
|
112
|
+
for (const element of root.querySelectorAll("a, img")) {
|
|
113
|
+
const attribute = element.localName === "a" ? "href" : "src";
|
|
114
|
+
const original = absLinks ? null : element.getAttribute(ORIGINAL_URL_ATTRIBUTE);
|
|
115
|
+
const value = original ?? element.getAttribute(attribute);
|
|
116
|
+
if (value === null) continue;
|
|
117
|
+
const normalized = normalizeUrl(value, baseUrl, absLinks);
|
|
118
|
+
if (normalized) {
|
|
119
|
+
element.setAttribute(attribute, normalized);
|
|
107
120
|
} else {
|
|
108
|
-
|
|
121
|
+
element.removeAttribute(attribute);
|
|
109
122
|
}
|
|
110
123
|
}
|
|
124
|
+
for (const element of root.querySelectorAll(`[${ORIGINAL_URL_ATTRIBUTE}]`)) {
|
|
125
|
+
element.removeAttribute(ORIGINAL_URL_ATTRIBUTE);
|
|
126
|
+
}
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
/** Dangerous URI schemes that must never be used in href/src attributes. */
|
|
@@ -121,7 +137,7 @@ function hasDangerousScheme(value: string): boolean {
|
|
|
121
137
|
return DANGEROUS_SCHEMES.includes(scheme);
|
|
122
138
|
}
|
|
123
139
|
|
|
124
|
-
function
|
|
140
|
+
function normalizeUrl(href: string, baseUrl: string, absLinks: boolean): string {
|
|
125
141
|
const trimmed = String(href || "").trim();
|
|
126
142
|
if (
|
|
127
143
|
!trimmed ||
|
|
@@ -140,7 +156,7 @@ function resolveUrl(href: string, baseUrl: string): string {
|
|
|
140
156
|
if (resolved.protocol !== "http:" && resolved.protocol !== "https:") {
|
|
141
157
|
return "";
|
|
142
158
|
}
|
|
143
|
-
return resolved.toString();
|
|
159
|
+
return absLinks ? resolved.toString() : trimmed;
|
|
144
160
|
} catch {
|
|
145
161
|
return trimmed;
|
|
146
162
|
}
|
package/src/extension.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { registerChildWebToolProvider } from "./child.ts";
|
|
1
2
|
import docsExtension from "./docs.ts";
|
|
2
3
|
import webExtension from "./web.ts";
|
|
3
4
|
|
|
4
5
|
export default function extension(pi: Parameters<typeof webExtension>[0]): void {
|
|
5
6
|
webExtension(pi);
|
|
6
7
|
docsExtension(pi);
|
|
8
|
+
registerChildWebToolProvider(pi);
|
|
7
9
|
}
|
package/src/fetch.ts
CHANGED
|
@@ -93,7 +93,7 @@ async function tryHeadNegotiation(
|
|
|
93
93
|
signal,
|
|
94
94
|
);
|
|
95
95
|
if (!getRes.ok)
|
|
96
|
-
throw new FetchError(`Fetch failed: ${getRes.status} ${getRes.statusText}`, {
|
|
96
|
+
throw new FetchError(`Fetch failed for ${url}: ${getRes.status} ${getRes.statusText}`, {
|
|
97
97
|
status: getRes.status,
|
|
98
98
|
});
|
|
99
99
|
return {
|
|
@@ -227,9 +227,12 @@ async function fetchFullTextResult(options: FetchFullTextOptions): Promise<Fetch
|
|
|
227
227
|
options.signal,
|
|
228
228
|
);
|
|
229
229
|
if (!fullRes.ok)
|
|
230
|
-
throw new FetchError(
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
throw new FetchError(
|
|
231
|
+
`Fetch failed for ${options.url}: ${fullRes.status} ${fullRes.statusText}`,
|
|
232
|
+
{
|
|
233
|
+
status: fullRes.status,
|
|
234
|
+
},
|
|
235
|
+
);
|
|
233
236
|
return buildFetchResult(fullRes, options.url, options.contentType, options.kind);
|
|
234
237
|
}
|
|
235
238
|
|
|
@@ -280,7 +283,9 @@ async function fetchAsHtml(
|
|
|
280
283
|
signal,
|
|
281
284
|
);
|
|
282
285
|
if (!res.ok)
|
|
283
|
-
throw new FetchError(`Fetch failed: ${res.status} ${res.statusText}`, {
|
|
286
|
+
throw new FetchError(`Fetch failed for ${url}: ${res.status} ${res.statusText}`, {
|
|
287
|
+
status: res.status,
|
|
288
|
+
});
|
|
284
289
|
return {
|
|
285
290
|
url: res.url || url,
|
|
286
291
|
text: await res.text(),
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type SettingsModule,
|
|
6
6
|
} from "@mrclrchtr/supi-core/settings";
|
|
7
7
|
import {
|
|
8
|
+
FIRECRAWL_ENABLED_KEY,
|
|
8
9
|
loadWebConfig,
|
|
9
10
|
WEB_CONFIG_SECTION,
|
|
10
11
|
WEB_DEFAULTS,
|
|
@@ -14,11 +15,11 @@ import { isBxAvailable } from "./tool/web_search/bx.ts";
|
|
|
14
15
|
|
|
15
16
|
const RELOAD_NOTICE = "Web Search setting saved. Run /reload to apply this change.";
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
+
/** Contribute Web settings; Firecrawl applies per call, Web Search requires reload. */
|
|
18
19
|
export function registerWebSettings(pi: ExtensionAPI, homeDir?: string): void {
|
|
19
20
|
const fixedSettings = defineConfigSettings({
|
|
20
21
|
id: WEB_CONFIG_SECTION,
|
|
21
|
-
label: "Web
|
|
22
|
+
label: "Web",
|
|
22
23
|
section: WEB_CONFIG_SECTION,
|
|
23
24
|
defaults: WEB_DEFAULTS,
|
|
24
25
|
fields: [
|
|
@@ -28,6 +29,13 @@ export function registerWebSettings(pi: ExtensionAPI, homeDir?: string): void {
|
|
|
28
29
|
label: "Web Search tool",
|
|
29
30
|
description: "Enable the web_search tool when bx is available on PATH",
|
|
30
31
|
},
|
|
32
|
+
{
|
|
33
|
+
kind: "boolean" as const,
|
|
34
|
+
key: FIRECRAWL_ENABLED_KEY,
|
|
35
|
+
label: "Firecrawl",
|
|
36
|
+
description:
|
|
37
|
+
"Allow agent-selected Firecrawl retrieval using FIRECRAWL_API_KEY; shares public URLs and may consume credits. Ordinary fetches stay direct.",
|
|
38
|
+
},
|
|
31
39
|
],
|
|
32
40
|
...(homeDir ? { homeDir } : {}),
|
|
33
41
|
});
|
|
@@ -36,6 +44,20 @@ export function registerWebSettings(pi: ExtensionAPI, homeDir?: string): void {
|
|
|
36
44
|
...fixedSettings,
|
|
37
45
|
apply: async (request) => {
|
|
38
46
|
const result = await fixedSettings.apply(request);
|
|
47
|
+
if (request.fieldKey === FIRECRAWL_ENABLED_KEY) {
|
|
48
|
+
const missingKey =
|
|
49
|
+
loadWebConfig(request.cwd, homeDir).firecrawlEnabled &&
|
|
50
|
+
!process.env.FIRECRAWL_API_KEY?.trim();
|
|
51
|
+
return {
|
|
52
|
+
...result,
|
|
53
|
+
notice: {
|
|
54
|
+
message: missingKey
|
|
55
|
+
? "Firecrawl preference saved, but FIRECRAWL_API_KEY is missing. Export it before starting Pi to use Firecrawl."
|
|
56
|
+
: "Firecrawl setting saved. Applies to subsequent fetch calls; ordinary fetches stay direct.",
|
|
57
|
+
level: missingKey ? "warning" : "info",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
39
61
|
if (
|
|
40
62
|
request.fieldKey === WEB_SEARCH_ENABLED_KEY &&
|
|
41
63
|
loadWebConfig(request.cwd, homeDir).webSearchEnabled &&
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadWebConfig } from "../../config.ts";
|
|
2
3
|
import { htmlToMarkdown, wrapAsCodeBlock } from "../../convert.ts";
|
|
3
|
-
import {
|
|
4
|
+
import { isValidHttpUrl } from "../../fetch.ts";
|
|
4
5
|
import { writeTempFile } from "../../temp-file.ts";
|
|
5
|
-
import {
|
|
6
|
+
import { fetchPage } from "./fetch.ts";
|
|
6
7
|
import {
|
|
7
8
|
WEB_FETCH_INLINE_MAX_CHARS,
|
|
8
9
|
type WebFetchMdInput,
|
|
@@ -10,13 +11,29 @@ import {
|
|
|
10
11
|
} from "./input.ts";
|
|
11
12
|
import { buildFileResult, buildInlineResult, type WebFetchResult } from "./result.ts";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Build web_fetch_md execution. `configCwd` fixes where Web settings (Firecrawl) are read;
|
|
16
|
+
* omitted, settings come from the calling session's cwd.
|
|
17
|
+
*/
|
|
18
|
+
export function createWebFetchExecute(configCwd?: string) {
|
|
19
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
20
|
+
return (
|
|
21
|
+
_toolCallId: string,
|
|
22
|
+
params: unknown,
|
|
23
|
+
signal: AbortSignal | undefined,
|
|
24
|
+
onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
|
|
25
|
+
ctx: ExtensionContext,
|
|
26
|
+
): Promise<WebFetchResult> => fetchAsMarkdown(params, signal, onUpdate, configCwd ?? ctx.cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Default web_fetch_md execution reading Web settings from the calling session's cwd. */
|
|
30
|
+
export const runWebFetch = createWebFetchExecute();
|
|
31
|
+
|
|
32
|
+
async function fetchAsMarkdown(
|
|
16
33
|
params: unknown,
|
|
17
34
|
signal: AbortSignal | undefined,
|
|
18
35
|
onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
|
|
19
|
-
|
|
36
|
+
configCwd: string,
|
|
20
37
|
): Promise<WebFetchResult> {
|
|
21
38
|
const input = (params ?? {}) as WebFetchMdInput;
|
|
22
39
|
const url = String(input.url || "").trim();
|
|
@@ -33,22 +50,29 @@ export async function runWebFetch(
|
|
|
33
50
|
details: { url, outputMode },
|
|
34
51
|
});
|
|
35
52
|
|
|
36
|
-
const result = await
|
|
53
|
+
const result = await fetchPage(url, {
|
|
54
|
+
timeoutMs,
|
|
55
|
+
signal,
|
|
56
|
+
fetchMode: input.fetch_mode,
|
|
57
|
+
firecrawlEnabled: loadWebConfig(configCwd).firecrawlEnabled,
|
|
58
|
+
});
|
|
37
59
|
const markdown = await resolveMarkdown(result, absLinks);
|
|
38
60
|
const lines = markdown.split("\n").length;
|
|
39
61
|
const chars = markdown.length;
|
|
40
|
-
const base = {
|
|
62
|
+
const base = {
|
|
63
|
+
chars,
|
|
64
|
+
lines,
|
|
65
|
+
url: result.url,
|
|
66
|
+
outputMode,
|
|
67
|
+
...(result.retrieval ? { retrieval: result.retrieval } : {}),
|
|
68
|
+
};
|
|
41
69
|
|
|
42
70
|
if (shouldReturnFile(outputMode, chars)) {
|
|
43
71
|
const filePath = await writeTempFile(markdown, "web-fetch-md", ".md");
|
|
44
72
|
return buildFileResult(base, filePath);
|
|
45
73
|
}
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
tempPrefix: "web-fetch-md",
|
|
49
|
-
suffix: ".md",
|
|
50
|
-
});
|
|
51
|
-
return buildInlineResult(base, markdown, output);
|
|
75
|
+
return buildInlineResult(base, markdown);
|
|
52
76
|
}
|
|
53
77
|
|
|
54
78
|
function shouldReturnFile(outputMode: WebFetchOutputMode, chars: number): boolean {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { BlockList, isIP } from "node:net";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { Value } from "typebox/value";
|
|
5
|
+
import {
|
|
6
|
+
FetchError,
|
|
7
|
+
type FetchOptions,
|
|
8
|
+
type FetchResult,
|
|
9
|
+
fetchWithNegotiation,
|
|
10
|
+
} from "../../fetch.ts";
|
|
11
|
+
import { WEB_FETCH_MODES, type WebFetchMode } from "./input.ts";
|
|
12
|
+
import { FIRECRAWL_METADATA_MAX_CHARS, type FirecrawlRetrieval } from "./result-schema.ts";
|
|
13
|
+
|
|
14
|
+
const FIRECRAWL_URL = "https://api.firecrawl.dev/v2/scrape";
|
|
15
|
+
const providerPageSchema = Type.Object({
|
|
16
|
+
success: Type.Literal(true),
|
|
17
|
+
data: Type.Object({
|
|
18
|
+
markdown: Type.String(),
|
|
19
|
+
warning: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
20
|
+
metadata: Type.Optional(
|
|
21
|
+
Type.Object({
|
|
22
|
+
title: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])),
|
|
23
|
+
statusCode: Type.Optional(Type.Integer()),
|
|
24
|
+
cacheState: Type.Optional(Type.String({ maxLength: FIRECRAWL_METADATA_MAX_CHARS })),
|
|
25
|
+
cachedAt: Type.Optional(Type.String({ maxLength: FIRECRAWL_METADATA_MAX_CHARS })),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const nonPublicIPv4 = new BlockList();
|
|
32
|
+
for (const [address, prefix] of [
|
|
33
|
+
["0.0.0.0", 8],
|
|
34
|
+
["10.0.0.0", 8],
|
|
35
|
+
["100.64.0.0", 10],
|
|
36
|
+
["127.0.0.0", 8],
|
|
37
|
+
["169.254.0.0", 16],
|
|
38
|
+
["172.16.0.0", 12],
|
|
39
|
+
["192.0.0.0", 24],
|
|
40
|
+
["192.0.2.0", 24],
|
|
41
|
+
["192.88.99.0", 24],
|
|
42
|
+
["192.168.0.0", 16],
|
|
43
|
+
["198.18.0.0", 15],
|
|
44
|
+
["198.51.100.0", 24],
|
|
45
|
+
["203.0.113.0", 24],
|
|
46
|
+
["224.0.0.0", 4],
|
|
47
|
+
["240.0.0.0", 4],
|
|
48
|
+
] as const)
|
|
49
|
+
nonPublicIPv4.addSubnet(address, prefix, "ipv4");
|
|
50
|
+
|
|
51
|
+
// Conservatively permit global-unicast IPv6 only, excluding special-purpose ranges.
|
|
52
|
+
// This also rejects loopback, link-local, unique-local, and IPv4-mapped literals.
|
|
53
|
+
const globalIPv6 = new BlockList();
|
|
54
|
+
globalIPv6.addSubnet("2000::", 3, "ipv6");
|
|
55
|
+
const specialIPv6 = new BlockList();
|
|
56
|
+
specialIPv6.addSubnet("2001::", 23, "ipv6");
|
|
57
|
+
specialIPv6.addSubnet("2001:db8::", 32, "ipv6");
|
|
58
|
+
specialIPv6.addSubnet("2002::", 16, "ipv6");
|
|
59
|
+
specialIPv6.addSubnet("3fff::", 20, "ipv6");
|
|
60
|
+
|
|
61
|
+
/** A page body with provenance when an external provider supplied it. */
|
|
62
|
+
export interface PageFetchResult extends FetchResult {
|
|
63
|
+
retrieval?: FirecrawlRetrieval;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Honor the explicit route; Firecrawl requires a user opt-in even for fallback. */
|
|
67
|
+
export async function fetchPage(
|
|
68
|
+
url: string,
|
|
69
|
+
options: FetchOptions & { fetchMode?: WebFetchMode; firecrawlEnabled: boolean },
|
|
70
|
+
): Promise<PageFetchResult> {
|
|
71
|
+
const mode = options.fetchMode ?? "direct";
|
|
72
|
+
if (!WEB_FETCH_MODES.includes(mode)) {
|
|
73
|
+
throw new FetchError(
|
|
74
|
+
'Unsupported fetch_mode; select "direct", "firecrawl_fallback", or "firecrawl"',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (mode !== "direct" && !options.firecrawlEnabled) {
|
|
78
|
+
throw new FetchError("Firecrawl is disabled. Enable Web > Firecrawl in /supi-settings.");
|
|
79
|
+
}
|
|
80
|
+
if (mode === "firecrawl") return fetchWithFirecrawl(url, options);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return await fetchWithNegotiation(url, options);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
options.signal?.throwIfAborted();
|
|
86
|
+
if (!(error instanceof FetchError) || error.status !== 403) throw error;
|
|
87
|
+
if (mode === "direct") {
|
|
88
|
+
const setupHint = options.firecrawlEnabled
|
|
89
|
+
? ""
|
|
90
|
+
: "requires Firecrawl enabled in /supi-settings and FIRECRAWL_API_KEY; ";
|
|
91
|
+
throw new FetchError(
|
|
92
|
+
`${error.message}. For a public page, retry with fetch_mode: "firecrawl_fallback" (${setupHint}shares the URL and may consume credits).`,
|
|
93
|
+
{ status: 403, cause: error },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return fetchWithFirecrawl(url, options);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function fetchWithFirecrawl(url: string, options: FetchOptions): Promise<PageFetchResult> {
|
|
101
|
+
const key = process.env.FIRECRAWL_API_KEY?.trim();
|
|
102
|
+
if (!key) throw new FetchError("Firecrawl requires FIRECRAWL_API_KEY");
|
|
103
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
104
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
105
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
106
|
+
try {
|
|
107
|
+
return await abortable(requestFirecrawl(url, { key, timeoutMs, signal }), signal);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
options.signal?.throwIfAborted();
|
|
110
|
+
if (timeout.aborted) {
|
|
111
|
+
// biome-ignore lint/style/useErrorCause: retain the timeout reason, not possibly credential-bearing provider errors
|
|
112
|
+
throw new FetchError(`Firecrawl timed out after ${timeoutMs}ms`, {
|
|
113
|
+
cause: timeout.reason,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (error instanceof FetchError) throw error;
|
|
117
|
+
// biome-ignore lint/style/useErrorCause: transport/parse errors may reflect Authorization; never retain them as causes
|
|
118
|
+
throw new FetchError("Firecrawl request failed");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function requestFirecrawl(
|
|
123
|
+
url: string,
|
|
124
|
+
options: { key: string; timeoutMs: number; signal: AbortSignal },
|
|
125
|
+
): Promise<PageFetchResult> {
|
|
126
|
+
options.signal.throwIfAborted();
|
|
127
|
+
const target = await publicTarget(url);
|
|
128
|
+
options.signal.throwIfAborted();
|
|
129
|
+
const response = await fetch(FIRECRAWL_URL, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
redirect: "error",
|
|
132
|
+
signal: options.signal,
|
|
133
|
+
headers: { Authorization: `Bearer ${options.key}`, "Content-Type": "application/json" },
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
url: target.href,
|
|
136
|
+
formats: ["markdown"],
|
|
137
|
+
onlyMainContent: true,
|
|
138
|
+
// The provider requires >= 1s; the local signal still enforces a shorter budget.
|
|
139
|
+
timeout: Math.max(1_000, Math.min(options.timeoutMs, 300_000)),
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw new FetchError(`Firecrawl failed: HTTP ${response.status}`, {
|
|
144
|
+
status: response.status,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const body: unknown = await response.json();
|
|
148
|
+
if (!Value.Check(providerPageSchema, body)) {
|
|
149
|
+
throw new FetchError("Firecrawl returned an unsuccessful or invalid response");
|
|
150
|
+
}
|
|
151
|
+
const { markdown, metadata, warning } = body.data;
|
|
152
|
+
if (
|
|
153
|
+
[markdown, metadata?.cacheState, metadata?.cachedAt].some((value) =>
|
|
154
|
+
value?.includes(options.key),
|
|
155
|
+
)
|
|
156
|
+
) {
|
|
157
|
+
throw new FetchError("Firecrawl returned credential-bearing content");
|
|
158
|
+
}
|
|
159
|
+
if (!markdown.trim() || warning?.trim()) {
|
|
160
|
+
throw new FetchError(
|
|
161
|
+
"Firecrawl returned empty content or a warning; content may be incomplete",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const status = metadata?.statusCode;
|
|
165
|
+
if (
|
|
166
|
+
(status !== undefined && status !== 304 && (status < 200 || status >= 300)) ||
|
|
167
|
+
isAccessBlock(metadata?.title, markdown)
|
|
168
|
+
) {
|
|
169
|
+
throw new FetchError("Firecrawl returned an error or access-block page, not source content");
|
|
170
|
+
}
|
|
171
|
+
// Keep only allowlisted provenance, never the raw provider envelope or credentials.
|
|
172
|
+
return {
|
|
173
|
+
url,
|
|
174
|
+
text: markdown,
|
|
175
|
+
contentType: "text/markdown",
|
|
176
|
+
isMarkdown: true,
|
|
177
|
+
isPlainText: false,
|
|
178
|
+
retrieval: {
|
|
179
|
+
provider: "firecrawl",
|
|
180
|
+
...(metadata?.cacheState ? { cacheState: metadata.cacheState } : {}),
|
|
181
|
+
...(metadata?.cachedAt ? { cachedAt: metadata.cachedAt } : {}),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Validate before disclosing a URL. DNS checks are conservative, not a sandbox for the reader. */
|
|
187
|
+
async function publicTarget(url: string): Promise<URL> {
|
|
188
|
+
const target = new URL(url);
|
|
189
|
+
const hostname = target.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
190
|
+
if (target.username || target.password) {
|
|
191
|
+
throw new FetchError("Firecrawl does not accept URLs containing credentials");
|
|
192
|
+
}
|
|
193
|
+
if (isIP(hostname)) {
|
|
194
|
+
if (!isPublicAddress(hostname)) throw new FetchError("Firecrawl requires a public URL");
|
|
195
|
+
} else {
|
|
196
|
+
if (
|
|
197
|
+
!hostname.includes(".") ||
|
|
198
|
+
/(?:^|\.)(?:localhost|local|localdomain|internal|test|invalid|example|onion|lan|home|corp)$|\.home\.arpa$/i.test(
|
|
199
|
+
hostname,
|
|
200
|
+
)
|
|
201
|
+
) {
|
|
202
|
+
throw new FetchError("Firecrawl requires a public hostname");
|
|
203
|
+
}
|
|
204
|
+
const addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
205
|
+
if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) {
|
|
206
|
+
throw new FetchError("Firecrawl requires exclusively public DNS addresses");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Fragments are not part of HTTP requests and may contain client-only information.
|
|
210
|
+
target.hash = "";
|
|
211
|
+
return target;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isPublicAddress(address: string): boolean {
|
|
215
|
+
if (isIP(address) === 4) return !nonPublicIPv4.check(address, "ipv4");
|
|
216
|
+
return (
|
|
217
|
+
isIP(address) === 6 && globalIPv6.check(address, "ipv6") && !specialIPv6.check(address, "ipv6")
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isAccessBlock(title: string | string[] | undefined, markdown: string): boolean {
|
|
222
|
+
const titles = Array.isArray(title) ? title : [title];
|
|
223
|
+
return (
|
|
224
|
+
titles.some((value) =>
|
|
225
|
+
/^(?:attention required!?|just a moment[.!…]*|access denied|403 forbidden|verify (?:you are|you're) human|checking your browser)(?:\s*[-|].*)?$/i.test(
|
|
226
|
+
value?.trim() ?? "",
|
|
227
|
+
),
|
|
228
|
+
) ||
|
|
229
|
+
// Only a page-leading heading signals a block; documentation may discuss these errors.
|
|
230
|
+
/^#{1,2}\s+(?:Sorry, you have been blocked|Access denied|403 Forbidden|Just a moment\.{0,3})\s*$/i.test(
|
|
231
|
+
markdown.trimStart().split("\n", 1)[0] ?? "",
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Bound DNS, HTTP, and response-body work; an outstanding lookup cannot trigger a later request. */
|
|
237
|
+
function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
const abort = () => reject(signal.reason);
|
|
240
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
241
|
+
pending.then(
|
|
242
|
+
(value) => {
|
|
243
|
+
signal.removeEventListener("abort", abort);
|
|
244
|
+
resolve(value);
|
|
245
|
+
},
|
|
246
|
+
(error) => {
|
|
247
|
+
signal.removeEventListener("abort", abort);
|
|
248
|
+
reject(error);
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
if (signal.aborted) abort();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { WebToolPromptSurface } from "../tool-specs.ts";
|
|
2
2
|
import { WEB_FETCH_INLINE_MAX_CHARS } from "./spec.ts";
|
|
3
3
|
|
|
4
|
-
export const toolDescription = `Fetch
|
|
4
|
+
export const toolDescription = `Fetch public http(s) pages as best-effort Markdown, directly or via opt-in Firecrawl for JS-rendered pages. Not for login, private, or paywalled pages. Direct fetching is not network-isolated. Use gh for GitHub URLs when available. Auto returns up to ${WEB_FETCH_INLINE_MAX_CHARS.toLocaleString()} chars inline, otherwise a temp file. Native Markdown is unchanged; HTML links default to absolute.`;
|
|
5
5
|
|
|
6
6
|
export const promptSnippet = "fetch a public URL as Markdown";
|
|
7
7
|
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// execute binding, and a spec <-> execute cycle would risk undefined
|
|
6
6
|
// bindings depending on module evaluation order.
|
|
7
7
|
|
|
8
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
9
8
|
import type { Static } from "typebox";
|
|
10
9
|
import { Type } from "typebox";
|
|
11
10
|
import { FETCH_TIMEOUT_MAX_MS } from "../../fetch.ts";
|
|
@@ -14,7 +13,12 @@ export const WEB_FETCH_INLINE_MAX_CHARS = 15_000;
|
|
|
14
13
|
export const WEB_FETCH_OUTPUT_MODES = ["auto", "inline", "file"] as const;
|
|
15
14
|
export type WebFetchOutputMode = (typeof WEB_FETCH_OUTPUT_MODES)[number];
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
/** Explicit retrieval routes; enabling Firecrawl never changes the default route. */
|
|
17
|
+
export const WEB_FETCH_MODES = ["direct", "firecrawl_fallback", "firecrawl"] as const;
|
|
18
|
+
export type WebFetchMode = (typeof WEB_FETCH_MODES)[number];
|
|
19
|
+
|
|
20
|
+
const OutputModeEnum = Type.Enum(WEB_FETCH_OUTPUT_MODES, {
|
|
21
|
+
type: "string",
|
|
18
22
|
default: "auto",
|
|
19
23
|
description: "auto, inline, or file output",
|
|
20
24
|
});
|
|
@@ -23,10 +27,23 @@ export const webFetchMdParameters = Type.Object(
|
|
|
23
27
|
{
|
|
24
28
|
url: Type.String({ description: "Public http(s) URL" }),
|
|
25
29
|
output_mode: Type.Optional(OutputModeEnum),
|
|
26
|
-
|
|
30
|
+
fetch_mode: Type.Optional(
|
|
31
|
+
Type.Enum(WEB_FETCH_MODES, {
|
|
32
|
+
type: "string",
|
|
33
|
+
default: "direct",
|
|
34
|
+
description:
|
|
35
|
+
"direct fetches locally; firecrawl_fallback uses Firecrawl only after HTTP 403; firecrawl skips direct fetching for JS-heavy or poorly extracted pages. Firecrawl requires opt-in via /supi-settings and FIRECRAWL_API_KEY, shares the URL and may consume credits. Never use it for signed, login-protected, private, or paywalled URLs.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
abs_links: Type.Optional(
|
|
39
|
+
Type.Boolean({
|
|
40
|
+
description: "Absolutize HTML links/images; false preserves original destinations.",
|
|
41
|
+
default: true,
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
27
44
|
timeout_ms: Type.Optional(
|
|
28
45
|
Type.Integer({
|
|
29
|
-
description: "
|
|
46
|
+
description: "Timeout per direct request and for the complete Firecrawl attempt (ms)",
|
|
30
47
|
default: 30_000,
|
|
31
48
|
minimum: 0,
|
|
32
49
|
maximum: FETCH_TIMEOUT_MAX_MS,
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createWebFetchExecute } from "./execute.ts";
|
|
2
3
|
import { getWebFetchPromptSurface } from "./guidance.ts";
|
|
3
4
|
import { renderWebFetchCall, renderWebFetchResult } from "./render.ts";
|
|
4
5
|
import { webFetchMdSpec } from "./spec.ts";
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Register the web_fetch_md tool. `configCwd` fixes where Web settings are read, so a child
|
|
9
|
+
* session keeps its containing session's Firecrawl policy.
|
|
10
|
+
*/
|
|
11
|
+
export function registerWebFetchMdTool(pi: ExtensionAPI, configCwd?: string): void {
|
|
8
12
|
const surface = getWebFetchPromptSurface();
|
|
9
13
|
pi.registerTool({
|
|
10
14
|
...webFetchMdSpec,
|
|
15
|
+
...(configCwd ? { execute: createWebFetchExecute(configCwd) } : {}),
|
|
11
16
|
description: surface.description,
|
|
12
17
|
promptSnippet: surface.promptSnippet,
|
|
13
18
|
promptGuidelines: surface.promptGuidelines,
|
|
@@ -8,7 +8,8 @@ export function renderWebFetchCall(args: unknown, theme: Theme) {
|
|
|
8
8
|
const input = (args ?? {}) as WebFetchMdInput;
|
|
9
9
|
const url = typeof input.url === "string" ? input.url : "";
|
|
10
10
|
const outputMode = typeof input.output_mode === "string" ? input.output_mode : undefined;
|
|
11
|
-
|
|
11
|
+
const annotation = [outputMode, input.fetch_mode].filter(Boolean).join(", ");
|
|
12
|
+
return renderToolCall(WEB_FETCH_MD_TOOL_NAME, url, theme, annotation || undefined);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/** Transcript renderer for web_fetch_md tool results. */
|
|
@@ -55,6 +56,7 @@ function buildWebFetchSummary(
|
|
|
55
56
|
"dim",
|
|
56
57
|
` (${details.chars.toLocaleString()} chars, ${details.lines.toLocaleString()} lines)`,
|
|
57
58
|
),
|
|
59
|
+
theme.fg("dim", retrievalSummary(details)),
|
|
58
60
|
].join("");
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -66,9 +68,17 @@ function buildWebFetchSummary(
|
|
|
66
68
|
),
|
|
67
69
|
].join("");
|
|
68
70
|
|
|
71
|
+
summary += theme.fg("dim", retrievalSummary(details));
|
|
72
|
+
|
|
69
73
|
if (details.truncation?.truncated) {
|
|
70
74
|
summary += theme.fg("warning", " [truncated]");
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
return summary;
|
|
74
78
|
}
|
|
79
|
+
|
|
80
|
+
function retrievalSummary(details: WebFetchDetails): string {
|
|
81
|
+
if (!details.retrieval) return "";
|
|
82
|
+
const cache = details.retrieval.cacheState ? `; cache: ${details.retrieval.cacheState}` : "";
|
|
83
|
+
return ` via Firecrawl${cache}`;
|
|
84
|
+
}
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { type Static, Type } from "typebox";
|
|
2
2
|
import { modelOutputSchema } from "../result-schema.ts";
|
|
3
3
|
|
|
4
|
+
/** Bound external provenance used in model-visible file summaries. */
|
|
5
|
+
export const FIRECRAWL_METADATA_MAX_CHARS = 128;
|
|
6
|
+
|
|
7
|
+
const firecrawlRetrievalSchema = Type.Object({
|
|
8
|
+
provider: Type.Literal("firecrawl"),
|
|
9
|
+
cacheState: Type.Optional(Type.String({ maxLength: FIRECRAWL_METADATA_MAX_CHARS })),
|
|
10
|
+
cachedAt: Type.Optional(Type.String({ maxLength: FIRECRAWL_METADATA_MAX_CHARS })),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** External reader provenance; missing cache metadata does not imply a fresh fetch. */
|
|
14
|
+
export type FirecrawlRetrieval = Static<typeof firecrawlRetrievalSchema>;
|
|
15
|
+
|
|
4
16
|
const metrics = {
|
|
5
|
-
url: Type.String({ description: "
|
|
17
|
+
url: Type.String({ description: "Source URL; the requested URL when using Firecrawl." }),
|
|
18
|
+
retrieval: Type.Optional(firecrawlRetrievalSchema),
|
|
6
19
|
chars: Type.Integer({ minimum: 0 }),
|
|
7
20
|
lines: Type.Integer({ minimum: 0 }),
|
|
8
21
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { limitModelVisibleOutput, modelOutputMetadata } from "../result.ts";
|
|
3
3
|
import type { WebFetchOutputMode } from "./input.ts";
|
|
4
|
-
import type { WebFetchMdOutput } from "./result-schema.ts";
|
|
4
|
+
import type { FirecrawlRetrieval, WebFetchMdOutput } from "./result-schema.ts";
|
|
5
5
|
|
|
6
6
|
/** Final fetch result requires both native representations. */
|
|
7
7
|
export type WebFetchResult = AgentToolResult<WebFetchDetails> & {
|
|
@@ -13,6 +13,7 @@ export interface WebFetchDetails extends Record<string, unknown> {
|
|
|
13
13
|
lines: number;
|
|
14
14
|
url: string;
|
|
15
15
|
outputMode: WebFetchOutputMode;
|
|
16
|
+
retrieval?: FirecrawlRetrieval;
|
|
16
17
|
filePath?: string;
|
|
17
18
|
truncation?: TruncationResult;
|
|
18
19
|
fullOutputPath?: string;
|
|
@@ -24,6 +25,7 @@ export interface WebFetchResultBase {
|
|
|
24
25
|
lines: number;
|
|
25
26
|
url: string;
|
|
26
27
|
outputMode: WebFetchOutputMode;
|
|
28
|
+
retrieval?: FirecrawlRetrieval;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** Assemble the file-mode result for one fetch. */
|
|
@@ -32,7 +34,7 @@ export function buildFileResult(base: WebFetchResultBase, filePath: string): Web
|
|
|
32
34
|
content: [
|
|
33
35
|
{
|
|
34
36
|
type: "text",
|
|
35
|
-
text:
|
|
37
|
+
text: `${retrievalNotice(base)}Content written to ${filePath} (${base.chars.toLocaleString()} chars, ${base.lines.toLocaleString()} lines). Use the read tool to access it.`,
|
|
36
38
|
},
|
|
37
39
|
],
|
|
38
40
|
structuredContent: {
|
|
@@ -41,17 +43,21 @@ export function buildFileResult(base: WebFetchResultBase, filePath: string): Web
|
|
|
41
43
|
chars: base.chars,
|
|
42
44
|
lines: base.lines,
|
|
43
45
|
filePath,
|
|
46
|
+
...(base.retrieval ? { retrieval: base.retrieval } : {}),
|
|
44
47
|
},
|
|
45
48
|
details: { ...base, filePath },
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/** Assemble the inline-mode result for one fetch. */
|
|
50
|
-
export function buildInlineResult(
|
|
53
|
+
export async function buildInlineResult(
|
|
51
54
|
base: WebFetchResultBase,
|
|
52
55
|
markdown: string,
|
|
53
|
-
|
|
54
|
-
)
|
|
56
|
+
): Promise<WebFetchResult> {
|
|
57
|
+
const output = await limitModelVisibleOutput(retrievalNotice(base) + markdown, {
|
|
58
|
+
tempPrefix: "web-fetch-md",
|
|
59
|
+
suffix: ".md",
|
|
60
|
+
});
|
|
55
61
|
return {
|
|
56
62
|
content: [{ type: "text", text: output.text }],
|
|
57
63
|
structuredContent: {
|
|
@@ -61,6 +67,7 @@ export function buildInlineResult(
|
|
|
61
67
|
lines: base.lines,
|
|
62
68
|
markdown,
|
|
63
69
|
modelOutput: modelOutputMetadata(output),
|
|
70
|
+
...(base.retrieval ? { retrieval: base.retrieval } : {}),
|
|
64
71
|
},
|
|
65
72
|
details: {
|
|
66
73
|
...base,
|
|
@@ -69,3 +76,14 @@ export function buildInlineResult(
|
|
|
69
76
|
},
|
|
70
77
|
};
|
|
71
78
|
}
|
|
79
|
+
|
|
80
|
+
function retrievalNotice(base: WebFetchResultBase): string {
|
|
81
|
+
if (!base.retrieval) return "";
|
|
82
|
+
const { cacheState, cachedAt } = base.retrieval;
|
|
83
|
+
const cache = cacheState ? `cache: ${cacheState}` : "cache status unknown";
|
|
84
|
+
const timestamp = cachedAt ? `; cached at ${cachedAt}` : "";
|
|
85
|
+
// Provider fields are schema-bounded; shorten URL display without losing the full structured URL.
|
|
86
|
+
const source =
|
|
87
|
+
base.url.length > 1024 ? `${base.url.slice(0, 1024)}… [source URL shortened]` : base.url;
|
|
88
|
+
return `Retrieved via Firecrawl (${cache}${timestamp}). Source: ${source}\n\n`;
|
|
89
|
+
}
|