pi-unsloth-webtools 0.4.0 → 0.4.2

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
@@ -61,6 +61,9 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
61
61
  When a host publishes both IPv4 and IPv6 addresses, IPv4 is preferred (broken IPv6 routes
62
62
  cannot stall a fetch), and a connection failure falls back to the next validated address for
63
63
  the same host before giving up.
64
+ - Transient DNS failures (EAI_AGAIN, resolver timeouts, connection refusals) are retried once
65
+ with a short backoff inside the same deadline, so a brief resolver hiccup does not fail a
66
+ fetch; the deadline abort cuts a retry short when no budget remains.
64
67
  - GitHub repo root pages are rewritten to the unauthenticated README API
65
68
  (`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
66
69
  (`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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
@@ -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, "%0x" + c.charCodeAt(0).toString(16));
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`);
@@ -408,7 +408,7 @@ export function isPublicIp(ip: string): boolean {
408
408
  }
409
409
  if (canonical === "::1" || canonical.startsWith("::")) return false;
410
410
  if (canonical.startsWith("fc") || canonical.startsWith("fd")) return false;
411
- if (/^fe[89ab][0-9a-f]:/.test(canonical)) return false;
411
+ if (/^fe[89a-f][0-9a-f]:/.test(canonical)) return false;
412
412
  if (canonical.startsWith("ff")) return false;
413
413
  if (canonical.startsWith("2001:db8")) return false;
414
414
  if (canonical.startsWith("64:ff9b:")) return false;
package/web-fetch.ts CHANGED
@@ -28,6 +28,8 @@ const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
28
28
  const META_VALUE_MAX_CHARS = 300;
29
29
  const MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024;
30
30
  const MAX_REQUESTS = 5;
31
+ const MAX_DNS_ATTEMPTS = 2;
32
+ const DNS_RETRY_BACKOFF_MS = 250;
31
33
  export const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
32
34
  export { loadDefaultFetchMaxChars, loadDefaultFetchTimeoutMs, loadDefaultFetchSettings } from "./settings.ts";
33
35
  function remainingMs(deadline: number, now: () => number): number {
@@ -522,34 +524,73 @@ function decodeTis620(bytes: Buffer): string {
522
524
  }
523
525
 
524
526
 
527
+ const TRANSIENT_DNS_ERROR_CODES = new Set([
528
+ "EAI_AGAIN",
529
+ "ETIMEDOUT",
530
+ "ECONNREFUSED",
531
+ "ENETUNREACH",
532
+ "EHOSTUNREACH",
533
+ ]);
534
+
535
+ function isTransientDnsError(err: unknown): boolean {
536
+ if (!(err instanceof Error)) return false;
537
+ const code = (err as NodeJS.ErrnoException).code;
538
+ return typeof code === "string" && TRANSIENT_DNS_ERROR_CODES.has(code);
539
+ }
540
+
541
+ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
542
+ return new Promise((resolve) => {
543
+ if (signal?.aborted) return resolve();
544
+ const timer = setTimeout(resolve, ms);
545
+ signal?.addEventListener("abort", () => {
546
+ clearTimeout(timer);
547
+ resolve();
548
+ }, { once: true });
549
+ });
550
+ }
551
+
525
552
  async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
526
- let addresses: { address: string; family: number }[];
527
- try {
528
- const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
529
- all: true,
530
- verbatim: true,
531
- signal,
532
- };
533
- addresses = await dnsLookup(hostname, lookupOptions);
534
- } catch (err) {
535
- return { ok: false, reason: `Failed to resolve host: ${err}`, ip: "", family: 0 };
536
- }
537
- if (!addresses.length) {
538
- return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
539
- }
540
- for (const entry of addresses) {
541
- if (!isPublicIp(entry.address)) {
542
- return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
553
+ let lastError: unknown = null;
554
+ for (let attempt = 0; attempt < MAX_DNS_ATTEMPTS; attempt++) {
555
+ if (signal?.aborted) break;
556
+ if (attempt > 0) {
557
+ await sleepAbortable(DNS_RETRY_BACKOFF_MS, signal);
558
+ if (signal?.aborted) break;
559
+ }
560
+ try {
561
+ const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
562
+ all: true,
563
+ verbatim: true,
564
+ signal,
565
+ };
566
+ const addresses = await dnsLookup(hostname, lookupOptions);
567
+ if (!addresses.length) {
568
+ return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
569
+ }
570
+ for (const entry of addresses) {
571
+ if (!isPublicIp(entry.address)) {
572
+ return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
573
+ }
574
+ }
575
+ addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
576
+ const first = addresses[0];
577
+ return {
578
+ ok: true,
579
+ reason: "",
580
+ ip: first.address,
581
+ family: first.family,
582
+ alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
583
+ };
584
+ } catch (err) {
585
+ lastError = err;
586
+ if (!isTransientDnsError(err)) break;
543
587
  }
544
588
  }
545
- addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
546
- const first = addresses[0];
547
589
  return {
548
- ok: true,
549
- reason: "",
550
- ip: first.address,
551
- family: first.family,
552
- alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
590
+ ok: false,
591
+ reason: lastError === null ? "Failed to resolve host: cancelled" : `Failed to resolve host: ${lastError}`,
592
+ ip: "",
593
+ family: 0,
553
594
  };
554
595
  }
555
596
  function budgetExceededResult(
@@ -769,16 +810,22 @@ export async function fetchUrlRaw(
769
810
  const performRequest = seams.request ?? requestHop;
770
811
  const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
771
812
  const abortController = new AbortController();
772
- const timeoutId = setTimeout(() => abortController.abort(), clampedRemainingMs(deadline, now));
813
+ const waitMs = clampedRemainingMs(deadline, now);
814
+ const timeoutId = setTimeout(() => abortController.abort(), waitMs);
773
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
+ });
774
820
  try {
775
- const resolved = await resolveHost(hostname, resolveSignal);
821
+ const resolved = await Promise.race([resolveHost(hostname, resolveSignal), deadlinePromise]);
776
822
  if (resolved.ok) return resolved;
777
823
  if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
778
824
  if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
779
825
  return resolved;
780
826
  } finally {
781
827
  clearTimeout(timeoutId);
828
+ if (deadlineId !== undefined) clearTimeout(deadlineId);
782
829
  }
783
830
  };
784
831
  const checkBudget = (contentType = ""): RawFetchResult | null => budgetExceededResult(deadline, signal, now, contentType);
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, SEARCH_TIMEOUT_MS, signal);
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 maxResults = options.maxResults ?? (await loadDefaultMaxResults(options.cwd));
55
- const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
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 (href && !checkUrlAccess(href, policy)[0]) continue;
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];