pi-unsloth-webtools 0.3.0 → 0.3.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 +7 -0
- package/engines.ts +13 -2
- package/html-to-md.ts +72 -49
- package/index.ts +98 -62
- package/package.json +1 -1
- package/pdf.ts +5 -0
- package/web-access.ts +5 -0
- package/web-fetch.ts +50 -33
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ Mirrors Unsloth Studio's `web_search` tool:
|
|
|
39
39
|
- Transient engine failures (network errors or null responses) are retried once with a short
|
|
40
40
|
backoff inside the same timeout budget (a retry that cannot fit in the remaining budget is
|
|
41
41
|
skipped); timeouts and cancellations are never retried.
|
|
42
|
+
- Sweeps stop as soon as enough results are gathered: engines still in flight are aborted
|
|
43
|
+
instead of being allowed to run to their timeout.
|
|
42
44
|
|
|
43
45
|
### web_fetch
|
|
44
46
|
|
|
@@ -48,11 +50,16 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
48
50
|
- URL validation: http/https only, no credentials or encoded hostnames, hostname/port checks (any
|
|
49
51
|
port 1–65535 is permitted; SSRF protection is enforced at the resolved-IP layer, not by port
|
|
50
52
|
allowlists).
|
|
53
|
+
Canonical public IPv4 literals are accepted like IPv6 literals; private literals are still
|
|
54
|
+
blocked at the resolved-IP layer.
|
|
51
55
|
- DNS resolution with SSRF protection: every resolved address is validated against
|
|
52
56
|
private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges, then the validated IP
|
|
53
57
|
is pinned for the connection (custom `lookup` + SNI `servername`), so DNS cannot rebind between
|
|
54
58
|
validation and fetch; resolution shares the caller's abort signal and the overall deadline,
|
|
55
59
|
so a stuck resolver cannot outlive the fetch.
|
|
60
|
+
When a host publishes both IPv4 and IPv6 addresses, IPv4 is preferred (broken IPv6 routes
|
|
61
|
+
cannot stall a fetch), and a connection failure falls back to the next validated address for
|
|
62
|
+
the same host before giving up.
|
|
56
63
|
- GitHub repo root pages are rewritten to the unauthenticated README API
|
|
57
64
|
(`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
|
|
58
65
|
(`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
|
package/engines.ts
CHANGED
|
@@ -863,6 +863,7 @@ export async function autoTextSearch(
|
|
|
863
863
|
const seenProviders = new Set<string>();
|
|
864
864
|
const aggregator = new ResultsAggregator();
|
|
865
865
|
const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
|
|
866
|
+
const controller = new AbortController();
|
|
866
867
|
let timedOut = false;
|
|
867
868
|
let cancelled = false;
|
|
868
869
|
const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
|
|
@@ -872,14 +873,19 @@ export async function autoTextSearch(
|
|
|
872
873
|
const run = async (engine: Engine) => {
|
|
873
874
|
let results: SearchResult[] | null = null;
|
|
874
875
|
for (let attempt = 0; attempt < 2 && results === null; attempt++) {
|
|
876
|
+
if (controller.signal.aborted) return;
|
|
875
877
|
const budgetLeft = deadline - Date.now();
|
|
876
878
|
if (budgetLeft <= 0) return;
|
|
877
879
|
if (attempt > 0 && budgetLeft < ENGINE_RETRY_BACKOFF_MS) return;
|
|
878
880
|
const remaining = Math.max(1, budgetLeft);
|
|
881
|
+
const engineSignal = signal
|
|
882
|
+
? AbortSignal.any([signal, controller.signal])
|
|
883
|
+
: controller.signal;
|
|
879
884
|
try {
|
|
880
|
-
results = await engine.search(query, ctx, remaining,
|
|
885
|
+
results = await engine.search(query, ctx, remaining, engineSignal);
|
|
881
886
|
} catch (e) {
|
|
882
887
|
if (e instanceof SearchCancelled) {
|
|
888
|
+
if (controller.signal.aborted && !signal?.aborted) return;
|
|
883
889
|
cancelled = true;
|
|
884
890
|
return;
|
|
885
891
|
}
|
|
@@ -895,15 +901,20 @@ export async function autoTextSearch(
|
|
|
895
901
|
cancelled = true;
|
|
896
902
|
return;
|
|
897
903
|
}
|
|
904
|
+
if (controller.signal.aborted) return;
|
|
898
905
|
}
|
|
899
906
|
}
|
|
900
907
|
if (results && results.length) {
|
|
901
908
|
aggregator.extend(results);
|
|
902
909
|
seenProviders.add(engine.provider);
|
|
910
|
+
if (aggregator.size >= maxResults) controller.abort();
|
|
903
911
|
}
|
|
904
912
|
};
|
|
905
913
|
while (i < engines.length) {
|
|
906
|
-
if (aggregator.size >= maxResults || cancelled)
|
|
914
|
+
if (aggregator.size >= maxResults || cancelled) {
|
|
915
|
+
controller.abort();
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
907
918
|
const engine = engines[i++];
|
|
908
919
|
if (seenProviders.has(engine.provider)) continue;
|
|
909
920
|
pending.push(run(engine));
|
package/html-to-md.ts
CHANGED
|
@@ -228,8 +228,11 @@ interface HtmlHandlers {
|
|
|
228
228
|
handleCharRef(name: string): void;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
const START_TAG_NAME_RE =
|
|
232
|
-
const ATTR_NAME_RE =
|
|
231
|
+
const START_TAG_NAME_RE = /[a-zA-Z][^\s/>]*/y;
|
|
232
|
+
const ATTR_NAME_RE = /[^\s=/>]+/y;
|
|
233
|
+
const ENTITY_NAMED_RE = /&([A-Za-z][A-Za-z0-9.-]*);/y;
|
|
234
|
+
const ENTITY_NUMERIC_RE = /&#([xX][0-9a-fA-F]+|[0-9]+);?/y;
|
|
235
|
+
const ENTITY_LEGACY_RE = /&([A-Za-z][A-Za-z0-9.-]*)(?=[^A-Za-z0-9]|$)/y;
|
|
233
236
|
|
|
234
237
|
const RAW_TEXT_TAGS = [
|
|
235
238
|
"script",
|
|
@@ -242,9 +245,23 @@ const RAW_TEXT_TAGS = [
|
|
|
242
245
|
"noframes",
|
|
243
246
|
];
|
|
244
247
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
+
function findRawTextClose(
|
|
249
|
+
input: string,
|
|
250
|
+
lowerInput: string,
|
|
251
|
+
from: number,
|
|
252
|
+
name: string,
|
|
253
|
+
): { start: number; end: number } | null {
|
|
254
|
+
const needle = `</${name}`;
|
|
255
|
+
let pos = from;
|
|
256
|
+
while (true) {
|
|
257
|
+
const hit = lowerInput.indexOf(needle, pos);
|
|
258
|
+
if (hit === -1) return null;
|
|
259
|
+
let j = hit + needle.length;
|
|
260
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
261
|
+
if (j < input.length && input[j] === ">") return { start: hit, end: j + 1 };
|
|
262
|
+
pos = hit + 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
248
265
|
|
|
249
266
|
function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, boolean] {
|
|
250
267
|
const attrs: AttrDict = {};
|
|
@@ -257,10 +274,11 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
|
|
|
257
274
|
pos++;
|
|
258
275
|
continue;
|
|
259
276
|
}
|
|
260
|
-
|
|
277
|
+
ATTR_NAME_RE.lastIndex = pos;
|
|
278
|
+
const nameMatch = ATTR_NAME_RE.exec(input);
|
|
261
279
|
if (!nameMatch) return [attrs, -1, false];
|
|
262
280
|
const name = nameMatch[0].toLowerCase();
|
|
263
|
-
pos
|
|
281
|
+
pos = nameMatch.index + nameMatch[0].length;
|
|
264
282
|
while (pos < input.length && /\s/.test(input[pos])) pos++;
|
|
265
283
|
let value: string | null = null;
|
|
266
284
|
if (pos < input.length && input[pos] === "=") {
|
|
@@ -285,64 +303,69 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
|
|
|
285
303
|
}
|
|
286
304
|
|
|
287
305
|
function scanTag(
|
|
288
|
-
|
|
306
|
+
input: string,
|
|
289
307
|
i: number,
|
|
290
308
|
): { end: number; kind: "comment" | "decl" | "end" | "start" | "startend"; name?: string; attrs?: AttrDict } | null {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const close = html.indexOf("-->", i + 4);
|
|
309
|
+
if (input.startsWith("!--", i + 1)) {
|
|
310
|
+
const close = input.indexOf("-->", i + 4);
|
|
294
311
|
if (close === -1) return null;
|
|
295
312
|
return { end: close + 3, kind: "decl" };
|
|
296
313
|
}
|
|
297
|
-
|
|
314
|
+
const next = input[i + 1];
|
|
315
|
+
if (next === "!" || next === "?") {
|
|
298
316
|
let j = i + 2;
|
|
299
|
-
while (j <
|
|
300
|
-
if (j >=
|
|
317
|
+
while (j < input.length && input[j] !== ">") j++;
|
|
318
|
+
if (j >= input.length) return null;
|
|
301
319
|
return { end: j + 1, kind: "decl" };
|
|
302
320
|
}
|
|
303
|
-
if (
|
|
321
|
+
if (next === "/") {
|
|
304
322
|
let j = i + 2;
|
|
305
|
-
while (j <
|
|
306
|
-
const name =
|
|
323
|
+
while (j < input.length && !/[\s>]/.test(input[j])) j++;
|
|
324
|
+
const name = input.slice(i + 2, j).toLowerCase();
|
|
307
325
|
if (!name) return null;
|
|
308
|
-
while (j <
|
|
309
|
-
if (j >=
|
|
326
|
+
while (j < input.length && input[j] !== ">") j++;
|
|
327
|
+
if (j >= input.length) return null;
|
|
310
328
|
return { end: j + 1, kind: "end", name };
|
|
311
329
|
}
|
|
312
|
-
|
|
330
|
+
START_TAG_NAME_RE.lastIndex = i + 1;
|
|
331
|
+
const nameMatch = START_TAG_NAME_RE.exec(input);
|
|
313
332
|
if (!nameMatch) return null;
|
|
314
333
|
const name = nameMatch[0].toLowerCase();
|
|
315
|
-
const [attrs,
|
|
316
|
-
if (
|
|
317
|
-
return { end:
|
|
334
|
+
const [attrs, nextPos, selfClosing] = parseAttrsUntilClose(input, i + 1 + nameMatch[0].length);
|
|
335
|
+
if (nextPos === -1) return null;
|
|
336
|
+
return { end: nextPos, kind: selfClosing ? "startend" : "start", name, attrs };
|
|
318
337
|
}
|
|
319
338
|
|
|
320
339
|
|
|
321
340
|
export function feedHtml(input: string, handlers: HtmlHandlers): void {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
341
|
+
let lowerInput: string | null = null;
|
|
342
|
+
const emitTextRange = (start: number, end: number) => {
|
|
343
|
+
if (end <= start) return;
|
|
344
|
+
let pos = start;
|
|
345
|
+
while (pos < end) {
|
|
346
|
+
const amp = input.indexOf("&", pos);
|
|
347
|
+
if (amp === -1 || amp >= end) {
|
|
348
|
+
handlers.handleData(input.slice(pos, end));
|
|
329
349
|
return;
|
|
330
350
|
}
|
|
331
|
-
if (amp > pos) handlers.handleData(
|
|
332
|
-
|
|
333
|
-
|
|
351
|
+
if (amp > pos) handlers.handleData(input.slice(pos, amp));
|
|
352
|
+
ENTITY_NAMED_RE.lastIndex = amp;
|
|
353
|
+
const named = ENTITY_NAMED_RE.exec(input);
|
|
354
|
+
if (named && named.index === amp && amp + named[0].length <= end) {
|
|
334
355
|
handlers.handleEntityRef(named[1]);
|
|
335
356
|
pos = amp + named[0].length;
|
|
336
357
|
continue;
|
|
337
358
|
}
|
|
338
|
-
|
|
339
|
-
|
|
359
|
+
ENTITY_NUMERIC_RE.lastIndex = amp;
|
|
360
|
+
const numeric = ENTITY_NUMERIC_RE.exec(input);
|
|
361
|
+
if (numeric && numeric.index === amp && amp + numeric[0].length <= end) {
|
|
340
362
|
handlers.handleCharRef(numeric[1]);
|
|
341
363
|
pos = amp + numeric[0].length;
|
|
342
364
|
continue;
|
|
343
365
|
}
|
|
344
|
-
|
|
345
|
-
|
|
366
|
+
ENTITY_LEGACY_RE.lastIndex = amp;
|
|
367
|
+
const legacy = ENTITY_LEGACY_RE.exec(input);
|
|
368
|
+
if (legacy && legacy.index === amp && amp + legacy[0].length <= end) {
|
|
346
369
|
handlers.handleEntityRef(legacy[1]);
|
|
347
370
|
pos = amp + legacy[0].length;
|
|
348
371
|
continue;
|
|
@@ -364,28 +387,28 @@ export function feedHtml(input: string, handlers: HtmlHandlers): void {
|
|
|
364
387
|
i++;
|
|
365
388
|
continue;
|
|
366
389
|
}
|
|
367
|
-
|
|
390
|
+
emitTextRange(textStart, i);
|
|
368
391
|
if (tag.kind === "start") {
|
|
369
|
-
const
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
handlers.handleStartTag(
|
|
375
|
-
|
|
376
|
-
handlers.handleEndTag(
|
|
377
|
-
textStart =
|
|
392
|
+
const rawName = tag.name!;
|
|
393
|
+
if (RAW_TEXT_TAGS.includes(rawName)) {
|
|
394
|
+
lowerInput ??= input.toLowerCase();
|
|
395
|
+
const close = findRawTextClose(input, lowerInput, tag.end, rawName);
|
|
396
|
+
if (close) {
|
|
397
|
+
handlers.handleStartTag(rawName, tag.attrs!);
|
|
398
|
+
emitTextRange(tag.end, close.start);
|
|
399
|
+
handlers.handleEndTag(rawName);
|
|
400
|
+
textStart = close.end;
|
|
378
401
|
i = textStart;
|
|
379
402
|
continue;
|
|
380
403
|
}
|
|
381
404
|
}
|
|
382
|
-
handlers.handleStartTag(
|
|
405
|
+
handlers.handleStartTag(rawName, tag.attrs!);
|
|
383
406
|
} else if (tag.kind === "startend") handlers.handleStartEndTag(tag.name!, tag.attrs!);
|
|
384
407
|
else if (tag.kind === "end") handlers.handleEndTag(tag.name!);
|
|
385
408
|
textStart = tag.end;
|
|
386
409
|
i = tag.end;
|
|
387
410
|
}
|
|
388
|
-
|
|
411
|
+
emitTextRange(textStart, input.length);
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
function popMarksAbove(marks: number[], index: number): void {
|
package/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { webSearch } from "./web-search.ts";
|
|
4
|
-
import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText } from "./web-fetch.ts";
|
|
3
|
+
import { webSearch as defaultWebSearch } from "./web-search.ts";
|
|
4
|
+
import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
|
|
5
5
|
|
|
6
|
-
function
|
|
6
|
+
function positiveNumber(value: unknown): number | undefined {
|
|
7
7
|
return typeof value === "number" && value > 0 ? value : undefined;
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -23,6 +23,19 @@ const WebSearchParams = Type.Object({
|
|
|
23
23
|
"Truncate the fetched page to this many characters (only used with the url parameter)",
|
|
24
24
|
}),
|
|
25
25
|
),
|
|
26
|
+
maxResults: Type.Optional(
|
|
27
|
+
Type.Number({
|
|
28
|
+
minimum: 1,
|
|
29
|
+
maximum: 20,
|
|
30
|
+
description: "Maximum number of search results to return (default: 5)",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
timeoutMs: Type.Optional(
|
|
34
|
+
Type.Number({
|
|
35
|
+
minimum: 1000,
|
|
36
|
+
description: "Overall timeout in milliseconds for the search or fetch",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
26
39
|
});
|
|
27
40
|
|
|
28
41
|
const WebFetchParams = Type.Object({
|
|
@@ -32,65 +45,88 @@ const WebFetchParams = Type.Object({
|
|
|
32
45
|
description: "Truncate the returned content to this many characters (default: no limit)",
|
|
33
46
|
}),
|
|
34
47
|
),
|
|
48
|
+
timeoutMs: Type.Optional(
|
|
49
|
+
Type.Number({
|
|
50
|
+
minimum: 1000,
|
|
51
|
+
description: "Overall timeout in milliseconds (default: 60000)",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
35
54
|
});
|
|
36
55
|
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
description:
|
|
42
|
-
"Search the web and fetch page content. Returns snippets for all results. " +
|
|
43
|
-
"Use the url parameter to fetch full page text from a specific URL.",
|
|
44
|
-
promptSnippet: "Search the web and fetch page content",
|
|
45
|
-
promptGuidelines: [
|
|
46
|
-
"Use web_search with the url parameter (e.g. {\"url\": \"<URL>\"}) to read the full text of a page found in search results.",
|
|
47
|
-
],
|
|
48
|
-
parameters: WebSearchParams,
|
|
49
|
-
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
50
|
-
if (params.url?.trim()) {
|
|
51
|
-
const url = params.url.trim();
|
|
52
|
-
onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
|
|
53
|
-
return {
|
|
54
|
-
content: [
|
|
55
|
-
{
|
|
56
|
-
type: "text",
|
|
57
|
-
text: await fetchPageText(url, {
|
|
58
|
-
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
|
|
59
|
-
signal: signal ?? undefined,
|
|
60
|
-
maxChars: positiveMaxChars(params.maxChars),
|
|
61
|
-
}),
|
|
62
|
-
},
|
|
63
|
-
],
|
|
64
|
-
details: {},
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
|
|
68
|
-
const text = await webSearch(params.query, { signal: signal ?? undefined });
|
|
69
|
-
return { content: [{ type: "text", text }], details: {} };
|
|
70
|
-
},
|
|
71
|
-
});
|
|
56
|
+
export interface WebToolsDeps {
|
|
57
|
+
fetchPageText?: typeof defaultFetchPageText;
|
|
58
|
+
webSearch?: typeof defaultWebSearch;
|
|
59
|
+
}
|
|
72
60
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
61
|
+
export function createWebTools(deps: WebToolsDeps = {}) {
|
|
62
|
+
const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
|
|
63
|
+
const webSearch = deps.webSearch ?? defaultWebSearch;
|
|
64
|
+
return {
|
|
65
|
+
webSearchTool: defineTool({
|
|
66
|
+
name: "web_search",
|
|
67
|
+
label: "Web Search",
|
|
68
|
+
description:
|
|
69
|
+
"Search the web and fetch page content. Returns snippets for all results. " +
|
|
70
|
+
"Use the url parameter to fetch full page text from a specific URL.",
|
|
71
|
+
promptSnippet: "Search the web and fetch page content",
|
|
72
|
+
promptGuidelines: [
|
|
73
|
+
'Use web_search with the url parameter (e.g. {"url": "<URL>"}) to read the full text of a page found in search results.',
|
|
74
|
+
],
|
|
75
|
+
parameters: WebSearchParams,
|
|
76
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
77
|
+
if (params.url?.trim()) {
|
|
78
|
+
const url = params.url.trim();
|
|
79
|
+
onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
|
|
80
|
+
return {
|
|
81
|
+
content: [
|
|
82
|
+
{
|
|
83
|
+
type: "text",
|
|
84
|
+
text: await fetchPageText(url, {
|
|
85
|
+
timeoutMs: positiveNumber(params.timeoutMs) ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
86
|
+
signal: signal ?? undefined,
|
|
87
|
+
maxChars: positiveNumber(params.maxChars),
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
details: {},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
|
|
95
|
+
const text = await webSearch(params.query, {
|
|
96
|
+
signal: signal ?? undefined,
|
|
97
|
+
timeoutMs: positiveNumber(params.timeoutMs),
|
|
98
|
+
maxResults: positiveNumber(params.maxResults),
|
|
99
|
+
});
|
|
100
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
webFetchTool: defineTool({
|
|
104
|
+
name: "web_fetch",
|
|
105
|
+
label: "Web Fetch",
|
|
106
|
+
description:
|
|
107
|
+
"Fetch a URL and return its readable text. HTML pages are converted to Markdown using a " +
|
|
108
|
+
"main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
|
|
109
|
+
"stripping. Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
|
|
110
|
+
"README API, so the README is returned instead of the repo page's UI chrome. " +
|
|
111
|
+
"Private/loopback/link-local targets are blocked (SSRF protection), and the download size " +
|
|
112
|
+
"is capped.",
|
|
113
|
+
promptSnippet: "Fetch a web page and return readable text content",
|
|
114
|
+
parameters: WebFetchParams,
|
|
115
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
116
|
+
onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
|
|
117
|
+
const text = await fetchPageText(params.url, {
|
|
118
|
+
timeoutMs: positiveNumber(params.timeoutMs) ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
119
|
+
signal: signal ?? undefined,
|
|
120
|
+
maxChars: positiveNumber(params.maxChars),
|
|
121
|
+
});
|
|
122
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default function (pi: ExtensionAPI) {
|
|
129
|
+
const { webSearchTool, webFetchTool } = createWebTools();
|
|
130
|
+
pi.registerTool(webSearchTool);
|
|
131
|
+
pi.registerTool(webFetchTool);
|
|
96
132
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-unsloth-webtools",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|
package/pdf.ts
CHANGED
|
@@ -697,6 +697,11 @@ function extractTextOps(bytes: Buffer): string {
|
|
|
697
697
|
}
|
|
698
698
|
}
|
|
699
699
|
}
|
|
700
|
+
if (c === "'" || c === '"') {
|
|
701
|
+
if (inText) out.push("\n");
|
|
702
|
+
i++;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
700
705
|
const token = text.slice(i, i + 2);
|
|
701
706
|
if (token === "BT") {
|
|
702
707
|
inText = true;
|
package/web-access.ts
CHANGED
|
@@ -37,6 +37,11 @@ export function normalizeDomain(value: unknown): string {
|
|
|
37
37
|
throw new Error("Website limits must contain domains without schemes or ports");
|
|
38
38
|
}
|
|
39
39
|
const numericParts = stripped.split(".");
|
|
40
|
+
const canonicalIpv4 =
|
|
41
|
+
numericParts.length === 4 &&
|
|
42
|
+
numericParts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/.test(part)) &&
|
|
43
|
+
numericParts.every((part) => Number(part) <= 255);
|
|
44
|
+
if (canonicalIpv4) return stripped;
|
|
40
45
|
if (
|
|
41
46
|
numericParts.length <= 4 &&
|
|
42
47
|
numericParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/.test(part))
|
package/web-fetch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import type { LookupAllOptions } from "node:dns";
|
|
2
3
|
import http from "node:http";
|
|
3
4
|
import https from "node:https";
|
|
4
5
|
import { createBrotliDecompress, createGunzip, createInflate, createInflateRaw } from "node:zlib";
|
|
@@ -165,6 +166,7 @@ export interface ResolvedHost {
|
|
|
165
166
|
reason: string;
|
|
166
167
|
ip: string;
|
|
167
168
|
family: number;
|
|
169
|
+
alternates?: { ip: string; family: number }[];
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
export interface FetchSeams {
|
|
@@ -204,14 +206,25 @@ export interface RawFetchResult {
|
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
function htmlProbe(body: string, re: RegExp): boolean {
|
|
207
|
-
let
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
209
|
+
let i = 0;
|
|
210
|
+
const n = body.length;
|
|
211
|
+
while (i < n) {
|
|
212
|
+
while (i < n && /[ \t\n\r\f\v]/.test(body[i])) i++;
|
|
213
|
+
if (body.startsWith("<!--", i)) {
|
|
214
|
+
const close = body.indexOf("-->", i + 4);
|
|
215
|
+
if (close === -1) break;
|
|
216
|
+
i = close + 3;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (body.startsWith("<?", i)) {
|
|
220
|
+
const close = body.indexOf("?>", i + 2);
|
|
221
|
+
if (close === -1) break;
|
|
222
|
+
i = close + 2;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
213
226
|
}
|
|
214
|
-
return re.test(
|
|
227
|
+
return re.test(body.slice(i, i + 256).toLowerCase());
|
|
215
228
|
}
|
|
216
229
|
|
|
217
230
|
export function looksLikeHtml(body: string): boolean {
|
|
@@ -455,32 +468,15 @@ function decodeTis620(bytes: Buffer): string {
|
|
|
455
468
|
}
|
|
456
469
|
|
|
457
470
|
|
|
458
|
-
function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
459
|
-
if (!signal) return promise;
|
|
460
|
-
return new Promise<T>((resolve, reject) => {
|
|
461
|
-
const onAbort = () => reject(new DOMException("aborted", "AbortError"));
|
|
462
|
-
if (signal.aborted) {
|
|
463
|
-
onAbort();
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
467
|
-
promise.then(
|
|
468
|
-
(value) => {
|
|
469
|
-
signal.removeEventListener("abort", onAbort);
|
|
470
|
-
resolve(value);
|
|
471
|
-
},
|
|
472
|
-
(err) => {
|
|
473
|
-
signal.removeEventListener("abort", onAbort);
|
|
474
|
-
reject(err);
|
|
475
|
-
},
|
|
476
|
-
);
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
|
|
480
471
|
async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
|
|
481
472
|
let addresses: { address: string; family: number }[];
|
|
482
473
|
try {
|
|
483
|
-
|
|
474
|
+
const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
|
|
475
|
+
all: true,
|
|
476
|
+
verbatim: true,
|
|
477
|
+
signal,
|
|
478
|
+
};
|
|
479
|
+
addresses = await dnsLookup(hostname, lookupOptions);
|
|
484
480
|
} catch (err) {
|
|
485
481
|
return { ok: false, reason: `Failed to resolve host: ${err}`, ip: "", family: 0 };
|
|
486
482
|
}
|
|
@@ -492,8 +488,15 @@ async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promi
|
|
|
492
488
|
return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
|
|
493
489
|
}
|
|
494
490
|
}
|
|
491
|
+
addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
|
|
495
492
|
const first = addresses[0];
|
|
496
|
-
return {
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
reason: "",
|
|
496
|
+
ip: first.address,
|
|
497
|
+
family: first.family,
|
|
498
|
+
alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
|
|
499
|
+
};
|
|
497
500
|
}
|
|
498
501
|
|
|
499
502
|
|
|
@@ -603,7 +606,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
603
606
|
if (total >= limit) {
|
|
604
607
|
truncated =
|
|
605
608
|
codec !== null ||
|
|
606
|
-
|
|
609
|
+
take.length < chunk.length ||
|
|
610
|
+
(Number.isFinite(declaredLength) && declaredLength > total);
|
|
607
611
|
decoder?.destroy();
|
|
608
612
|
res.destroy();
|
|
609
613
|
finish(null, Buffer.concat(chunks));
|
|
@@ -722,8 +726,9 @@ export async function fetchUrlRaw(
|
|
|
722
726
|
let currentUrl = url;
|
|
723
727
|
let pinnedIp = resolved.ip;
|
|
724
728
|
let pinnedFamily = resolved.family;
|
|
729
|
+
let alternates: { ip: string; family: number }[] = resolved.alternates ?? [];
|
|
730
|
+
let alternateIndex = 0;
|
|
725
731
|
const userAgent = randomUserAgent();
|
|
726
|
-
|
|
727
732
|
for (let hop = 0; hop < MAX_REQUESTS; hop++) {
|
|
728
733
|
const budgetResult = budgetExceededResult(deadline, signal, now);
|
|
729
734
|
if (budgetResult !== null) return budgetResult;
|
|
@@ -752,6 +757,16 @@ export async function fetchUrlRaw(
|
|
|
752
757
|
signal,
|
|
753
758
|
});
|
|
754
759
|
} catch (err) {
|
|
760
|
+
if (
|
|
761
|
+
!(err instanceof FetchCancelledError) &&
|
|
762
|
+
!(err instanceof FetchTimeoutError) &&
|
|
763
|
+
alternateIndex < alternates.length
|
|
764
|
+
) {
|
|
765
|
+
const next = alternates[alternateIndex++];
|
|
766
|
+
pinnedIp = next.ip;
|
|
767
|
+
pinnedFamily = next.family;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
755
770
|
return { error: fetchErrorMessage(err), body: "", contentType: "" };
|
|
756
771
|
}
|
|
757
772
|
|
|
@@ -782,6 +797,8 @@ export async function fetchUrlRaw(
|
|
|
782
797
|
if (!redirected.ok) return { error: redirected.reason, body: "", contentType: "" };
|
|
783
798
|
pinnedIp = redirected.ip;
|
|
784
799
|
pinnedFamily = redirected.family;
|
|
800
|
+
alternates = redirected.alternates ?? [];
|
|
801
|
+
alternateIndex = 0;
|
|
785
802
|
continue;
|
|
786
803
|
}
|
|
787
804
|
|