pi-unsloth-webtools 0.4.1 → 0.5.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 +22 -0
- package/engines.ts +65 -11
- package/package.json +10 -4
- package/pdf.ts +1 -1
- package/web-access.ts +4 -2
- package/web-fetch.ts +20 -3
- package/web-search.ts +11 -6
package/README.md
CHANGED
|
@@ -179,6 +179,28 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
|
|
|
179
179
|
|
|
180
180
|
Tool params always win over file defaults.
|
|
181
181
|
|
|
182
|
+
## Troubleshooting
|
|
183
|
+
|
|
184
|
+
Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
|
|
185
|
+
|
|
186
|
+
| Symptom | Exact string | Action |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| Empty query | `No query provided.` | Pass `query` or `url`. |
|
|
189
|
+
| Cancelled | `Search cancelled.` / `Failed to fetch URL: cancelled.` | Retry only if the abort was not intentional. |
|
|
190
|
+
| Search timeout | `Search failed: the search engines (...) did not respond within N seconds.` | Retry, narrow the query, or raise `timeoutMs`. |
|
|
191
|
+
| No results | `No results found.` | Rephrase the query. |
|
|
192
|
+
| Policy filtered everything | `No results found within the website access limits.` | Widen `websitePolicy`, do not work around it. |
|
|
193
|
+
| Blocked URL | `Blocked: ...` | Respect it. Includes non-http schemes, credentials, invalid hosts, non-public IPs, and policy denials. |
|
|
194
|
+
| HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot. |
|
|
195
|
+
| Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
|
|
196
|
+
| PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
|
|
197
|
+
| Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
|
|
198
|
+
| maxChars cut | `... (truncated, N chars total)` | Raise `maxChars` for the full text. |
|
|
199
|
+
| Empty page | `(page returned no readable text)` | Page had no extractable text; JS-rendered pages need a browser tool. |
|
|
200
|
+
| GitHub rewrite | `README of ... (fetched via the GitHub README API):` | Expected repo-root rewrite, not the HTML chrome. |
|
|
201
|
+
| Cache fallback | `Served from cache` / `STALE cache from YYYY-MM-DD` | Network failed; output is the cached copy with its date. |
|
|
202
|
+
| Wayback fallback | `Fetched from Wayback Machine snapshot (YYYY-MM-DD) for ...` | Original 404'd; output is the archived copy with its date. |
|
|
203
|
+
|
|
182
204
|
## Development
|
|
183
205
|
|
|
184
206
|
```sh
|
package/engines.ts
CHANGED
|
@@ -216,6 +216,10 @@ function parsePredExpr(input: string): Pred {
|
|
|
216
216
|
pos += "position()=last()".length;
|
|
217
217
|
return { op: "last" };
|
|
218
218
|
}
|
|
219
|
+
if (input.startsWith("last()", pos)) {
|
|
220
|
+
pos += "last()".length;
|
|
221
|
+
return { op: "last" };
|
|
222
|
+
}
|
|
219
223
|
if (input.startsWith("contains(@class,", pos)) {
|
|
220
224
|
pos += "contains(@class,".length;
|
|
221
225
|
const value = quoted();
|
|
@@ -359,7 +363,38 @@ function matchesPred(pred: Pred, el: DomNode, index: number, total: number): boo
|
|
|
359
363
|
}
|
|
360
364
|
}
|
|
361
365
|
|
|
366
|
+
function predContainsLast(pred: Pred): boolean {
|
|
367
|
+
switch (pred.op) {
|
|
368
|
+
case "last":
|
|
369
|
+
return true;
|
|
370
|
+
case "or":
|
|
371
|
+
case "and":
|
|
372
|
+
return predContainsLast(pred.a) || predContainsLast(pred.b);
|
|
373
|
+
case "child":
|
|
374
|
+
return pred.preds.some(predContainsLast);
|
|
375
|
+
default:
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
362
380
|
function applyStep(step: XStep, nodes: DomNode[]): DomNode[] {
|
|
381
|
+
if (step.preds.some(predContainsLast)) {
|
|
382
|
+
const out: DomNode[] = [];
|
|
383
|
+
const seen = new Set<DomNode>();
|
|
384
|
+
for (const node of nodes) {
|
|
385
|
+
const source = step.axis === "child" ? node.children : descendantsOf(node);
|
|
386
|
+
const list = source.filter((c) => !step.name || c.tag === step.name);
|
|
387
|
+
const total = list.length;
|
|
388
|
+
list.forEach((el, index) => {
|
|
389
|
+
if (seen.has(el)) return;
|
|
390
|
+
if (step.preds.every((p) => matchesPred(p, el, index, total))) {
|
|
391
|
+
seen.add(el);
|
|
392
|
+
out.push(el);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
363
398
|
const candidates: DomNode[] = [];
|
|
364
399
|
for (const node of nodes) {
|
|
365
400
|
const list = step.axis === "child" ? node.children : descendantsOf(node);
|
|
@@ -427,6 +462,7 @@ export function extractResults(
|
|
|
427
462
|
if (!data) continue;
|
|
428
463
|
result[key] = key === "href" ? normalizeUrl(data) : normalizeText(data);
|
|
429
464
|
}
|
|
465
|
+
if (!result.title && !result.href && !result.body) continue;
|
|
430
466
|
results.push(result);
|
|
431
467
|
}
|
|
432
468
|
return results;
|
|
@@ -445,8 +481,7 @@ function googleUserAgent(): string {
|
|
|
445
481
|
return (
|
|
446
482
|
`Mozilla/5.0 (Linux; Android ${androidVer}; ${device}) ` +
|
|
447
483
|
`AppleWebKit/537.36 (KHTML, like Gecko) ` +
|
|
448
|
-
`Chrome/${chromeMajor}.0.${chromeBuild}.${chromePatch} Mobile Safari/537.36`
|
|
449
|
-
"NSTNWV"
|
|
484
|
+
`Chrome/${chromeMajor}.0.${chromeBuild}.${chromePatch} Mobile Safari/537.36`
|
|
450
485
|
);
|
|
451
486
|
}
|
|
452
487
|
|
|
@@ -794,7 +829,9 @@ export class ResultsAggregator {
|
|
|
794
829
|
}
|
|
795
830
|
|
|
796
831
|
append(item: SearchResult): void {
|
|
832
|
+
if (typeof item.href !== "string" || !item.href.trim()) return;
|
|
797
833
|
const key = canonicalizeHref(item.href);
|
|
834
|
+
if (!key) return;
|
|
798
835
|
const existing = this.cache.get(key);
|
|
799
836
|
if (!existing || item.body.length > existing.body.length) {
|
|
800
837
|
this.cache.set(key, { ...item, href: key });
|
|
@@ -917,9 +954,9 @@ export async function autoTextSearch(
|
|
|
917
954
|
const timedOutProviders = new Set<string>();
|
|
918
955
|
let cancelled = false;
|
|
919
956
|
const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
|
|
920
|
-
const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults /
|
|
957
|
+
const maxWorkers = Math.min(uniqueProviders, Math.max(4, Math.ceil(maxResults / 5) + 1));
|
|
921
958
|
let i = 0;
|
|
922
|
-
|
|
959
|
+
const pending = new Set<Promise<void>>();
|
|
923
960
|
const run = async (engine: Engine) => {
|
|
924
961
|
let results: SearchResult[] | null = null;
|
|
925
962
|
for (let attempt = 0; attempt < 2 && results === null; attempt++) {
|
|
@@ -963,18 +1000,35 @@ export async function autoTextSearch(
|
|
|
963
1000
|
if (aggregator.size >= maxResults) controller.abort();
|
|
964
1001
|
}
|
|
965
1002
|
};
|
|
966
|
-
while (i < engines.length) {
|
|
1003
|
+
while (i < engines.length || pending.size > 0) {
|
|
967
1004
|
if (aggregator.size >= maxResults || cancelled) {
|
|
968
1005
|
controller.abort();
|
|
969
1006
|
break;
|
|
970
1007
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1008
|
+
while (i < engines.length && pending.size < maxWorkers) {
|
|
1009
|
+
if (aggregator.size >= maxResults || cancelled) {
|
|
1010
|
+
controller.abort();
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
const engine = engines[i++];
|
|
1014
|
+
if (seenProviders.has(engine.provider)) continue;
|
|
1015
|
+
const task = run(engine);
|
|
1016
|
+
pending.add(task);
|
|
1017
|
+
void task.then(
|
|
1018
|
+
() => {
|
|
1019
|
+
pending.delete(task);
|
|
1020
|
+
},
|
|
1021
|
+
() => {
|
|
1022
|
+
pending.delete(task);
|
|
1023
|
+
},
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
if (pending.size === 0) break;
|
|
1027
|
+
if (aggregator.size >= maxResults || cancelled) {
|
|
1028
|
+
controller.abort();
|
|
1029
|
+
break;
|
|
977
1030
|
}
|
|
1031
|
+
await Promise.race(pending);
|
|
978
1032
|
}
|
|
979
1033
|
await Promise.allSettled(pending);
|
|
980
1034
|
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-unsloth-webtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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 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
|
@@ -344,7 +344,7 @@ function findLink(links: LinkInfo[], span: SpanData): string | null {
|
|
|
344
344
|
if (midX < link.x0 || midX > link.x1 || midY < link.y0 || midY > link.y1) continue;
|
|
345
345
|
let uri = link.uri;
|
|
346
346
|
for (const c of "()\n") {
|
|
347
|
-
uri = uri.replaceAll(c, "%
|
|
347
|
+
uri = uri.replaceAll(c, "%" + c.charCodeAt(0).toString(16).padStart(2, "0"));
|
|
348
348
|
}
|
|
349
349
|
return `[${span.text.trim()}](${uri})`;
|
|
350
350
|
}
|
package/web-access.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function normalizeWebsitePolicy(value: unknown): WebsitePolicy {
|
|
|
137
137
|
}
|
|
138
138
|
const normalized: WebsitePolicy = { allowedDomains: [], blockedDomains: [] };
|
|
139
139
|
for (const key of ["allowedDomains", "blockedDomains"] as const) {
|
|
140
|
-
const rawDomains = raw[key];
|
|
140
|
+
const rawDomains = raw[key] ?? [];
|
|
141
141
|
if (!Array.isArray(rawDomains)) throw new Error(`${key} must be a list`);
|
|
142
142
|
if (rawDomains.length > MAX_DOMAINS_PER_LIST) {
|
|
143
143
|
throw new Error(`${key} supports at most ${MAX_DOMAINS_PER_LIST} domains`);
|
|
@@ -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;
|
|
@@ -408,7 +410,7 @@ export function isPublicIp(ip: string): boolean {
|
|
|
408
410
|
}
|
|
409
411
|
if (canonical === "::1" || canonical.startsWith("::")) return false;
|
|
410
412
|
if (canonical.startsWith("fc") || canonical.startsWith("fd")) return false;
|
|
411
|
-
if (/^fe[
|
|
413
|
+
if (/^fe[89a-f][0-9a-f]:/.test(canonical)) return false;
|
|
412
414
|
if (canonical.startsWith("ff")) return false;
|
|
413
415
|
if (canonical.startsWith("2001:db8")) return false;
|
|
414
416
|
if (canonical.startsWith("64:ff9b:")) return false;
|
package/web-fetch.ts
CHANGED
|
@@ -810,16 +810,22 @@ export async function fetchUrlRaw(
|
|
|
810
810
|
const performRequest = seams.request ?? requestHop;
|
|
811
811
|
const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
|
|
812
812
|
const abortController = new AbortController();
|
|
813
|
-
const
|
|
813
|
+
const waitMs = clampedRemainingMs(deadline, now);
|
|
814
|
+
const timeoutId = setTimeout(() => abortController.abort(), waitMs);
|
|
814
815
|
const resolveSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal;
|
|
816
|
+
let deadlineId: ReturnType<typeof setTimeout> | undefined;
|
|
817
|
+
const deadlinePromise = new Promise<ResolvedHost>((resolve) => {
|
|
818
|
+
deadlineId = setTimeout(() => resolve({ ok: false, reason: FETCH_TIMEOUT_MESSAGE, ip: "", family: 0 }), waitMs);
|
|
819
|
+
});
|
|
815
820
|
try {
|
|
816
|
-
const resolved = await resolveHost(hostname, resolveSignal);
|
|
821
|
+
const resolved = await Promise.race([resolveHost(hostname, resolveSignal), deadlinePromise]);
|
|
817
822
|
if (resolved.ok) return resolved;
|
|
818
823
|
if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
|
|
819
824
|
if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
|
|
820
825
|
return resolved;
|
|
821
826
|
} finally {
|
|
822
827
|
clearTimeout(timeoutId);
|
|
828
|
+
if (deadlineId !== undefined) clearTimeout(deadlineId);
|
|
823
829
|
}
|
|
824
830
|
};
|
|
825
831
|
const checkBudget = (contentType = ""): RawFetchResult | null => budgetExceededResult(deadline, signal, now, contentType);
|
|
@@ -848,7 +854,13 @@ export async function fetchUrlRaw(
|
|
|
848
854
|
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
|
|
849
855
|
"Accept-Encoding": "identity",
|
|
850
856
|
};
|
|
851
|
-
if (options.extraHeaders)
|
|
857
|
+
if (options.extraHeaders) {
|
|
858
|
+
for (const [key, value] of Object.entries(options.extraHeaders)) {
|
|
859
|
+
if (key.toLowerCase() === "host") continue;
|
|
860
|
+
headers[key] = value;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
headers["Host"] = hostHeader;
|
|
852
864
|
const inactivity = remainingMs(deadline, now);
|
|
853
865
|
let response: HopResponse;
|
|
854
866
|
try {
|
|
@@ -1206,6 +1218,11 @@ async function fetchWaybackSnapshot(
|
|
|
1206
1218
|
return null;
|
|
1207
1219
|
}
|
|
1208
1220
|
if (!snapshotUrl) return null;
|
|
1221
|
+
const snapshotAccess = checkUrlAccess(snapshotUrl, options.websitePolicy ?? null);
|
|
1222
|
+
if (!snapshotAccess[0]) return null;
|
|
1223
|
+
const snapshotHost = snapshotAccess[2];
|
|
1224
|
+
const snapshotIsIp = snapshotHost.includes(":") || /^\d+\.\d+\.\d+\.\d+$/.test(snapshotHost);
|
|
1225
|
+
if (snapshotIsIp && !isPublicIp(snapshotHost)) return null;
|
|
1209
1226
|
if (now() >= deadlineMs) return null;
|
|
1210
1227
|
let snapResult: RawFetchResult | null = null;
|
|
1211
1228
|
try {
|
package/web-search.ts
CHANGED
|
@@ -23,15 +23,16 @@ export type SearchClient = (
|
|
|
23
23
|
query: string,
|
|
24
24
|
maxResults: number,
|
|
25
25
|
signal?: AbortSignal,
|
|
26
|
+
timeoutMs?: number,
|
|
26
27
|
) => Promise<SearchResult[]>;
|
|
27
|
-
|
|
28
28
|
export async function ddgSearch(
|
|
29
29
|
query: string,
|
|
30
30
|
maxResults = MAX_RESULTS,
|
|
31
31
|
signal?: AbortSignal,
|
|
32
|
+
timeoutMs = SEARCH_TIMEOUT_MS,
|
|
32
33
|
): Promise<SearchResult[]> {
|
|
33
34
|
if (signal?.aborted) throw new SearchCancelled();
|
|
34
|
-
return autoTextSearch(query, maxResults,
|
|
35
|
+
return autoTextSearch(query, maxResults, timeoutMs, signal);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
const POLICY_OVERFETCH = 4;
|
|
@@ -51,8 +52,11 @@ export async function webSearch(
|
|
|
51
52
|
query: string | undefined,
|
|
52
53
|
options: WebSearchOptions = {},
|
|
53
54
|
): Promise<string> {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
55
|
+
const rawMaxResults = options.maxResults ?? (await loadDefaultMaxResults(options.cwd));
|
|
56
|
+
const saneMax = Number.isFinite(rawMaxResults) ? Math.floor(rawMaxResults as number) : MAX_RESULTS;
|
|
57
|
+
const maxResults = Math.min(20, Math.max(1, saneMax));
|
|
58
|
+
const rawTimeout = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
|
|
59
|
+
const timeoutMs = Number.isFinite(rawTimeout) ? (rawTimeout as number) : SEARCH_TIMEOUT_MS;
|
|
56
60
|
const signal = options.signal;
|
|
57
61
|
const policy = options.websitePolicy ?? null;
|
|
58
62
|
const client = options.client ?? ddgSearch;
|
|
@@ -66,14 +70,15 @@ export async function webSearch(
|
|
|
66
70
|
(policy?.blockedDomains?.length ?? 0) > 0,
|
|
67
71
|
);
|
|
68
72
|
const wanted = restricted ? maxResults * POLICY_OVERFETCH : maxResults;
|
|
69
|
-
const results = await client(effectiveQuery, wanted, signal);
|
|
73
|
+
const results = await client(effectiveQuery, wanted, signal, timeoutMs);
|
|
70
74
|
if (signal?.aborted) return "Search cancelled.";
|
|
71
75
|
if (!results.length) return EMPTY_SEARCH_RESULTS[0];
|
|
72
76
|
const allowed: SearchResult[] = [];
|
|
73
77
|
for (const result of results) {
|
|
74
78
|
if (allowed.length >= maxResults) break;
|
|
75
79
|
const href = String(result.href ?? "").trim();
|
|
76
|
-
if (
|
|
80
|
+
if (!href) continue;
|
|
81
|
+
if (!checkUrlAccess(href, policy)[0]) continue;
|
|
77
82
|
allowed.push(result);
|
|
78
83
|
}
|
|
79
84
|
if (!allowed.length) return EMPTY_SEARCH_RESULTS[1];
|