pi-unsloth-webtools 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/cache.ts +9 -1
- package/engines.ts +29 -9
- package/index.ts +7 -4
- package/package.json +10 -4
- package/pdf.ts +2 -1
- package/user-agents.ts +6 -7
- package/web-access.ts +2 -0
- package/web-fetch.ts +21 -4
- package/web-search.ts +3 -2
package/README.md
CHANGED
|
@@ -174,10 +174,35 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
|
|
|
174
174
|
|---|---|---|
|
|
175
175
|
| `unslothWebTools.maxResults` | `5` | Default `maxResults` for `web_search` (clamped 1-20) |
|
|
176
176
|
| `unslothWebTools.maxChars` / `webFetch.maxChars` / `smartFetchDefaultMaxChars` | tool param | Default `maxChars` for `web_fetch` and `web_search` url mode |
|
|
177
|
-
| `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` | Default `timeoutMs` (>=1000) |
|
|
177
|
+
| `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` fetch, `300000` search | Default `timeoutMs` when the tool param is absent (>=1000). Fetch and `web_search` url mode fall back to 60000; `web_search` query mode falls back to 300000 |
|
|
178
178
|
| `webSearch.maxResults` / `smartWebSearch.resultsPerQuery` | same as above | Legacy aliases for `maxResults` |
|
|
179
|
+
| `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
|
|
179
180
|
|
|
180
|
-
Tool params always win over file defaults.
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
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.
|
|
184
|
+
|
|
185
|
+
## Troubleshooting
|
|
186
|
+
|
|
187
|
+
Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
|
|
188
|
+
|
|
189
|
+
| Symptom | Exact string | Action |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| Empty query | `No query provided.` | Pass `query` or `url`. |
|
|
192
|
+
| Cancelled | `Search cancelled.` / `Failed to fetch URL: cancelled.` | Retry only if the abort was not intentional. |
|
|
193
|
+
| Search timeout | `Search failed: the search engines (...) did not respond within N seconds.` | Retry, narrow the query, or raise `timeoutMs`. |
|
|
194
|
+
| No results | `No results found.` | Rephrase the query. |
|
|
195
|
+
| Policy filtered everything | `No results found within the website access limits.` | Widen `websitePolicy`, do not work around it. |
|
|
196
|
+
| Blocked URL | `Blocked: ...` | Respect it. Includes non-http schemes, credentials, invalid hosts, non-public IPs, and policy denials. |
|
|
197
|
+
| HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot. |
|
|
198
|
+
| Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
|
|
199
|
+
| PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
|
|
200
|
+
| Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
|
|
201
|
+
| maxChars cut | `... (truncated, N chars total)` | Raise `maxChars` for the full text. |
|
|
202
|
+
| Empty page | `(page returned no readable text)` | Page had no extractable text; JS-rendered pages need a browser tool. |
|
|
203
|
+
| GitHub rewrite | `README of ... (fetched via the GitHub README API):` | Expected repo-root rewrite, not the HTML chrome. |
|
|
204
|
+
| Cache fallback | `Served from cache` / `STALE cache from YYYY-MM-DD` | Network failed; output is the cached copy with its date. |
|
|
205
|
+
| Wayback fallback | `Fetched from Wayback Machine snapshot (YYYY-MM-DD) for ...` | Original 404'd; output is the archived copy with its date. |
|
|
181
206
|
|
|
182
207
|
## Development
|
|
183
208
|
|
package/cache.ts
CHANGED
|
@@ -98,9 +98,17 @@ async function prune(): Promise<void> {
|
|
|
98
98
|
} catch {
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const tmpFiles = files.filter((f) => f.includes(".tmp."));
|
|
103
|
+
for (const file of tmpFiles) {
|
|
104
|
+
try {
|
|
105
|
+
const full = join(dir, file);
|
|
106
|
+
const s = await stat(full);
|
|
107
|
+
if (now - s.mtimeMs >= CACHE_TTL_MS) await unlinkSafe(full);
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
101
110
|
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
102
111
|
if (!jsonFiles.length) return;
|
|
103
|
-
const now = Date.now();
|
|
104
112
|
const entries: { file: string; size: number; timestamp: number }[] = [];
|
|
105
113
|
let total = 0;
|
|
106
114
|
for (const file of jsonFiles) {
|
package/engines.ts
CHANGED
|
@@ -82,6 +82,9 @@ export function canonicalizeHref(href: string): string {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
url.hash = "";
|
|
85
|
+
if ((url.protocol === "http:" && url.port === "80") || (url.protocol === "https:" && url.port === "443")) {
|
|
86
|
+
url.port = "";
|
|
87
|
+
}
|
|
85
88
|
return url.toString();
|
|
86
89
|
} catch {
|
|
87
90
|
return href;
|
|
@@ -954,9 +957,9 @@ export async function autoTextSearch(
|
|
|
954
957
|
const timedOutProviders = new Set<string>();
|
|
955
958
|
let cancelled = false;
|
|
956
959
|
const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
|
|
957
|
-
const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults /
|
|
960
|
+
const maxWorkers = Math.min(uniqueProviders, Math.max(4, Math.ceil(maxResults / 5) + 1));
|
|
958
961
|
let i = 0;
|
|
959
|
-
|
|
962
|
+
const pending = new Set<Promise<void>>();
|
|
960
963
|
const run = async (engine: Engine) => {
|
|
961
964
|
let results: SearchResult[] | null = null;
|
|
962
965
|
for (let attempt = 0; attempt < 2 && results === null; attempt++) {
|
|
@@ -1000,18 +1003,35 @@ export async function autoTextSearch(
|
|
|
1000
1003
|
if (aggregator.size >= maxResults) controller.abort();
|
|
1001
1004
|
}
|
|
1002
1005
|
};
|
|
1003
|
-
while (i < engines.length) {
|
|
1006
|
+
while (i < engines.length || pending.size > 0) {
|
|
1004
1007
|
if (aggregator.size >= maxResults || cancelled) {
|
|
1005
1008
|
controller.abort();
|
|
1006
1009
|
break;
|
|
1007
1010
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1011
|
+
while (i < engines.length && pending.size < maxWorkers) {
|
|
1012
|
+
if (aggregator.size >= maxResults || cancelled) {
|
|
1013
|
+
controller.abort();
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
const engine = engines[i++];
|
|
1017
|
+
if (seenProviders.has(engine.provider)) continue;
|
|
1018
|
+
const task = run(engine);
|
|
1019
|
+
pending.add(task);
|
|
1020
|
+
void task.then(
|
|
1021
|
+
() => {
|
|
1022
|
+
pending.delete(task);
|
|
1023
|
+
},
|
|
1024
|
+
() => {
|
|
1025
|
+
pending.delete(task);
|
|
1026
|
+
},
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (pending.size === 0) break;
|
|
1030
|
+
if (aggregator.size >= maxResults || cancelled) {
|
|
1031
|
+
controller.abort();
|
|
1032
|
+
break;
|
|
1014
1033
|
}
|
|
1034
|
+
await Promise.race(pending);
|
|
1015
1035
|
}
|
|
1016
1036
|
await Promise.allSettled(pending);
|
|
1017
1037
|
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
package/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { webSearch as defaultWebSearch } from "./web-search.ts";
|
|
3
|
+
import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.ts";
|
|
4
4
|
import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
|
|
5
|
-
import { loadDefaultFetchSettings } from "./settings.ts";
|
|
5
|
+
import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs } from "./settings.ts";
|
|
6
6
|
|
|
7
7
|
function positiveNumber(value: unknown): number | undefined {
|
|
8
8
|
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
@@ -107,11 +107,14 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
109
|
onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
|
|
110
|
+
const timeoutParam = positiveNumber(params.timeoutMs);
|
|
111
|
+
const searchCwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
112
|
+
const searchTimeoutMs = timeoutParam ?? (await loadDefaultFetchTimeoutMs(searchCwd)) ?? SEARCH_TIMEOUT_MS;
|
|
110
113
|
const text = await webSearch(params.query, {
|
|
111
114
|
signal: signal ?? undefined,
|
|
112
|
-
timeoutMs:
|
|
115
|
+
timeoutMs: searchTimeoutMs,
|
|
113
116
|
maxResults: positiveNumber(params.maxResults),
|
|
114
|
-
cwd:
|
|
117
|
+
cwd: searchCwd,
|
|
115
118
|
});
|
|
116
119
|
return { content: [{ type: "text", text }], details: {} };
|
|
117
120
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-unsloth-webtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
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)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -55,16 +55,22 @@
|
|
|
55
55
|
"test:smoke": "vitest run test/smoke.test.ts",
|
|
56
56
|
"typecheck": "tsc --noEmit",
|
|
57
57
|
"check:package": "node -e \"const fs=require('fs');const pkg=require('./package.json');const listed=new Set(pkg.files);const bad=[];for(const f of pkg.files){if(!fs.existsSync(f))bad.push('missing file: '+f)}for(const f of fs.readdirSync('.').filter(f=>f.endsWith('.ts'))){if(!listed.has(f))bad.push('unlisted source: '+f)}if(bad.length){console.error(bad.join('\\n'));process.exit(1)}\"",
|
|
58
|
-
"prepublishOnly": "npm run typecheck && npm run test:unit && npm run check:package"
|
|
58
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm run test:unit && npm run check:package && npm run check:publint",
|
|
59
|
+
"lint": "oxlint .",
|
|
60
|
+
"check:publint": "publint",
|
|
61
|
+
"check:attw": "attw --pack"
|
|
59
62
|
},
|
|
60
63
|
"devDependencies": {
|
|
61
64
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
62
65
|
"@types/node": "^24.0.0",
|
|
63
66
|
"typebox": "^1.3.7",
|
|
64
67
|
"typescript": "~5.9.3",
|
|
65
|
-
"vitest": "^4.1.9"
|
|
68
|
+
"vitest": "^4.1.9",
|
|
69
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
70
|
+
"oxlint": "^1.81.0",
|
|
71
|
+
"publint": "^0.3.24"
|
|
66
72
|
},
|
|
67
|
-
"
|
|
73
|
+
"optionalDependencies": {
|
|
68
74
|
"mupdf": "^1.28.0"
|
|
69
75
|
}
|
|
70
76
|
}
|
package/pdf.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { inflateSync } from "node:zlib";
|
|
3
3
|
|
|
4
4
|
export const MAX_WEB_PDF_PAGES = 50;
|
|
5
|
+
const MAX_PDF_INFLATE_BYTES = 64 * 1024 * 1024;
|
|
5
6
|
|
|
6
7
|
export class PdfParseError extends Error {
|
|
7
8
|
constructor() {
|
|
@@ -786,7 +787,7 @@ async function extractPdfTextFallback(data: Buffer): Promise<string> {
|
|
|
786
787
|
let bytes = raw;
|
|
787
788
|
if (isFlate) {
|
|
788
789
|
try {
|
|
789
|
-
bytes = inflateSync(raw);
|
|
790
|
+
bytes = inflateSync(raw, { maxOutputLength: MAX_PDF_INFLATE_BYTES });
|
|
790
791
|
} catch {
|
|
791
792
|
continue;
|
|
792
793
|
}
|
package/user-agents.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
const USER_AGENTS = [
|
|
2
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
3
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
4
|
-
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
5
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:
|
|
6
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:
|
|
7
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/
|
|
2
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
|
3
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
|
4
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
|
5
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
|
|
6
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0",
|
|
7
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
8
8
|
];
|
|
9
|
-
|
|
10
9
|
export function randomUserAgent(): string {
|
|
11
10
|
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
|
12
11
|
}
|
package/web-access.ts
CHANGED
|
@@ -381,6 +381,8 @@ function ipv4Octets(ip: string): number[] | null {
|
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
export function isPublicIp(ip: string): boolean {
|
|
384
|
+
if (ip.includes("%")) return false;
|
|
385
|
+
if (ip.includes(".") && ip.includes(":")) return false;
|
|
384
386
|
if (ip.includes(".")) {
|
|
385
387
|
const o = ipv4Octets(ip);
|
|
386
388
|
if (!o) return false;
|
package/web-fetch.ts
CHANGED
|
@@ -631,8 +631,14 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
631
631
|
headers: opts.headers,
|
|
632
632
|
timeout: opts.inactivityMs,
|
|
633
633
|
servername: url.protocol === "https:" ? url.hostname : undefined,
|
|
634
|
-
lookup: (
|
|
635
|
-
|
|
634
|
+
lookup: ((_hostname: string, _options: unknown, _callback: unknown) => {
|
|
635
|
+
const done = (typeof _options === "function" ? _options : _callback) as (err: unknown, address: unknown, family?: unknown) => void;
|
|
636
|
+
if ((_options as { all?: boolean } | undefined)?.all) {
|
|
637
|
+
done(null, [{ address: opts.pinnedIp, family: opts.family }]);
|
|
638
|
+
} else {
|
|
639
|
+
done(null, opts.pinnedIp, opts.family);
|
|
640
|
+
}
|
|
641
|
+
}) as typeof options.lookup,
|
|
636
642
|
};
|
|
637
643
|
let settled = false;
|
|
638
644
|
let resRef: IncomingMessage | null = null;
|
|
@@ -798,7 +804,7 @@ export async function fetchUrlRaw(
|
|
|
798
804
|
url: string,
|
|
799
805
|
options: RawFetchOptions = {},
|
|
800
806
|
): Promise<RawFetchResult> {
|
|
801
|
-
const timeoutMs = options.timeoutMs ??
|
|
807
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
802
808
|
const now = options.nowMs ?? Date.now;
|
|
803
809
|
const deadline = options.deadlineMs ?? now() + timeoutMs;
|
|
804
810
|
const signal = options.signal;
|
|
@@ -854,7 +860,13 @@ export async function fetchUrlRaw(
|
|
|
854
860
|
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
|
|
855
861
|
"Accept-Encoding": "identity",
|
|
856
862
|
};
|
|
857
|
-
if (options.extraHeaders)
|
|
863
|
+
if (options.extraHeaders) {
|
|
864
|
+
for (const [key, value] of Object.entries(options.extraHeaders)) {
|
|
865
|
+
if (key.toLowerCase() === "host") continue;
|
|
866
|
+
headers[key] = value;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
headers["Host"] = hostHeader;
|
|
858
870
|
const inactivity = remainingMs(deadline, now);
|
|
859
871
|
let response: HopResponse;
|
|
860
872
|
try {
|
|
@@ -1212,6 +1224,11 @@ async function fetchWaybackSnapshot(
|
|
|
1212
1224
|
return null;
|
|
1213
1225
|
}
|
|
1214
1226
|
if (!snapshotUrl) return null;
|
|
1227
|
+
const snapshotAccess = checkUrlAccess(snapshotUrl, options.websitePolicy ?? null);
|
|
1228
|
+
if (!snapshotAccess[0]) return null;
|
|
1229
|
+
const snapshotHost = snapshotAccess[2];
|
|
1230
|
+
const snapshotIsIp = snapshotHost.includes(":") || /^\d+\.\d+\.\d+\.\d+$/.test(snapshotHost);
|
|
1231
|
+
if (snapshotIsIp && !isPublicIp(snapshotHost)) return null;
|
|
1215
1232
|
if (now() >= deadlineMs) return null;
|
|
1216
1233
|
let snapResult: RawFetchResult | null = null;
|
|
1217
1234
|
try {
|
package/web-search.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
|
|
12
12
|
export { EmptySweepError, SearchCancelled, SearchTimeoutError } from "./engines.ts";
|
|
13
13
|
|
|
14
|
-
const SEARCH_TIMEOUT_MS = 300_000;
|
|
14
|
+
export const SEARCH_TIMEOUT_MS = 300_000;
|
|
15
15
|
const MAX_RESULTS = 5;
|
|
16
16
|
|
|
17
17
|
export const EMPTY_SEARCH_RESULTS = [
|
|
@@ -56,7 +56,8 @@ export async function webSearch(
|
|
|
56
56
|
const saneMax = Number.isFinite(rawMaxResults) ? Math.floor(rawMaxResults as number) : MAX_RESULTS;
|
|
57
57
|
const maxResults = Math.min(20, Math.max(1, saneMax));
|
|
58
58
|
const rawTimeout = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
|
|
59
|
-
const
|
|
59
|
+
const flooredTimeout = Number.isFinite(rawTimeout) ? Math.floor(rawTimeout as number) : SEARCH_TIMEOUT_MS;
|
|
60
|
+
const timeoutMs = flooredTimeout >= 1 ? flooredTimeout : SEARCH_TIMEOUT_MS;
|
|
60
61
|
const signal = options.signal;
|
|
61
62
|
const policy = options.websitePolicy ?? null;
|
|
62
63
|
const client = options.client ?? ddgSearch;
|