pi-unsloth-webtools 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +10 -3
  2. package/index.ts +63 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,7 +42,8 @@ Mirrors Unsloth Studio's `web_search` tool:
42
42
  `Snippet:` blocks separated by `---`, ending with the hint to pass `{"url": "<URL>"}` to
43
43
  read a full page.
44
44
  - Accepts an optional `url` parameter; when given, fetches that page's text instead of
45
- searching (optionally truncated with `maxChars`).
45
+ searching (optionally truncated with `maxChars`). An HTTP 403 on that fetch falls back
46
+ to `web_render` when the tool is enabled.
46
47
  - Rate-limit, timeout, and empty-result messages mirror Studio's `_search_failure_message`.
47
48
  - Transient engine failures (network errors or null responses) are retried once with a short
48
49
  backoff inside the same timeout budget (a retry that cannot fit in the remaining budget is
@@ -119,6 +120,9 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
119
120
  `Date:` (`article:published_time` / `dc.date` / `date`) and `Site:` (`og:site_name` /
120
121
  `application-name`) lines are added when declared, so the model can judge recency and
121
122
  provenance.
123
+ - A direct fetch refused with HTTP 403 is retried through `web_render` automatically when that
124
+ tool is enabled; the rendered page is prefixed with a note saying so. When `web_render` is
125
+ disabled or the render also fails, the original `Failed to fetch URL: HTTP 403 ...` is returned.
122
126
 
123
127
  ### web_render
124
128
 
@@ -137,6 +141,7 @@ that need JavaScript to render:
137
141
  [Companion: rotating exit IPs](#companion-rotating-exit-ips).
138
142
  - Enabled by default. Disable it with `/webtools-config` (`webRenderEnabled` in
139
143
  `~/.config/pi-unsloth-webtools/config.json`), which deactivates the tool for the session.
144
+ - Also used automatically when a `web_fetch` or `web_search` url-mode fetch is refused with HTTP 403.
140
145
 
141
146
  ## Known differences from Studio
142
147
 
@@ -148,6 +153,8 @@ that need JavaScript to render:
148
153
  - Third-party rendering: the extra `web_render` tool asks the Jina Reader (`r.jina.ai`) to fetch
149
154
  the page, so the target URL leaves the machine. Studio has no third-party rendering path. This
150
155
  path always refuses local files and non-public addresses, regardless of the local-access settings.
156
+ A direct fetch refused with HTTP 403 is retried through it automatically when enabled, so those
157
+ targets also leave the machine in that case.
151
158
  - PDF styling: MuPDF.js exposes one font per line, so mixed-style lines style the
152
159
  whole line instead of per-span; superscript, subscript, underline, strikeout, and
153
160
  highlight markers are not emitted. Tables use a conservative text-grid detector:
@@ -260,7 +267,7 @@ first changed:
260
267
 
261
268
  | Key | Default | Description |
262
269
  |---|---|---|
263
- | `webRenderEnabled` | `true` | When `false`, the `web_render` tool is deactivated for the session; `web_search` and `web_fetch` are unaffected |
270
+ | `webRenderEnabled` | `true` | When `false`, the `web_render` tool is deactivated for the session and the automatic HTTP 403 fallback in `web_fetch` / `web_search` url mode is disabled |
264
271
 
265
272
  On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (falling back to
266
273
  `~/.config`); on Windows it always uses `~/.config`.
@@ -284,7 +291,7 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
284
291
  | Private address blocked | `Blocked: refusing to fetch the non-public address ...` | The SSRF guard is active (`allowPrivateAddresses: false`); remove it or set `true` to reach localhost/LAN, and write the scheme explicitly (`http://localhost:3000`). |
285
292
  | Local file blocked | `Blocked: the URL has an invalid hostname or port.` for paths | Local files are disabled: remove `allowLocalFiles: false` to read `file://`, absolute, `~/`, or `./` paths. |
286
293
  | File read failed | `Failed to read file: ...` | Check the path exists and is a regular file. |
287
- | HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot. |
294
+ | HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot; a 403 retries through `web_render` when enabled. |
288
295
  | Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
289
296
  | PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
290
297
  | Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
package/index.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  import { defineTool, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
4
  import { collapseWhitespace } from "./html-to-md.ts";
4
5
  import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.ts";
5
6
  import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
6
7
  import { renderPageText as defaultRenderPageText } from "./web-render.ts";
7
8
  import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadJinaApiKey } from "./settings.ts";
8
- import { readConfigWithStatus, toggleWebRender } from "./config.ts";
9
+ import { readConfig, readConfigWithStatus, toggleWebRender } from "./config.ts";
9
10
  import { WebToolsConfigOverlay } from "./config-ui.ts";
10
11
 
11
12
  function toolCallLine(theme: Theme, name: string, detail: string) {
12
13
  const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
13
- return { render: () => [line], invalidate: () => {} };
14
+ return { render: (width: number) => [truncateToWidth(line, width)], invalidate: () => {} };
14
15
  }
15
16
 
16
17
  function collapsedArg(value: unknown): string {
@@ -34,6 +35,12 @@ async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unkn
34
35
  };
35
36
  }
36
37
 
38
+ function fetchWasForbidden(text: string): boolean {
39
+ return /^Failed to fetch URL: HTTP 403\b/m.test(text);
40
+ }
41
+
42
+ const FORBIDDEN_FALLBACK_NOTE = "Direct fetch failed with HTTP 403; rendered via the Jina Reader instead.";
43
+
37
44
  const WebSearchParams = Type.Object({
38
45
  query: Type.Optional(
39
46
  Type.String({ description: "The search query" }),
@@ -99,19 +106,46 @@ export interface WebToolsDeps {
99
106
  fetchPageText?: typeof defaultFetchPageText;
100
107
  webSearch?: typeof defaultWebSearch;
101
108
  renderPageText?: typeof defaultRenderPageText;
109
+ webRenderEnabled?: () => Promise<boolean>;
102
110
  }
103
111
 
104
112
  export function createWebTools(deps: WebToolsDeps = {}) {
105
113
  const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
106
114
  const webSearch = deps.webSearch ?? defaultWebSearch;
107
115
  const renderPageText = deps.renderPageText ?? defaultRenderPageText;
116
+ const webRenderEnabled =
117
+ deps.webRenderEnabled ?? (async () => (await readConfig()).webRenderEnabled !== false);
118
+
119
+ const renderOnForbidden = async (
120
+ url: string,
121
+ failedText: string,
122
+ options: { timeoutMs: number; maxChars?: number; signal?: AbortSignal; cwd?: string },
123
+ ): Promise<string | null> => {
124
+ if (!fetchWasForbidden(failedText)) return null;
125
+ try {
126
+ if (!(await webRenderEnabled())) return null;
127
+ const apiKey = await loadJinaApiKey(options.cwd);
128
+ const rendered = await renderPageText(url, {
129
+ timeoutMs: options.timeoutMs,
130
+ maxChars: options.maxChars,
131
+ signal: options.signal,
132
+ apiKey,
133
+ });
134
+ if (rendered.startsWith("Failed to render URL:") || rendered.startsWith("Blocked:")) return null;
135
+ return FORBIDDEN_FALLBACK_NOTE + "\n\n" + rendered;
136
+ } catch {
137
+ return null;
138
+ }
139
+ };
140
+
108
141
  return {
109
142
  webSearchTool: defineTool({
110
143
  name: "web_search",
111
144
  label: "Web Search",
112
145
  description:
113
146
  "Search the web and fetch page content. Returns snippets for all results. " +
114
- "Use the url parameter to fetch full page text from a specific URL.",
147
+ "Use the url parameter to fetch full page text from a specific URL. " +
148
+ "A direct fetch refused with HTTP 403 falls back to web_render when that tool is enabled.",
115
149
  promptSnippet: "Search the web and fetch page content",
116
150
  promptGuidelines: [
117
151
  'Use web_search with the url parameter (e.g. {"url": "<URL>"}) to read the full text of a page found in search results.',
@@ -128,21 +162,20 @@ export function createWebTools(deps: WebToolsDeps = {}) {
128
162
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
129
163
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
130
164
  const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
131
- return {
132
- content: [
133
- {
134
- type: "text",
135
- text: await fetchPageText(url, {
136
- timeoutMs,
137
- signal: signal ?? undefined,
138
- maxChars,
139
- allowPrivateAddresses,
140
- allowLocalFiles,
141
- }),
142
- },
143
- ],
144
- details: {},
145
- };
165
+ const text = await fetchPageText(url, {
166
+ timeoutMs,
167
+ signal: signal ?? undefined,
168
+ maxChars,
169
+ allowPrivateAddresses,
170
+ allowLocalFiles,
171
+ });
172
+ const rendered = await renderOnForbidden(url, text, {
173
+ timeoutMs,
174
+ maxChars,
175
+ signal: signal ?? undefined,
176
+ cwd,
177
+ });
178
+ return { content: [{ type: "text", text: rendered ?? text }], details: {} };
146
179
  }
147
180
  onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
148
181
  const timeoutParam = positiveNumber(params.timeoutMs);
@@ -167,7 +200,11 @@ export function createWebTools(deps: WebToolsDeps = {}) {
167
200
  "README API, so the README is returned instead of the repo page's UI chrome. " +
168
201
  "Private/loopback/link-local targets and local files (file:// URLs, absolute, ~/ or ./ paths, including " +
169
202
  "PDFs) are supported by default; opt out with webFetch.allowPrivateAddresses: false or " +
170
- "webFetch.allowLocalFiles: false in settings. The download size is capped.",
203
+ "webFetch.allowLocalFiles: false in settings. The download size is capped. " +
204
+ "A direct fetch refused with HTTP 403 falls back to web_render (Jina Reader) when that tool is enabled.",
205
+ promptGuidelines: [
206
+ "web_fetch automatically retries HTTP 403 responses through web_render (Jina Reader); do not call web_render again for the same URL after a 403.",
207
+ ],
171
208
  promptSnippet: "Fetch a web page and return readable text content",
172
209
  parameters: WebFetchParams,
173
210
  renderCall(args, theme) {
@@ -184,7 +221,13 @@ export function createWebTools(deps: WebToolsDeps = {}) {
184
221
  allowPrivateAddresses,
185
222
  allowLocalFiles,
186
223
  });
187
- return { content: [{ type: "text", text }], details: {} };
224
+ const rendered = await renderOnForbidden(params.url, text, {
225
+ timeoutMs,
226
+ maxChars,
227
+ signal: signal ?? undefined,
228
+ cwd,
229
+ });
230
+ return { content: [{ type: "text", text: rendered ?? text }], details: {} };
188
231
  },
189
232
  }),
190
233
  webRenderTool: defineTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",