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 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 / 10) + 1);
960
+ const maxWorkers = Math.min(uniqueProviders, Math.max(4, Math.ceil(maxResults / 5) + 1));
958
961
  let i = 0;
959
- let pending: Promise<void>[] = [];
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
- const engine = engines[i++];
1009
- if (seenProviders.has(engine.provider)) continue;
1010
- pending.push(run(engine));
1011
- if (pending.length >= maxWorkers) {
1012
- await Promise.allSettled(pending);
1013
- pending = [];
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: positiveNumber(params.timeoutMs),
115
+ timeoutMs: searchTimeoutMs,
113
116
  maxResults: positiveNumber(params.maxResults),
114
- cwd: (_ctx as ExtensionContext | undefined)?.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.4.2",
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
- "dependencies": {
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/131.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/131.0.0.0 Safari/537.36",
4
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
5
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
6
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
7
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
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: (_host, _opts, callback) =>
635
- callback(null, [{ address: opts.pinnedIp, family: opts.family }]),
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 ?? 30_000;
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) Object.assign(headers, 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 timeoutMs = Number.isFinite(rawTimeout) ? (rawTimeout as number) : SEARCH_TIMEOUT_MS;
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;