pi-unsloth-webtools 0.6.0 → 0.7.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/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # pi-unsloth-webtools
2
2
 
3
- A [pi](https://github.com/earendil-works/pi-coding-agent) extension providing `web_search` and
4
- `web_fetch` tools, ported from the Unsloth Studio codebase
5
- ([`unslothai/unsloth`](https://github.com/unslothai/unsloth), `studio/backend/core/inference/`).
3
+ A [pi](https://github.com/earendil-works/pi-coding-agent) extension providing `web_search`,
4
+ `web_fetch`, and `web_render` tools. It began as a port of the Unsloth Studio codebase
5
+ ([`unslothai/unsloth`](https://github.com/unslothai/unsloth), `studio/backend/core/inference/`);
6
+ the engine, extraction, and PDF layers are still derived from it, but the package is no longer
7
+ behavior-identical to Studio — it enables local file and private-address fetching by default
8
+ and adds a fetch cache, Wayback fallbacks, page metadata, a third-party rendering tool
9
+ (`web_render`), and other behavior Studio does not have. See
10
+ [Known differences from Studio](#known-differences-from-studio). The `unsloth` in the name marks
11
+ provenance, not affiliation.
6
12
 
7
13
  ## Install
8
14
 
@@ -20,6 +26,8 @@ pi install /path/to/pi-unsloth-webtools
20
26
 
21
27
  ## What it does
22
28
 
29
+ All three tools display their target in the TUI tool row: `web_search "query"`, `web_search <url>` in url mode, `web_fetch <url>`, and `web_render <url>`.
30
+
23
31
  ### web_search
24
32
 
25
33
  Mirrors Unsloth Studio's `web_search` tool:
@@ -112,8 +120,34 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
112
120
  `application-name`) lines are added when declared, so the model can judge recency and
113
121
  provenance.
114
122
 
123
+ ### web_render
124
+
125
+ Renders a public page to Markdown through the third-party Jina Reader (`r.jina.ai`) for pages
126
+ that need JavaScript to render:
127
+
128
+ - Every target is validated and resolved locally first: http/https only, and any private,
129
+ loopback, link-local, or otherwise non-public address is refused. Local files are refused on
130
+ this path regardless of `webFetch.allowPrivateAddresses` / `webFetch.allowLocalFiles`, because
131
+ the URL is sent to Jina.
132
+ - `unslothWebTools.jinaApiKey` (or `webRender.jinaApiKey`, or the `JINA_API_KEY` environment
133
+ variable) raises the Reader's rate limits; without a key it still works at Jina's free limits.
134
+ - Output is Markdown prefixed with `Title:` / `URL:` lines and a `Rendered via the Jina Reader`
135
+ provenance line. An optional `maxChars` truncates, like `web_fetch`.
136
+ - Keyless Reader requests are rate-limited per outgoing IP; see
137
+ [Companion: rotating exit IPs](#companion-rotating-exit-ips).
138
+ - Enabled by default. Disable it with `/webtools-config` (`webRenderEnabled` in
139
+ `~/.config/pi-unsloth-webtools/config.json`), which deactivates the tool for the session.
140
+
115
141
  ## Known differences from Studio
116
142
 
143
+ - Local access: Studio validates every resolved address against non-public ranges and fetches
144
+ public web content only. This port permits private/loopback/link-local targets and local files
145
+ (`file://` URLs and absolute, `~/`, `./` paths) by default; opt out with
146
+ `webFetch.allowPrivateAddresses: false` and `webFetch.allowLocalFiles: false` to restore
147
+ Studio's behavior.
148
+ - Third-party rendering: the extra `web_render` tool asks the Jina Reader (`r.jina.ai`) to fetch
149
+ the page, so the target URL leaves the machine. Studio has no third-party rendering path. This
150
+ path always refuses local files and non-public addresses, regardless of the local-access settings.
117
151
  - PDF styling: MuPDF.js exposes one font per line, so mixed-style lines style the
118
152
  whole line instead of per-span; superscript, subscript, underline, strikeout, and
119
153
  highlight markers are not emitted. Tables use a conservative text-grid detector:
@@ -133,8 +167,10 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
133
167
  generic engine failures. The timeout budget bounds the entire sweep: per-engine
134
168
  timeouts shrink as the budget is consumed, so the reported timeout matches the
135
169
  worst-case wall time.
136
- - Proxies: Studio routes through environment proxies; this port always connects
137
- directly with DNS pinning (deliberately out of scope).
170
+ - Proxies: Studio routes through environment proxies; this port's direct fetch always connects
171
+ directly with DNS pinning (deliberately out of scope). The search and `web_render` paths use the
172
+ process-wide `fetch`, so an agent-level proxy dispatcher does apply to them — see
173
+ [Companion: rotating exit IPs](#companion-rotating-exit-ips).
138
174
  - Dedup and titles: the aggregator keys on canonicalized hrefs (`utm_*`/tracking parameters
139
175
  and fragments stripped, then the URL re-serialized); fetched HTML pages are prefixed with
140
176
  the document `<title>`. Studio keys on raw hrefs and returns the converted body alone.
@@ -145,14 +181,16 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
145
181
 
146
182
  ## When to use alternatives
147
183
 
148
- This package is intentionally a faithful, zero-dependency port of Studio's pipeline. Use it
149
- when you need deterministic, offline-friendly behavior with strong SSRF guarantees and test
150
- parity with `unsloth/studio`. For other tradeoffs, prefer:
184
+ This package keeps Studio's deterministic, zero-dependency pipeline and test parity with
185
+ `unsloth/studio`, then layers local access, third-party rendering, and other Studio-independent
186
+ behavior on top. The SSRF guard is real and thoroughly tested, but it is opt-in:
187
+ `allowPrivateAddresses` defaults to `true`, and local files are readable unless `allowLocalFiles`
188
+ is `false`. For other tradeoffs, prefer:
151
189
 
152
190
  | Need | Use |
153
191
  |---|---|
154
192
  | Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | `pi-smart-fetch` (`wreq-js` `chrome_145`) |
155
- | Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | `georgebashi/pi-web-fetch` (puppeteer + trafilatura) |
193
+ | Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | Built-in `web_render` (Jina Reader) first; `georgebashi/pi-web-fetch` (puppeteer + trafilatura) when the Reader falls short |
156
194
  | Hosted search with semantic ranking and no scraping | `Brave Search API` / `Tavily` / `Exa` via `pi-ollama-web-search` |
157
195
  | Prompt-focused page distillation to save context | `pi-web-fetch` `prompt` -> sub-agent or Claude Code `WebFetch(url,prompt)` |
158
196
  | Batch fetching many URLs concurrently | `pi-smart-fetch` `batch_web_fetch` or call `web_fetch` in parallel |
@@ -160,6 +198,25 @@ parity with `unsloth/studio`. For other tradeoffs, prefer:
160
198
  Mixing is supported: `pi install npm:pi-unsloth-webtools npm:pi-smart-fetch` lets the model
161
199
  choose the best tool per URL. No need to fork this package to add those features.
162
200
 
201
+ ## Companion: rotating exit IPs
202
+
203
+ [`pi-tor-proxy`](https://github.com/YuGiMob/pi-tor-proxy) routes pi's in-process `fetch` traffic
204
+ through Tor (it downloads and manages its own Tor binary) and gives each pi instance its own
205
+ circuit and exit IP. The search sweep and `web_render` both use `fetch`, so they leave through
206
+ the current Tor exit, and Jina rate-limits keyless Reader requests per outgoing IP —
207
+ `/tor-cycle` swaps the exit those limits are counted against, while `/tor-country` and
208
+ `/tor-exclude` constrain which exits are used.
209
+
210
+ ```sh
211
+ pi install npm:pi-unsloth-webtools npm:pi-tor-proxy
212
+ ```
213
+
214
+ `web_fetch` is not routed: it connects directly through `node:http`/`node:https` with a pinned,
215
+ validated IP and ignores the proxy variables (see the proxy note under Known differences).
216
+ Caveats: Tor mode supports Linux and macOS only, adds latency, and many search engines and
217
+ Cloudflare-fronted services challenge or block Tor exits, so cycling helps with per-IP limits but
218
+ is not a guarantee.
219
+
163
220
  ## Configuration
164
221
 
165
222
  Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project overrides global):
@@ -187,10 +244,30 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
187
244
  | `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
188
245
  | `unslothWebTools.allowPrivateAddresses` / `webFetch.allowPrivateAddresses` | `true` | Opt out to restore the resolved-IP SSRF guard: private/loopback/link-local hosts (localhost, LAN IPs) are refused again. Non-canonical numeric IP encodings stay blocked either way |
189
246
  | `unslothWebTools.allowLocalFiles` / `webFetch.allowLocalFiles` | `true` | Opt out to refuse local files in `web_fetch` and `web_search` url mode (`file://` URLs, absolute, `~/`, or `./` paths); when enabled, PDFs are extracted and HTML converted |
247
+ | `unslothWebTools.jinaApiKey` / `webRender.jinaApiKey` | none (`JINA_API_KEY` fallback) | API key for `web_render`'s Jina Reader; raises its rate limits. Settings keys win over the environment variable |
248
+
249
+ ### Settings window
250
+
251
+ `/webtools-config` opens an interactive settings window (↑↓ navigate, space toggle, q close). Settings
252
+ persist across sessions in `~/.config/pi-unsloth-webtools/config.json`, created when a setting is
253
+ first changed:
254
+
255
+ ```json
256
+ {
257
+ "webRenderEnabled": true
258
+ }
259
+ ```
260
+
261
+ | Key | Default | Description |
262
+ |---|---|---|
263
+ | `webRenderEnabled` | `true` | When `false`, the `web_render` tool is deactivated for the session; `web_search` and `web_fetch` are unaffected |
264
+
265
+ On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (falling back to
266
+ `~/.config`); on Windows it always uses `~/.config`.
190
267
 
191
268
  Tool params always win over file defaults. Search dedup also strips default ports, so `https://example.com:443/a` and `https://example.com/a` collapse.
192
269
 
193
- Environment overrides: `PI_UNSLOTH_CACHE_DIR` changes the fetch cache directory, `PI_UNSLOTH_WEBTOOLS_STATS` opts into append-only sweep stats JSONL, `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` change the global settings directory. Cache entries live 1 hour and stale copies are served only after a network failure.
270
+ Environment overrides: `PI_UNSLOTH_CACHE_DIR` changes the fetch cache directory, `PI_UNSLOTH_WEBTOOLS_STATS` opts into append-only sweep stats JSONL, `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` change the global settings directory, and `JINA_API_KEY` supplies the `web_render` key when no settings key is set. Cache entries live 1 hour and stale copies are served only after a network failure.
194
271
 
195
272
  ## Troubleshooting
196
273
 
@@ -212,7 +289,10 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
212
289
  | PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
213
290
  | Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
214
291
  | maxChars cut | `... (truncated, N chars total)` | Raise `maxChars` for the full text. |
215
- | Empty page | `(page returned no readable text)` | Page had no extractable text; JS-rendered pages need a browser tool. |
292
+ | Empty page | `(page returned no readable text)` | Page had no extractable text; try `web_render`, which renders JavaScript pages. |
293
+ | Render blocked (local file) | `Blocked: web_render cannot fetch local files.` | Use `web_fetch`, which reads local files by default. |
294
+ | Render blocked (private host) | `Blocked: refusing to fetch the non-public address ...` | `web_render` only reaches public hosts; use `web_fetch` for localhost/LAN. |
295
+ | Render failure | `Failed to render URL: ...` | Jina rejected or failed the request (rate limit, bad key, unreachable page). Retry, or check `unslothWebTools.jinaApiKey` / `JINA_API_KEY`. |
216
296
  | GitHub rewrite | `README of ... (fetched via the GitHub README API):` | Expected repo-root rewrite, not the HTML chrome. |
217
297
  | Cache fallback | `Served from cache` / `STALE cache from YYYY-MM-DD` | Network failed; output is the cached copy with its date. |
218
298
  | Wayback fallback | `Fetched from Wayback Machine snapshot (YYYY-MM-DD) for ...` | Original 404'd; output is the archived copy with its date. |
@@ -253,6 +333,8 @@ The suite ports Unsloth Studio's own tests for these tools:
253
333
  - `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
254
334
  result-health sweep (at least two engines must return well-formed results; engines
255
335
  that block or reset connections from datacenter IPs count as unhealthy, not failures)
336
+ - `test/web-render.test.ts`: Jina Reader rendering with a stubbed fetch and DNS, the public-only
337
+ guard, policy enforcement, error mapping, truncation, cancellation, and API key precedence
256
338
 
257
339
  The seams (`seams.resolve` / `seams.request` / `rawFetch`) replace the network stack
258
340
  with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host`
package/config-ui.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { readConfig, type Config } from "./config.ts";
4
+
5
+ export type ConfigToggleKey = "webRenderEnabled";
6
+
7
+ export interface ConfigRow {
8
+ key: ConfigToggleKey;
9
+ label: string;
10
+ hint: string;
11
+ enabled: boolean;
12
+ }
13
+
14
+ export function configRows(config: Config): ConfigRow[] {
15
+ return [
16
+ {
17
+ key: "webRenderEnabled",
18
+ label: "Web render",
19
+ hint: "web_render tool (Jina Reader)",
20
+ enabled: config.webRenderEnabled !== false,
21
+ },
22
+ ];
23
+ }
24
+
25
+ function padRow(theme: Theme, innerWidth: number, content: string): string {
26
+ const padded = content + " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
27
+ return theme.fg("border", "│") + padded + theme.fg("border", "│");
28
+ }
29
+
30
+ export class WebToolsConfigOverlay {
31
+ private rows: ConfigRow[] = [];
32
+ private selected = 0;
33
+
34
+ constructor(
35
+ private readonly opts: {
36
+ tui: { requestRender(force?: boolean): void };
37
+ theme: Theme;
38
+ done: () => void;
39
+ onToggle: (key: ConfigToggleKey) => Promise<void>;
40
+ },
41
+ ) {}
42
+
43
+ async load(): Promise<void> {
44
+ this.rows = configRows(await readConfig());
45
+ }
46
+
47
+ private runToggle(row: ConfigRow): void {
48
+ this.opts.tui.requestRender(true);
49
+ void this.opts
50
+ .onToggle(row.key)
51
+ .then(async () => {
52
+ this.rows = configRows(await readConfig());
53
+ this.opts.tui.requestRender(true);
54
+ })
55
+ .catch((error: unknown) => {
56
+ console.error("Failed to toggle web tools setting:", error);
57
+ });
58
+ }
59
+
60
+ private toggleSelected(): void {
61
+ const row = this.rows[this.selected];
62
+ if (!row) return;
63
+ row.enabled = !row.enabled;
64
+ this.runToggle(row);
65
+ }
66
+
67
+ handleInput(data: string): void {
68
+ if (matchesKey(data, Key.up) || data === "k") {
69
+ this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
70
+ return;
71
+ }
72
+ if (matchesKey(data, Key.down) || data === "j") {
73
+ this.selected = (this.selected + 1) % this.rows.length;
74
+ return;
75
+ }
76
+ if (matchesKey(data, Key.space) || matchesKey(data, Key.enter) || data === " " || data === "\r" || data === "\n") {
77
+ this.toggleSelected();
78
+ return;
79
+ }
80
+ if (matchesKey(data, Key.escape) || data === "q") {
81
+ this.opts.done();
82
+ }
83
+ }
84
+
85
+ invalidate(): void {}
86
+
87
+ render(width: number): string[] {
88
+ const theme = this.opts.theme;
89
+ const innerWidth = width - 2;
90
+ const lines: string[] = [];
91
+ lines.push(theme.fg("border", `╭${"─".repeat(innerWidth)}╮`));
92
+ lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Web Tools Config"))}`));
93
+ lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
94
+ this.rows.forEach((row, index) => {
95
+ const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
96
+ const box = row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
97
+ const label = index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
98
+ lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
99
+ });
100
+ lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
101
+ lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · q close")));
102
+ lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
103
+ return lines;
104
+ }
105
+ }
package/config.ts ADDED
@@ -0,0 +1,207 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const CONFIG_DIR_NAME = "pi-unsloth-webtools";
7
+ const TEMP_PREFIX = ".tmp-";
8
+ const CONFIG_LOCK_DELAY_MS = 25;
9
+ const CONFIG_LOCK_STALE_MS = 5000;
10
+ const CONFIG_LOCK_RETRIES = Math.ceil(CONFIG_LOCK_STALE_MS / CONFIG_LOCK_DELAY_MS) * 2;
11
+
12
+ export interface Config {
13
+ webRenderEnabled: boolean;
14
+ }
15
+
16
+ const DEFAULT_CONFIG: Config = {
17
+ webRenderEnabled: true,
18
+ };
19
+
20
+ function homeBase(): string {
21
+ const envHome = process.env.HOME;
22
+ return envHome && envHome.length > 0 ? envHome : homedir();
23
+ }
24
+
25
+ function configBase(): string {
26
+ if (process.platform !== "win32") {
27
+ const xdg = process.env.XDG_CONFIG_HOME;
28
+ if (xdg && xdg.length > 0) return xdg;
29
+ }
30
+ return join(homeBase(), ".config");
31
+ }
32
+
33
+ export function configDir(): string {
34
+ return join(configBase(), CONFIG_DIR_NAME);
35
+ }
36
+
37
+ export function configPath(): string {
38
+ return join(configDir(), "config.json");
39
+ }
40
+
41
+ function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ function errCode(error: unknown): string | undefined {
46
+ if (typeof error !== "object" || error === null) return undefined;
47
+ const code = (error as { code?: unknown }).code;
48
+ return typeof code === "string" ? code : undefined;
49
+ }
50
+
51
+ function parseConfig(content: string): Config {
52
+ const parsed = JSON.parse(content) as unknown;
53
+ if (!isRecord(parsed) || (parsed.webRenderEnabled !== undefined && typeof parsed.webRenderEnabled !== "boolean")) {
54
+ throw new Error("config.json must be an object with a boolean webRenderEnabled field");
55
+ }
56
+ return {
57
+ webRenderEnabled:
58
+ typeof parsed.webRenderEnabled === "boolean" ? parsed.webRenderEnabled : DEFAULT_CONFIG.webRenderEnabled,
59
+ };
60
+ }
61
+
62
+ async function loadConfigFile(): Promise<{ config: Config; corrupted: boolean }> {
63
+ let content: string;
64
+ try {
65
+ content = await readFile(configPath(), "utf-8");
66
+ } catch (error: unknown) {
67
+ if (errCode(error) === "ENOENT") return { config: { ...DEFAULT_CONFIG }, corrupted: false };
68
+ console.error("Config file unreadable, using defaults:", error);
69
+ return { config: { ...DEFAULT_CONFIG }, corrupted: false };
70
+ }
71
+ try {
72
+ return { config: parseConfig(content), corrupted: false };
73
+ } catch (error: unknown) {
74
+ try {
75
+ const badPath = configPath();
76
+ await rename(badPath, `${badPath}.corrupt-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`);
77
+ } catch {}
78
+ console.error("Config file corrupted, quarantined, using defaults:", error);
79
+ return { config: { ...DEFAULT_CONFIG }, corrupted: true };
80
+ }
81
+ }
82
+
83
+ export async function readConfig(): Promise<Config> {
84
+ return (await loadConfigFile()).config;
85
+ }
86
+
87
+ export async function readConfigWithStatus(): Promise<{ config: Config; corrupted: boolean }> {
88
+ return loadConfigFile();
89
+ }
90
+
91
+ async function syncDir(dir: string): Promise<void> {
92
+ if (process.platform === "win32") return;
93
+ try {
94
+ const handle = await open(dir, "r");
95
+ try {
96
+ await handle.sync();
97
+ } finally {
98
+ await handle.close();
99
+ }
100
+ } catch {}
101
+ }
102
+
103
+ export async function writeConfig(config: Config): Promise<void> {
104
+ const path = configPath();
105
+ const dir = dirname(path);
106
+ await mkdir(dir, { recursive: true, mode: 0o700 });
107
+ const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}`);
108
+ const content = JSON.stringify(config, null, 2);
109
+ await writeFile(tempPath, content, { mode: 0o600 });
110
+ try {
111
+ await rename(tempPath, path);
112
+ await syncDir(dir);
113
+ } catch (error: unknown) {
114
+ if (process.platform === "win32" && errCode(error) === "EPERM") {
115
+ try {
116
+ await writeFile(path, content, "utf-8");
117
+ return;
118
+ } finally {
119
+ try {
120
+ await rm(tempPath, { force: true });
121
+ } catch {}
122
+ }
123
+ }
124
+ try {
125
+ await rm(tempPath, { force: true });
126
+ } catch {}
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ interface ConfigLock {
132
+ path: string;
133
+ dev: number;
134
+ ino: number;
135
+ }
136
+
137
+ async function acquireConfigLock(lockPath: string): Promise<ConfigLock> {
138
+ try {
139
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
140
+ } catch {}
141
+ for (let attempt = 0; attempt < CONFIG_LOCK_RETRIES; attempt++) {
142
+ try {
143
+ await mkdir(lockPath, { mode: 0o700 });
144
+ } catch (error: unknown) {
145
+ if (errCode(error) === "ENOENT") {
146
+ try {
147
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
148
+ } catch {}
149
+ continue;
150
+ }
151
+ if (errCode(error) !== "EEXIST") throw error;
152
+ try {
153
+ const lockStats = await stat(lockPath);
154
+ if (Date.now() - lockStats.mtimeMs > CONFIG_LOCK_STALE_MS) {
155
+ await rm(lockPath, { recursive: true, force: true });
156
+ continue;
157
+ }
158
+ } catch {}
159
+ await new Promise<void>((resolve) => setTimeout(resolve, CONFIG_LOCK_DELAY_MS));
160
+ continue;
161
+ }
162
+ try {
163
+ const lockStats = await stat(lockPath);
164
+ return { path: lockPath, dev: lockStats.dev, ino: lockStats.ino };
165
+ } catch (error: unknown) {
166
+ if (errCode(error) !== "ENOENT") {
167
+ try {
168
+ await rm(lockPath, { recursive: true, force: true });
169
+ } catch {}
170
+ }
171
+ continue;
172
+ }
173
+ }
174
+ throw new Error(`[E_ACCESS] Could not acquire config lock: ${lockPath}`);
175
+ }
176
+
177
+ async function releaseConfigLock(lock: ConfigLock): Promise<void> {
178
+ try {
179
+ const lockStats = await stat(lock.path);
180
+ if (lockStats.dev !== lock.dev || lockStats.ino !== lock.ino) return;
181
+ } catch {
182
+ return;
183
+ }
184
+ try {
185
+ await rm(lock.path, { recursive: true, force: true });
186
+ } catch {}
187
+ }
188
+
189
+ export async function updateConfig(mut: (config: Config) => void): Promise<Config> {
190
+ const path = configPath();
191
+ const lock = await acquireConfigLock(`${path}.lock`);
192
+ try {
193
+ const config = await readConfig();
194
+ mut(config);
195
+ await writeConfig(config);
196
+ return config;
197
+ } finally {
198
+ await releaseConfigLock(lock);
199
+ }
200
+ }
201
+
202
+ export async function toggleWebRender(): Promise<boolean> {
203
+ const config = await updateConfig((current) => {
204
+ current.webRenderEnabled = !(current.webRenderEnabled === true);
205
+ });
206
+ return config.webRenderEnabled === true;
207
+ }
package/index.ts CHANGED
@@ -1,8 +1,21 @@
1
- import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { defineTool, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import { collapseWhitespace } from "./html-to-md.ts";
3
4
  import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.ts";
4
5
  import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
5
- import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs } from "./settings.ts";
6
+ import { renderPageText as defaultRenderPageText } from "./web-render.ts";
7
+ import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadJinaApiKey } from "./settings.ts";
8
+ import { readConfigWithStatus, toggleWebRender } from "./config.ts";
9
+ import { WebToolsConfigOverlay } from "./config-ui.ts";
10
+
11
+ function toolCallLine(theme: Theme, name: string, detail: string) {
12
+ const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
13
+ return { render: () => [line], invalidate: () => {} };
14
+ }
15
+
16
+ function collapsedArg(value: unknown): string {
17
+ return collapseWhitespace(typeof value === "string" ? value : "");
18
+ }
6
19
 
7
20
  function positiveNumber(value: unknown): number | undefined {
8
21
  if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
@@ -67,14 +80,31 @@ const WebFetchParams = Type.Object({
67
80
  ),
68
81
  });
69
82
 
83
+ const WebRenderParams = Type.Object({
84
+ url: Type.String({ description: "Public URL of the page to render" }),
85
+ maxChars: Type.Optional(
86
+ Type.Number({
87
+ description: "Truncate the returned content to this many characters (default: no limit)",
88
+ }),
89
+ ),
90
+ timeoutMs: Type.Optional(
91
+ Type.Number({
92
+ minimum: 1000,
93
+ description: "Overall timeout in milliseconds (default: 60000)",
94
+ }),
95
+ ),
96
+ });
97
+
70
98
  export interface WebToolsDeps {
71
99
  fetchPageText?: typeof defaultFetchPageText;
72
100
  webSearch?: typeof defaultWebSearch;
101
+ renderPageText?: typeof defaultRenderPageText;
73
102
  }
74
103
 
75
104
  export function createWebTools(deps: WebToolsDeps = {}) {
76
105
  const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
77
106
  const webSearch = deps.webSearch ?? defaultWebSearch;
107
+ const renderPageText = deps.renderPageText ?? defaultRenderPageText;
78
108
  return {
79
109
  webSearchTool: defineTool({
80
110
  name: "web_search",
@@ -87,6 +117,11 @@ export function createWebTools(deps: WebToolsDeps = {}) {
87
117
  'Use web_search with the url parameter (e.g. {"url": "<URL>"}) to read the full text of a page found in search results.',
88
118
  ],
89
119
  parameters: WebSearchParams,
120
+ renderCall(args, theme) {
121
+ const url = collapsedArg(args.url);
122
+ const query = collapsedArg(args.query);
123
+ return toolCallLine(theme, "web_search", url || (query ? `"${query}"` : ""));
124
+ },
90
125
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
91
126
  if (params.url?.trim()) {
92
127
  const url = params.url.trim();
@@ -135,6 +170,9 @@ export function createWebTools(deps: WebToolsDeps = {}) {
135
170
  "webFetch.allowLocalFiles: false in settings. The download size is capped.",
136
171
  promptSnippet: "Fetch a web page and return readable text content",
137
172
  parameters: WebFetchParams,
173
+ renderCall(args, theme) {
174
+ return toolCallLine(theme, "web_fetch", collapsedArg(args.url));
175
+ },
138
176
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
139
177
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
140
178
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
@@ -149,11 +187,91 @@ export function createWebTools(deps: WebToolsDeps = {}) {
149
187
  return { content: [{ type: "text", text }], details: {} };
150
188
  },
151
189
  }),
190
+ webRenderTool: defineTool({
191
+ name: "web_render",
192
+ label: "Web Render",
193
+ description:
194
+ "Render a public web page to Markdown through the third-party Jina Reader (r.jina.ai). " +
195
+ "Use it for JavaScript-rendered pages that web_fetch cannot read. " +
196
+ "The target URL is sent to Jina; local files and non-public addresses are refused. " +
197
+ "An optional JINA_API_KEY or unslothWebTools.jinaApiKey raises the Reader's rate limits.",
198
+ promptSnippet: "Render a JavaScript-rendered page to Markdown via the Jina Reader",
199
+ promptGuidelines: [
200
+ 'Use web_render when web_fetch reports "(page returned no readable text)" or the page only fills in through JavaScript.',
201
+ ],
202
+ parameters: WebRenderParams,
203
+ renderCall(args, theme) {
204
+ return toolCallLine(theme, "web_render", collapsedArg(args.url));
205
+ },
206
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
207
+ onUpdate?.({ content: [{ type: "text", text: `Rendering ${params.url}...` }], details: {} });
208
+ const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
209
+ const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
210
+ const apiKey = await loadJinaApiKey(cwd);
211
+ const text = await renderPageText(params.url, {
212
+ timeoutMs,
213
+ maxChars,
214
+ signal: signal ?? undefined,
215
+ apiKey,
216
+ });
217
+ return { content: [{ type: "text", text }], details: {} };
218
+ },
219
+ }),
152
220
  };
153
221
  }
154
222
 
155
223
  export default function (pi: ExtensionAPI) {
156
- const { webSearchTool, webFetchTool } = createWebTools();
224
+ const { webSearchTool, webFetchTool, webRenderTool } = createWebTools();
157
225
  pi.registerTool(webSearchTool);
158
226
  pi.registerTool(webFetchTool);
227
+ pi.registerTool(webRenderTool);
228
+
229
+ pi.on("session_start", async (_event, ctx) => {
230
+ try {
231
+ const { config, corrupted } = await readConfigWithStatus();
232
+ if (corrupted && ctx.hasUI) {
233
+ ctx.ui.notify("Web tools config was corrupt and was reset to defaults", "warning");
234
+ }
235
+ if (!config.webRenderEnabled) {
236
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "web_render"));
237
+ }
238
+ } catch (error) {
239
+ console.error("Failed to load web tools config:", error);
240
+ }
241
+ });
242
+
243
+ pi.registerCommand("webtools-config", {
244
+ description: "Open the web tools settings window (web_render on/off)",
245
+ handler: async (_args, ctx) => {
246
+ if (!ctx.hasUI) {
247
+ ctx.ui.notify("/webtools-config requires interactive mode", "error");
248
+ return;
249
+ }
250
+ await ctx.ui.custom<void>(
251
+ async (tui, theme, _keybindings, done) => {
252
+ const overlay = new WebToolsConfigOverlay({
253
+ tui,
254
+ theme,
255
+ done,
256
+ onToggle: async (key) => {
257
+ if (key !== "webRenderEnabled") return;
258
+ const enabled = await toggleWebRender();
259
+ const active = pi.getActiveTools();
260
+ pi.setActiveTools(
261
+ enabled
262
+ ? [...new Set([...active, "web_render"])]
263
+ : active.filter((name) => name !== "web_render"),
264
+ );
265
+ },
266
+ });
267
+ await overlay.load();
268
+ return overlay;
269
+ },
270
+ {
271
+ overlay: true,
272
+ overlayOptions: { anchor: "center", width: "90%", minWidth: 60, maxHeight: "90%" },
273
+ },
274
+ );
275
+ },
276
+ });
159
277
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
- "description": "Pi extension: web_search and web_fetch tools ported from the Unsloth Studio codebase (DuckDuckGo search, SSRF-safe direct fetching, HTML-to-Markdown extraction)",
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",
7
7
  "repository": {
8
8
  "type": "git",
@@ -23,8 +23,11 @@
23
23
  "license": "AGPL-3.0",
24
24
  "files": [
25
25
  "index.ts",
26
+ "config.ts",
27
+ "config-ui.ts",
26
28
  "web-search.ts",
27
29
  "web-fetch.ts",
30
+ "web-render.ts",
28
31
  "web-access.ts",
29
32
  "html-to-md.ts",
30
33
  "engines.ts",
@@ -44,6 +47,7 @@
44
47
  },
45
48
  "peerDependencies": {
46
49
  "@earendil-works/pi-coding-agent": "*",
50
+ "@earendil-works/pi-tui": "*",
47
51
  "typebox": "*"
48
52
  },
49
53
  "engines": {
package/settings.ts CHANGED
@@ -49,6 +49,21 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
49
49
  return undefined;
50
50
  }
51
51
 
52
+ function pickString(data: Record<string, unknown>, paths: string[][]): string | undefined {
53
+ for (const path of paths) {
54
+ let cur: unknown = data;
55
+ for (const key of path) {
56
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
57
+ else {
58
+ cur = undefined;
59
+ break;
60
+ }
61
+ }
62
+ if (typeof cur === "string" && cur.trim()) return cur.trim();
63
+ }
64
+ return undefined;
65
+ }
66
+
52
67
  const MAX_RESULTS = 5;
53
68
 
54
69
  const MAX_RESULTS_PATHS: string[][] = [
@@ -79,6 +94,11 @@ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
79
94
  ["webFetch", "allowLocalFiles"],
80
95
  ];
81
96
 
97
+ const JINA_API_KEY_PATHS: string[][] = [
98
+ ["unslothWebTools", "jinaApiKey"],
99
+ ["webRender", "jinaApiKey"],
100
+ ];
101
+
82
102
  function clampMaxResults(value: number): number {
83
103
  return Math.min(20, Math.max(1, value));
84
104
  }
@@ -148,3 +168,14 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
148
168
  }
149
169
  return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
150
170
  }
171
+
172
+ export async function loadJinaApiKey(cwd?: string): Promise<string | undefined> {
173
+ let result: string | undefined;
174
+ for (const data of await settingsEntries(cwd)) {
175
+ const candidate = pickString(data, JINA_API_KEY_PATHS);
176
+ if (candidate !== undefined) result = candidate;
177
+ }
178
+ if (result !== undefined) return result;
179
+ const env = process.env.JINA_API_KEY?.trim();
180
+ return env ? env : undefined;
181
+ }
package/web-fetch.ts CHANGED
@@ -557,7 +557,7 @@ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
557
557
  });
558
558
  }
559
559
 
560
- async function resolveAndValidate(
560
+ export async function resolveAndValidateHost(
561
561
  hostname: string,
562
562
  signal?: AbortSignal,
563
563
  allowPrivateAddresses = false,
@@ -824,7 +824,7 @@ export async function fetchUrlRaw(
824
824
  const maxBytes = options.maxBytes ?? MAX_FETCH_BYTES;
825
825
  const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
826
826
  const seams = options.seams ?? {};
827
- const resolveHost = seams.resolve ?? resolveAndValidate;
827
+ const resolveHost = seams.resolve ?? resolveAndValidateHost;
828
828
  const allowPrivateAddresses = options.allowPrivateAddresses ?? true;
829
829
  const performRequest = seams.request ?? requestHop;
830
830
  const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
@@ -1256,7 +1256,7 @@ async function fetchWaybackSnapshot(
1256
1256
  }
1257
1257
  const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/;
1258
1258
 
1259
- function parseLocalPath(url: string): string | null {
1259
+ export function parseLocalPath(url: string): string | null {
1260
1260
  const trimmed = url.trim();
1261
1261
  if (/^file:/i.test(trimmed)) {
1262
1262
  try {
package/web-render.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { collapseWhitespace } from "./html-to-md.ts";
2
+ import { checkUrlAccess, MAX_SIGNAL_TIMEOUT_MS, normalizeUrlScheme, type WebsitePolicy } from "./web-access.ts";
3
+ import { parseLocalPath, resolveAndValidateHost, truncatePageText } from "./web-fetch.ts";
4
+
5
+ const DEFAULT_RENDER_TIMEOUT_MS = 60_000;
6
+ const JINA_READER_URL = "https://r.jina.ai/";
7
+ const CANCELLED_MESSAGE = "Failed to render URL: cancelled.";
8
+ const TIMED_OUT_MESSAGE = "Failed to render URL: timed out.";
9
+ const LOCAL_FILE_MESSAGE = "Blocked: web_render cannot fetch local files.";
10
+ const EMPTY_READER_MESSAGE = "Failed to render URL: the Jina Reader returned no content.";
11
+
12
+ export interface RenderPageOptions {
13
+ timeoutMs?: number;
14
+ maxChars?: number;
15
+ signal?: AbortSignal;
16
+ websitePolicy?: WebsitePolicy | null;
17
+ apiKey?: string;
18
+ }
19
+
20
+ interface JinaReaderData {
21
+ title?: string;
22
+ url?: string;
23
+ content?: string;
24
+ }
25
+
26
+ interface JinaReaderPayload {
27
+ data?: JinaReaderData;
28
+ }
29
+
30
+ function clampTimeout(value: number | undefined): number {
31
+ if (value === undefined || !Number.isFinite(value)) return DEFAULT_RENDER_TIMEOUT_MS;
32
+ return Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, Math.floor(value)));
33
+ }
34
+
35
+ function budgetMessage(caller: AbortSignal | undefined, signal: AbortSignal): string | null {
36
+ if (caller?.aborted) return CANCELLED_MESSAGE;
37
+ if (signal.aborted) return TIMED_OUT_MESSAGE;
38
+ return null;
39
+ }
40
+
41
+ async function readerErrorDetail(response: Response): Promise<string> {
42
+ try {
43
+ const body = (await response.json()) as { readableMessage?: unknown; message?: unknown; detail?: unknown };
44
+ const detail = [body.readableMessage, body.message, body.detail].find(
45
+ (value) => typeof value === "string" && value.trim().length > 0,
46
+ );
47
+ if (detail) return `${response.status} ${detail}`;
48
+ } catch {}
49
+ return `${response.status} ${response.statusText}`.trim();
50
+ }
51
+
52
+ function formatRenderedPage(data: JinaReaderData, fallbackUrl: string, maxChars: number | undefined): string {
53
+ const title = collapseWhitespace(String(data.title ?? ""));
54
+ const finalUrl = collapseWhitespace(String(data.url ?? "")) || fallbackUrl;
55
+ const content = String(data.content ?? "").trim();
56
+ if (!content) return truncatePageText("", maxChars);
57
+ const header: string[] = [];
58
+ if (title) header.push(`Title: ${title}`);
59
+ header.push(`URL: ${finalUrl}`);
60
+ header.push("Rendered via the Jina Reader (r.jina.ai).");
61
+ return truncatePageText(`${header.join("\n")}\n\n${content}`, maxChars);
62
+ }
63
+
64
+ export async function renderPageText(url: string, options: RenderPageOptions = {}): Promise<string> {
65
+ const target = typeof url === "string" ? url.trim() : "";
66
+ const policy = options.websitePolicy ?? null;
67
+ if (!target) return checkUrlAccess("", policy)[1];
68
+ if (parseLocalPath(target) !== null) return LOCAL_FILE_MESSAGE;
69
+ const normalized = normalizeUrlScheme(target);
70
+ const [allowed, reason, hostname] = checkUrlAccess(normalized, policy);
71
+ if (!allowed) return reason;
72
+ const timeoutSignal = AbortSignal.timeout(clampTimeout(options.timeoutMs));
73
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
74
+ const resolved = await resolveAndValidateHost(hostname, signal, false);
75
+ const resolutionBudget = budgetMessage(options.signal, signal);
76
+ if (resolutionBudget !== null) return resolutionBudget;
77
+ if (!resolved.ok) return resolved.reason;
78
+ const headers: Record<string, string> = { Accept: "application/json" };
79
+ const apiKey = options.apiKey?.trim();
80
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
81
+ let response: Response;
82
+ try {
83
+ response = await fetch(JINA_READER_URL + encodeURIComponent(normalized), { headers, signal });
84
+ } catch (err) {
85
+ const budget = budgetMessage(options.signal, signal);
86
+ if (budget !== null) return budget;
87
+ return `Failed to render URL: ${err instanceof Error ? err.message : String(err)}`;
88
+ }
89
+ if (!response.ok) return `Failed to render URL: ${await readerErrorDetail(response)}`;
90
+ let payload: JinaReaderPayload;
91
+ try {
92
+ payload = (await response.json()) as JinaReaderPayload;
93
+ } catch {
94
+ const budget = budgetMessage(options.signal, signal);
95
+ if (budget !== null) return budget;
96
+ return `Failed to render URL: the Jina Reader returned a non-JSON response (HTTP ${response.status}).`;
97
+ }
98
+ if (!payload || !payload.data) return EMPTY_READER_MESSAGE;
99
+ return formatRenderedPage(payload.data, normalized, options.maxChars);
100
+ }