pi-unsloth-webtools 0.3.1 → 0.4.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 CHANGED
@@ -51,7 +51,8 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
51
51
  port 1–65535 is permitted; SSRF protection is enforced at the resolved-IP layer, not by port
52
52
  allowlists).
53
53
  Canonical public IPv4 literals are accepted like IPv6 literals; private literals are still
54
- blocked at the resolved-IP layer.
54
+ blocked at the resolved-IP layer (non-canonical numeric encodings like `0x7f.0.0.1` /
55
+ `013.0.0.1` / `2130706433` are rejected by URL validation before DNS).
55
56
  - DNS resolution with SSRF protection: every resolved address is validated against
56
57
  private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges, then the validated IP
57
58
  is pinned for the connection (custom `lookup` + SNI `servername`), so DNS cannot rebind between
@@ -64,7 +65,7 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
64
65
  (`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
65
66
  (`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
66
67
  returning error-page content.
67
- - Up to 4 redirect hops, each re-validated and re-resolved against the same rules.
68
+ - Up to 5 total requests (initial fetch + up to 4 HTTP redirects or `<meta http-equiv="refresh">` refreshes), each re-validated and re-resolved against the same rules.
68
69
  - 512 KiB download cap (10 MiB for PDFs), overall deadline + per-hop socket timeouts, abort-aware
69
70
  (`signal` cancels mid-flight). Fetches cut off by a download cap are marked with a trailing
70
71
  truncation notice, so a partial page is not mistaken for a complete one.
@@ -131,6 +132,50 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
131
132
  deliberately pins the Studio snapshot — seven engines, bing disabled upstream, no vqd,
132
133
  no pagination — so engine behavior matches Studio rather than ddgs head.
133
134
 
135
+ ## When to use alternatives
136
+
137
+ This package is intentionally a faithful, zero-dependency port of Studio's pipeline. Use it
138
+ when you need deterministic, offline-friendly behavior with strong SSRF guarantees and test
139
+ parity with `unsloth/studio`. For other tradeoffs, prefer:
140
+
141
+ | Need | Use |
142
+ |---|---|
143
+ | Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | `pi-smart-fetch` (`wreq-js` `chrome_145`) |
144
+ | Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | `georgebashi/pi-web-fetch` (puppeteer + trafilatura) |
145
+ | Hosted search with semantic ranking and no scraping | `Brave Search API` / `Tavily` / `Exa` via `pi-ollama-web-search` |
146
+ | Prompt-focused page distillation to save context | `pi-web-fetch` `prompt` -> sub-agent or Claude Code `WebFetch(url,prompt)` |
147
+ | Batch fetching many URLs concurrently | `pi-smart-fetch` `batch_web_fetch` or call `web_fetch` in parallel |
148
+
149
+ Mixing is supported: `pi install npm:pi-unsloth-webtools npm:pi-smart-fetch` lets the model
150
+ choose the best tool per URL. No need to fork this package to add those features.
151
+
152
+ ## Configuration
153
+
154
+ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project overrides global):
155
+
156
+ ```json
157
+ {
158
+ "unslothWebTools": {
159
+ "maxResults": 5,
160
+ "maxChars": 50000,
161
+ "timeoutMs": 60000
162
+ },
163
+ "webFetch": {
164
+ "maxChars": 50000,
165
+ "timeoutMs": 15000
166
+ }
167
+ }
168
+ ```
169
+
170
+ | Key | Default | Description |
171
+ |---|---|---|
172
+ | `unslothWebTools.maxResults` | `5` | Default `maxResults` for `web_search` (clamped 1-20) |
173
+ | `unslothWebTools.maxChars` / `webFetch.maxChars` / `smartFetchDefaultMaxChars` | tool param | Default `maxChars` for `web_fetch` and `web_search` url mode |
174
+ | `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` | Default `timeoutMs` (>=1000) |
175
+ | `webSearch.maxResults` / `smartWebSearch.resultsPerQuery` | same as above | Legacy aliases for `maxResults` |
176
+
177
+ Tool params always win over file defaults.
178
+
134
179
  ## Development
135
180
 
136
181
  ```sh
@@ -165,7 +210,8 @@ The suite ports Unsloth Studio's own tests for these tools:
165
210
  - `test/entities.test.ts`: `decodeHtmlEntities` parity with CPython `html.unescape`,
166
211
  legacy refs, longest-prefix rule, Windows-1252 numeric mappings, invalid codepoints
167
212
  - `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
168
- result-health sweep (at least three engines must return well-formed results)
213
+ result-health sweep (at least two engines must return well-formed results; engines
214
+ that block or reset connections from datacenter IPs count as unhealthy, not failures)
169
215
 
170
216
  The seams (`seams.resolve` / `seams.request` / `rawFetch`) replace the network stack
171
217
  with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host`
package/agent-dir.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ function expandAgentDir(value: string): string {
5
+ const trimmed = value.trim();
6
+ if (trimmed === "~") return homedir();
7
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(homedir(), trimmed.slice(2));
8
+ return trimmed;
9
+ }
10
+
11
+ export function agentDir(): string {
12
+ const env = (process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR)?.trim();
13
+ if (env) return expandAgentDir(env);
14
+ try {
15
+ const home = homedir();
16
+ if (home) return join(home, ".pi", "agent");
17
+ } catch {}
18
+ return "";
19
+ }
package/cache.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile, chmod } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { agentDir } from "./agent-dir.ts";
6
+ import { canonicalizeHref } from "./engines.ts";
7
+
8
+ const CACHE_TTL_MS = 60 * 60 * 1000;
9
+ const MAX_ENTRIES = 128;
10
+ const MAX_BYTES = 128 * 1024 * 1024;
11
+ const STALE_MAX_AGE_MS = 24 * CACHE_TTL_MS;
12
+
13
+ function cacheDir(): string {
14
+ const env = process.env.PI_UNSLOTH_CACHE_DIR?.trim();
15
+ if (env) return env;
16
+ const base = agentDir();
17
+ if (base) return join(base, "pi-unsloth-cache");
18
+ return join(tmpdir(), "pi-unsloth-cache");
19
+ }
20
+
21
+ function cacheKey(url: string): string {
22
+ const canon = canonicalizeHref(url) || url;
23
+ return createHash("sha256").update(canon).digest("hex");
24
+ }
25
+
26
+ function cachePath(key: string): string {
27
+ return join(cacheDir(), `${key}.json`);
28
+ }
29
+
30
+ async function secureChmod(path: string, mode: number): Promise<void> {
31
+ if (process.platform === "win32") return;
32
+ try {
33
+ await chmod(path, mode);
34
+ } catch {}
35
+ }
36
+
37
+ async function unlinkSafe(path: string): Promise<boolean> {
38
+ try {
39
+ await unlink(path);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ async function ensureDir(): Promise<void> {
47
+ const dir = cacheDir();
48
+ try {
49
+ await mkdir(dir, { recursive: true, mode: 0o700 });
50
+ await secureChmod(dir, 0o700);
51
+ } catch {}
52
+ }
53
+
54
+ export interface CacheEntry {
55
+ url: string;
56
+ body: string;
57
+ contentType: string;
58
+ timestamp: number;
59
+ }
60
+
61
+ export async function getCached(url: string): Promise<CacheEntry | null> {
62
+ const key = cacheKey(url);
63
+ const path = cachePath(key);
64
+ try {
65
+ const raw = await readFile(path, "utf8");
66
+ const entry = JSON.parse(raw) as CacheEntry;
67
+ if (!entry || typeof entry.body !== "string" || typeof entry.contentType !== "string" || typeof entry.timestamp !== "number") return null;
68
+ if (!Number.isFinite(entry.timestamp)) return null;
69
+ return entry;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export async function setCached(url: string, body: string, contentType: string): Promise<void> {
76
+ await ensureDir();
77
+ const key = cacheKey(url);
78
+ const path = cachePath(key);
79
+ const entry: CacheEntry = { url: canonicalizeHref(url) || url, body, contentType, timestamp: Date.now() };
80
+ const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
81
+ try {
82
+ await writeFile(tmpPath, JSON.stringify(entry), { mode: 0o600 });
83
+ await secureChmod(tmpPath, 0o600);
84
+ await rename(tmpPath, path);
85
+ await secureChmod(path, 0o600);
86
+ } catch {
87
+ await unlinkSafe(tmpPath);
88
+ return;
89
+ }
90
+ await prune();
91
+ }
92
+
93
+ async function prune(): Promise<void> {
94
+ const dir = cacheDir();
95
+ let files: string[];
96
+ try {
97
+ files = await readdir(dir);
98
+ } catch {
99
+ return;
100
+ }
101
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
102
+ if (!jsonFiles.length) return;
103
+ const now = Date.now();
104
+ const entries: { file: string; size: number; timestamp: number }[] = [];
105
+ let total = 0;
106
+ for (const file of jsonFiles) {
107
+ const full = join(dir, file);
108
+ try {
109
+ const s = await stat(full);
110
+ let timestamp = s.mtimeMs;
111
+ try {
112
+ const raw = await readFile(full, "utf8");
113
+ const entry = JSON.parse(raw) as CacheEntry;
114
+ if (entry && typeof entry.timestamp === "number" && Number.isFinite(entry.timestamp)) timestamp = entry.timestamp;
115
+ } catch {}
116
+ if (now - timestamp >= STALE_MAX_AGE_MS) {
117
+ await unlinkSafe(full);
118
+ continue;
119
+ }
120
+ entries.push({ file: full, size: s.size, timestamp });
121
+ total += s.size;
122
+ } catch {}
123
+ }
124
+ const expired = entries.filter((e) => now - e.timestamp >= CACHE_TTL_MS).sort((a, b) => a.timestamp - b.timestamp);
125
+ for (const e of expired) {
126
+ if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) break;
127
+ if (await unlinkSafe(e.file)) {
128
+ total -= e.size;
129
+ const idx = entries.indexOf(e);
130
+ if (idx !== -1) entries.splice(idx, 1);
131
+ }
132
+ }
133
+ if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) return;
134
+ entries.sort((a, b) => a.timestamp - b.timestamp);
135
+ let idx = 0;
136
+ while (idx < entries.length && (entries.length > MAX_ENTRIES || total > MAX_BYTES)) {
137
+ const e = entries[idx];
138
+ if (await unlinkSafe(e.file)) {
139
+ total -= e.size;
140
+ entries.splice(idx, 1);
141
+ } else {
142
+ idx++;
143
+ }
144
+ }
145
+ }
146
+
147
+ export function isFresh(entry: CacheEntry, now = Date.now()): boolean {
148
+ return now - entry.timestamp < CACHE_TTL_MS;
149
+ }
150
+
151
+ export function staleNotice(entry: CacheEntry): string {
152
+ const date = new Date(entry.timestamp).toISOString().slice(0, 10);
153
+ return `\n\n*STALE cache from ${date} — network fetch failed, serving cached copy*`;
154
+ }
package/engines.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { appendFile, chmod, mkdir } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join } from "node:path";
2
4
  import { collapseWhitespace, decodeHtmlEntities, feedHtml } from "./html-to-md.ts";
3
5
  import type { AttrDict } from "./html-to-md.ts";
4
6
  import { randomUserAgent } from "./user-agents.ts";
7
+ import { agentDir } from "./agent-dir.ts";
8
+ import { MAX_SIGNAL_TIMEOUT_MS } from "./web-access.ts";
5
9
  export class EmptySweepError extends Error {
6
10
  constructor() {
7
11
  super("No results found");
@@ -9,8 +13,10 @@ export class EmptySweepError extends Error {
9
13
  }
10
14
 
11
15
  export class SearchTimeoutError extends Error {
12
- constructor() {
16
+ providers: string[];
17
+ constructor(providers: string[] = []) {
13
18
  super("timed out");
19
+ this.providers = providers;
14
20
  }
15
21
  }
16
22
 
@@ -556,7 +562,8 @@ async function httpFetch(
556
562
  .join("; ")
557
563
  : null;
558
564
  if (cookie) headers["Cookie"] = cookie;
559
- const signals: AbortSignal[] = [AbortSignal.timeout(options.timeoutMs)];
565
+ const timeoutMs = Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, options.timeoutMs));
566
+ const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)];
560
567
  if (options.signal) signals.push(options.signal);
561
568
  let response: Response;
562
569
  try {
@@ -840,6 +847,40 @@ export function rankResults(docs: SearchResult[], query: string): SearchResult[]
840
847
  }
841
848
  return [...wiki, ...both, ...titleOnly, ...bodyOnly, ...neither];
842
849
  }
850
+ async function recordSweepStats(query: string, maxResults: number, started: number, timedOutProviders: string[], resultCount: number): Promise<void> {
851
+ const flag = process.env.PI_UNSLOTH_WEBTOOLS_STATS?.trim();
852
+ if (!flag) return;
853
+ const lower = flag.toLowerCase();
854
+ if (lower === "0" || lower === "false" || lower === "no" || lower === "off") return;
855
+ const base = agentDir();
856
+ if (!base) return;
857
+ const isDefaultFlag = lower === "1" || lower === "true" || lower === "yes" || lower === "on";
858
+ const statsPath = isDefaultFlag ? join(base, "pi-unsloth-webtools-stats.jsonl") : isAbsolute(flag) ? flag : join(base, flag);
859
+ try {
860
+ const dir = dirname(statsPath);
861
+ await mkdir(dir, { recursive: true, mode: 0o700 });
862
+ if (process.platform !== "win32") {
863
+ try {
864
+ await chmod(dir, 0o700);
865
+ } catch {}
866
+ }
867
+ const sortedProviders = [...timedOutProviders].sort();
868
+ const entry = JSON.stringify({
869
+ ts: new Date().toISOString(),
870
+ query,
871
+ maxResults,
872
+ durationMs: Date.now() - started,
873
+ timedOutProviders: sortedProviders,
874
+ resultCount,
875
+ });
876
+ await appendFile(statsPath, entry + "\n", { mode: 0o600 });
877
+ if (process.platform !== "win32") {
878
+ try {
879
+ await chmod(statsPath, 0o600);
880
+ } catch {}
881
+ }
882
+ } catch {}
883
+ }
843
884
 
844
885
  function shuffledEngines(): Engine[] {
845
886
  const shuffled = [...TEXT_ENGINES];
@@ -858,13 +899,22 @@ export async function autoTextSearch(
858
899
  timeoutMs: number,
859
900
  signal?: AbortSignal,
860
901
  ): Promise<SearchResult[]> {
902
+ const started = Date.now();
861
903
  const engines = shuffledEngines();
862
- const deadline = Date.now() + timeoutMs;
904
+ const deadline = started + timeoutMs;
863
905
  const seenProviders = new Set<string>();
864
906
  const aggregator = new ResultsAggregator();
865
907
  const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
866
908
  const controller = new AbortController();
867
- let timedOut = false;
909
+ let onAbort: (() => void) | undefined;
910
+ if (signal) {
911
+ if (signal.aborted) controller.abort();
912
+ else {
913
+ onAbort = () => controller.abort();
914
+ signal.addEventListener("abort", onAbort, { once: true });
915
+ }
916
+ }
917
+ const timedOutProviders = new Set<string>();
868
918
  let cancelled = false;
869
919
  const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
870
920
  const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults / 10) + 1);
@@ -873,7 +923,10 @@ export async function autoTextSearch(
873
923
  const run = async (engine: Engine) => {
874
924
  let results: SearchResult[] | null = null;
875
925
  for (let attempt = 0; attempt < 2 && results === null; attempt++) {
876
- if (controller.signal.aborted) return;
926
+ if (controller.signal.aborted) {
927
+ if (signal?.aborted) cancelled = true;
928
+ return;
929
+ }
877
930
  const budgetLeft = deadline - Date.now();
878
931
  if (budgetLeft <= 0) return;
879
932
  if (attempt > 0 && budgetLeft < ENGINE_RETRY_BACKOFF_MS) return;
@@ -890,7 +943,7 @@ export async function autoTextSearch(
890
943
  return;
891
944
  }
892
945
  if (e instanceof SearchTimeoutError) {
893
- timedOut = true;
946
+ timedOutProviders.add(engine.name);
894
947
  return;
895
948
  }
896
949
  }
@@ -924,9 +977,18 @@ export async function autoTextSearch(
924
977
  }
925
978
  }
926
979
  await Promise.allSettled(pending);
980
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
927
981
  if (cancelled) throw new SearchCancelled();
928
982
  const results = rankResults(aggregator.extractDicts(), query);
929
- if (results.length) return results.slice(0, maxResults);
930
- if (timedOut) throw new SearchTimeoutError();
983
+ if (results.length) {
984
+ void recordSweepStats(query, maxResults, started, [...timedOutProviders], results.length);
985
+ return results.slice(0, maxResults);
986
+ }
987
+ if (timedOutProviders.size) {
988
+ const sorted = [...timedOutProviders].sort();
989
+ void recordSweepStats(query, maxResults, started, sorted, 0);
990
+ throw new SearchTimeoutError(sorted);
991
+ }
992
+ void recordSweepStats(query, maxResults, started, [], 0);
931
993
  throw new EmptySweepError();
932
994
  }
package/html-to-md.ts CHANGED
@@ -1086,8 +1086,13 @@ function visibleLineChars(line: string): number {
1086
1086
  let openBracket = false;
1087
1087
  while (i < n) {
1088
1088
  if (line[i] === "\\") {
1089
- total += 2;
1090
- i += 2;
1089
+ if (i + 1 < n) {
1090
+ total += 2;
1091
+ i += 2;
1092
+ } else {
1093
+ total += 1;
1094
+ i += 1;
1095
+ }
1091
1096
  continue;
1092
1097
  }
1093
1098
  if (line[i] === "[") openBracket = true;
@@ -1097,7 +1102,11 @@ function visibleLineChars(line: string): number {
1097
1102
  while (j < n && depth) {
1098
1103
  const char = line[j];
1099
1104
  if (char === "\\") {
1100
- j += 2;
1105
+ if (j + 1 < n) {
1106
+ j += 2;
1107
+ } else {
1108
+ j += 1;
1109
+ }
1101
1110
  continue;
1102
1111
  }
1103
1112
  depth += (char === "(" ? 1 : 0) - (char === ")" ? 1 : 0);
package/index.ts CHANGED
@@ -1,10 +1,23 @@
1
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { 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
6
 
6
7
  function positiveNumber(value: unknown): number | undefined {
7
- return typeof value === "number" && value > 0 ? value : undefined;
8
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
9
+ const n = Math.floor(value);
10
+ return n > 0 ? n : undefined;
11
+ }
12
+ async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unknown; maxChars?: unknown }) {
13
+ const timeoutParam = positiveNumber(params.timeoutMs);
14
+ const maxCharsParam = positiveNumber(params.maxChars);
15
+ if (timeoutParam !== undefined && maxCharsParam !== undefined) return { timeoutMs: timeoutParam, maxChars: maxCharsParam };
16
+ const defaults = await loadDefaultFetchSettings(cwd);
17
+ return {
18
+ timeoutMs: timeoutParam ?? defaults.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
19
+ maxChars: maxCharsParam ?? defaults.maxChars,
20
+ };
8
21
  }
9
22
 
10
23
  const WebSearchParams = Type.Object({
@@ -77,14 +90,16 @@ export function createWebTools(deps: WebToolsDeps = {}) {
77
90
  if (params.url?.trim()) {
78
91
  const url = params.url.trim();
79
92
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
93
+ const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
94
+ const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
80
95
  return {
81
96
  content: [
82
97
  {
83
98
  type: "text",
84
99
  text: await fetchPageText(url, {
85
- timeoutMs: positiveNumber(params.timeoutMs) ?? DEFAULT_FETCH_TIMEOUT_MS,
100
+ timeoutMs,
86
101
  signal: signal ?? undefined,
87
- maxChars: positiveNumber(params.maxChars),
102
+ maxChars,
88
103
  }),
89
104
  },
90
105
  ],
@@ -96,6 +111,7 @@ export function createWebTools(deps: WebToolsDeps = {}) {
96
111
  signal: signal ?? undefined,
97
112
  timeoutMs: positiveNumber(params.timeoutMs),
98
113
  maxResults: positiveNumber(params.maxResults),
114
+ cwd: (_ctx as ExtensionContext | undefined)?.cwd,
99
115
  });
100
116
  return { content: [{ type: "text", text }], details: {} };
101
117
  },
@@ -114,10 +130,12 @@ export function createWebTools(deps: WebToolsDeps = {}) {
114
130
  parameters: WebFetchParams,
115
131
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
116
132
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
133
+ const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
134
+ const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
117
135
  const text = await fetchPageText(params.url, {
118
- timeoutMs: positiveNumber(params.timeoutMs) ?? DEFAULT_FETCH_TIMEOUT_MS,
136
+ timeoutMs,
119
137
  signal: signal ?? undefined,
120
- maxChars: positiveNumber(params.maxChars),
138
+ maxChars,
121
139
  });
122
140
  return { content: [{ type: "text", text }], details: {} };
123
141
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",
@@ -31,6 +31,9 @@
31
31
  "entities.ts",
32
32
  "pdf.ts",
33
33
  "user-agents.ts",
34
+ "cache.ts",
35
+ "settings.ts",
36
+ "agent-dir.ts",
34
37
  "README.md",
35
38
  "LICENSE"
36
39
  ],
package/pdf.ts CHANGED
@@ -594,7 +594,7 @@ function assemblePages(
594
594
  pageLimitOverride?: boolean,
595
595
  ): string {
596
596
  const parts: string[] = [];
597
- const pageLimitReached = pageLimitOverride ?? pages.length >= MAX_WEB_PDF_PAGES;
597
+ const pageLimitReached = pageLimitOverride ?? pages.length > MAX_WEB_PDF_PAGES;
598
598
  for (const page of pages) {
599
599
  const pageText = page.text.trim();
600
600
  if (!pageText) continue;
package/settings.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { agentDir } from "./agent-dir.ts";
4
+
5
+ async function readJson(file: string): Promise<Record<string, unknown> | undefined> {
6
+ try {
7
+ const raw = await readFile(file, "utf-8");
8
+ const data = JSON.parse(raw);
9
+ if (data && typeof data === "object" && !Array.isArray(data)) return data as Record<string, unknown>;
10
+ return undefined;
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ }
15
+
16
+ function toNumber(value: unknown): number | undefined {
17
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
18
+ return Math.floor(value);
19
+ }
20
+
21
+ function pickNumber(data: Record<string, unknown>, paths: string[][]): number | undefined {
22
+ for (const path of paths) {
23
+ let cur: unknown = data;
24
+ for (const key of path) {
25
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
26
+ else {
27
+ cur = undefined;
28
+ break;
29
+ }
30
+ }
31
+ const n = toNumber(cur);
32
+ if (n !== undefined) return n;
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ const MAX_RESULTS = 5;
38
+
39
+ const MAX_RESULTS_PATHS: string[][] = [
40
+ ["unslothWebTools", "maxResults"],
41
+ ["webSearch", "maxResults"],
42
+ ["smartWebSearch", "resultsPerQuery"],
43
+ ];
44
+
45
+ const FETCH_MAX_CHARS_PATHS: string[][] = [
46
+ ["unslothWebTools", "maxChars"],
47
+ ["webFetch", "maxChars"],
48
+ ["smartFetchDefaultMaxChars"],
49
+ ];
50
+
51
+ const FETCH_TIMEOUT_PATHS: string[][] = [
52
+ ["unslothWebTools", "timeoutMs"],
53
+ ["webFetch", "timeoutMs"],
54
+ ["smartFetchDefaultTimeoutMs"],
55
+ ];
56
+
57
+ function clampMaxResults(value: number): number {
58
+ return Math.min(20, Math.max(1, value));
59
+ }
60
+
61
+ function settingsFiles(cwd?: string): string[] {
62
+ const base = agentDir();
63
+ const globalFile = base ? join(base, "settings.json") : "";
64
+ const files = globalFile ? [globalFile] : [];
65
+ if (cwd) files.push(join(cwd, ".pi", "settings.json"));
66
+ return files;
67
+ }
68
+
69
+ async function settingsEntries(cwd?: string): Promise<Record<string, unknown>[]> {
70
+ const entries: Record<string, unknown>[] = [];
71
+ for (const file of settingsFiles(cwd)) {
72
+ const data = await readJson(file);
73
+ if (data) entries.push(data);
74
+ }
75
+ return entries;
76
+ }
77
+
78
+ export async function loadDefaultMaxResults(cwd?: string): Promise<number> {
79
+ let result = MAX_RESULTS;
80
+ for (const data of await settingsEntries(cwd)) {
81
+ const candidate = pickNumber(data, MAX_RESULTS_PATHS);
82
+ if (candidate !== undefined) result = clampMaxResults(candidate);
83
+ }
84
+ return result;
85
+ }
86
+
87
+ async function loadFetchSetting(cwd: string | undefined, paths: string[][], valid: (n: number) => boolean): Promise<number | undefined> {
88
+ let result: number | undefined;
89
+ for (const data of await settingsEntries(cwd)) {
90
+ const candidate = pickNumber(data, paths);
91
+ if (candidate !== undefined && valid(candidate)) result = candidate;
92
+ }
93
+ return result;
94
+ }
95
+
96
+ export async function loadDefaultFetchMaxChars(cwd?: string): Promise<number | undefined> {
97
+ return loadFetchSetting(cwd, FETCH_MAX_CHARS_PATHS, (n) => n > 0);
98
+ }
99
+
100
+ export async function loadDefaultFetchTimeoutMs(cwd?: string): Promise<number | undefined> {
101
+ return loadFetchSetting(cwd, FETCH_TIMEOUT_PATHS, (n) => n >= 1000);
102
+ }
103
+
104
+ export async function loadDefaultFetchSettings(cwd?: string): Promise<{ maxChars?: number; timeoutMs?: number }> {
105
+ let maxChars: number | undefined;
106
+ let timeoutMs: number | undefined;
107
+ for (const data of await settingsEntries(cwd)) {
108
+ const c = pickNumber(data, FETCH_MAX_CHARS_PATHS);
109
+ if (c !== undefined && c > 0) maxChars = c;
110
+ const t = pickNumber(data, FETCH_TIMEOUT_PATHS);
111
+ if (t !== undefined && t >= 1000) timeoutMs = t;
112
+ }
113
+ return { maxChars, timeoutMs };
114
+ }