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 +49 -3
- package/agent-dir.ts +19 -0
- package/cache.ts +154 -0
- package/engines.ts +70 -8
- package/html-to-md.ts +12 -3
- package/index.ts +24 -6
- package/package.json +4 -1
- package/pdf.ts +1 -1
- package/settings.ts +114 -0
- package/web-access.ts +48 -27
- package/web-fetch.ts +246 -82
- package/web-search.ts +7 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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)
|
|
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
|
-
|
|
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)
|
|
930
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
100
|
+
timeoutMs,
|
|
86
101
|
signal: signal ?? undefined,
|
|
87
|
-
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
|
|
136
|
+
timeoutMs,
|
|
119
137
|
signal: signal ?? undefined,
|
|
120
|
-
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
|
+
"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
|
|
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
|
+
}
|