pi-webfind 0.5.2 → 0.6.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 +14 -2
- package/extensions/web-search.ts +162 -73
- package/lib/adapters.ts +295 -117
- package/lib/apis.ts +2 -0
- package/lib/cache.ts +42 -16
- package/lib/engine.ts +489 -91
- package/lib/extract.ts +377 -61
- package/lib/fetcher.ts +443 -217
- package/lib/net.ts +93 -0
- package/lib/rank.ts +89 -16
- package/lib/safe.ts +94 -0
- package/lib/version.ts +1 -1
- package/package.json +20 -16
- package/themes/claude-dark.json +80 -0
package/lib/net.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Host-level state shared by engines and the fetcher: per-host cooldowns
|
|
2
|
+
// (HTTP 429), the process-wide offline flag (ENOTFOUND/ENETUNREACH), and the
|
|
3
|
+
// single rate-limit gap for the r.jina.ai relay.
|
|
4
|
+
|
|
5
|
+
const cooldowns = new Map<string, number>();
|
|
6
|
+
|
|
7
|
+
/** Put `host` on cooldown for `ms` milliseconds (e.g. after an HTTP 429). */
|
|
8
|
+
export function setHostCooldown(host: string, ms: number): void {
|
|
9
|
+
cooldowns.set(host, Date.now() + ms);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Epoch ms until which `host` is cooling down (0 = not cooling). */
|
|
13
|
+
export function hostCooldownUntil(host: string): number {
|
|
14
|
+
return cooldowns.get(host) ?? 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ------------------------------------------------------------- offline flag
|
|
18
|
+
|
|
19
|
+
const OFFLINE_MS = 10_000;
|
|
20
|
+
let offlineUntil = 0;
|
|
21
|
+
let offlineCode = "";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mark the network as offline for 10 s. Called on ENOTFOUND/ENETUNREACH from
|
|
25
|
+
* the search hosts (a typo'd target host must not black out the network —
|
|
26
|
+
* `markOfflineIfSearchHost` guards the single-host case).
|
|
27
|
+
*/
|
|
28
|
+
export function markOffline(code: string): void {
|
|
29
|
+
offlineUntil = Date.now() + OFFLINE_MS;
|
|
30
|
+
offlineCode = code;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Any successful/answered request clears the flag. */
|
|
34
|
+
export function markOnline(): void {
|
|
35
|
+
offlineUntil = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Throws while the offline flag is set (called at the top of every network fn). */
|
|
39
|
+
export function assertOnline(): void {
|
|
40
|
+
if (Date.now() < offlineUntil) throw new Error(`network unavailable (${offlineCode})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True while the offline flag is set (tools surface "Network appears offline"). */
|
|
44
|
+
export function isOffline(): boolean {
|
|
45
|
+
return Date.now() < offlineUntil;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ENOTFOUND from a single non-search host is usually a typo'd URL, not an
|
|
50
|
+
* outage — only search/archive infrastructure trips the process-wide flag on
|
|
51
|
+
* its own (or any two distinct hosts failing within 2 s).
|
|
52
|
+
*/
|
|
53
|
+
const SEARCH_HOSTS = /^(html|lite)\.duckduckgo\.com$|^(www\.)?bing\.com$|^search\.brave\.com$|^archive\.org$|^r\.jina\.ai$/;
|
|
54
|
+
const recentNotFound: Array<{ host: string; at: number }> = [];
|
|
55
|
+
|
|
56
|
+
export function noteNotFound(host: string): void {
|
|
57
|
+
if (SEARCH_HOSTS.test(host)) {
|
|
58
|
+
markOffline("ENOTFOUND");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
recentNotFound.push({ host, at: now });
|
|
63
|
+
while (recentNotFound.length > 0 && now - recentNotFound[0]!.at > 2_000) recentNotFound.shift();
|
|
64
|
+
const distinct = new Set(recentNotFound.map((r) => r.host));
|
|
65
|
+
if (distinct.size >= 2) markOffline("ENOTFOUND");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Wait so that consecutive r.jina.ai requests stay ≥ `JINA_RATE_MS` apart. Single shared gap. */
|
|
69
|
+
let lastJinaCall = 0;
|
|
70
|
+
/** With a free JINA_API_KEY the reader relay allows ~200 rpm instead of ~20 — throttle accordingly. */
|
|
71
|
+
export const JINA_RATE_MS = process.env.JINA_API_KEY ? 300 : 3_500;
|
|
72
|
+
export async function jinaGap(signal?: AbortSignal): Promise<void> {
|
|
73
|
+
const wait = lastJinaCall + JINA_RATE_MS - Date.now();
|
|
74
|
+
if (wait > 0) await sleep(wait, signal);
|
|
75
|
+
lastJinaCall = Date.now();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Authorization header for r.jina.ai when JINA_API_KEY is set (raises the rate cap). */
|
|
79
|
+
export function jinaAuth(): Record<string, string> {
|
|
80
|
+
const key = process.env.JINA_API_KEY;
|
|
81
|
+
return key ? { Authorization: `Bearer ${key}` } : {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
if (signal?.aborted) return resolve();
|
|
87
|
+
const t = setTimeout(resolve, ms);
|
|
88
|
+
signal?.addEventListener("abort", () => {
|
|
89
|
+
clearTimeout(t);
|
|
90
|
+
resolve();
|
|
91
|
+
}, { once: true });
|
|
92
|
+
});
|
|
93
|
+
}
|
package/lib/rank.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Zero dependencies, no model calls.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const WORD_RE = /[
|
|
9
|
+
const WORD_RE = /[\p{L}\p{N}_#+-]+(?:\.[\p{L}\p{N}_#+-]+)*/gu;
|
|
10
|
+
const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu;
|
|
10
11
|
|
|
11
12
|
function stem(t: string): string {
|
|
12
13
|
if (t.length > 4 && t.endsWith("ing")) return t.slice(0, -3);
|
|
@@ -15,8 +16,26 @@ function stem(t: string): string {
|
|
|
15
16
|
return t;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
function tokenize(s: string): string[] {
|
|
19
|
-
|
|
19
|
+
export function tokenize(s: string): string[] {
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
for (const m of s.toLowerCase().matchAll(WORD_RE)) {
|
|
22
|
+
const w = m[0];
|
|
23
|
+
if (CJK_RE.test(w)) {
|
|
24
|
+
CJK_RE.lastIndex = 0;
|
|
25
|
+
for (const run of w.match(CJK_RE) ?? []) {
|
|
26
|
+
if (run.length === 1) out.push(run);
|
|
27
|
+
for (let i = 0; i + 1 < run.length; i++) out.push(run.slice(i, i + 2));
|
|
28
|
+
}
|
|
29
|
+
for (const r of w.replace(CJK_RE, " ").trim().split(/\s+/)) if (r) out.push(stem(r));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
out.push(stem(w));
|
|
33
|
+
// dotted identifiers: also emit the last segment (react.useEffect -> useeffect)
|
|
34
|
+
const dot = w.lastIndexOf(".");
|
|
35
|
+
const tail = dot > 0 ? w.slice(dot + 1) : "";
|
|
36
|
+
if (tail && /\p{L}/u.test(tail)) out.push(stem(tail));
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
export interface Passage {
|
|
@@ -25,6 +44,8 @@ export interface Passage {
|
|
|
25
44
|
heading: string;
|
|
26
45
|
/** index in document order */
|
|
27
46
|
pos: number;
|
|
47
|
+
/** "code" = a whole fenced block (or chunk of one); exempt from MIN_PASSAGE */
|
|
48
|
+
kind: "prose" | "code";
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
const MIN_PASSAGE = 24;
|
|
@@ -35,7 +56,23 @@ export function splitPassages(text: string): Passage[] {
|
|
|
35
56
|
const lines = text.split("\n");
|
|
36
57
|
let heading = "";
|
|
37
58
|
let buf: string[] = [];
|
|
59
|
+
const FENCE_RE = /^\s*(```|~~~)/;
|
|
60
|
+
let fence: string[] | null = null;
|
|
38
61
|
for (const line of lines) {
|
|
62
|
+
if (fence) {
|
|
63
|
+
fence.push(line);
|
|
64
|
+
if (FENCE_RE.test(line)) {
|
|
65
|
+
pushFence(fence);
|
|
66
|
+
fence = null;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (FENCE_RE.test(line)) {
|
|
71
|
+
push(buf.join("\n"));
|
|
72
|
+
buf = [];
|
|
73
|
+
fence = [line];
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
39
76
|
if (line.trim() === "") {
|
|
40
77
|
push(buf.join("\n"));
|
|
41
78
|
buf = [];
|
|
@@ -50,28 +87,50 @@ export function splitPassages(text: string): Passage[] {
|
|
|
50
87
|
}
|
|
51
88
|
buf.push(line);
|
|
52
89
|
}
|
|
90
|
+
if (fence) pushFence([...fence, fence[0].match(FENCE_RE)![1]!]); // unterminated fence at EOF
|
|
53
91
|
push(buf.join("\n").trim());
|
|
54
92
|
return passages;
|
|
55
93
|
|
|
94
|
+
/** Whole fenced block as one code passage (or ~900-char line-chunks when huge). */
|
|
95
|
+
function pushFence(fl: string[]) {
|
|
96
|
+
const opener = fl[0]!, closer = fl[fl.length - 1]!, body = fl.slice(1, -1);
|
|
97
|
+
if (body.join("\n").trim().length === 0) return;
|
|
98
|
+
const emit = (chunk: string[]) =>
|
|
99
|
+
passages.push({ text: [opener, ...chunk, closer].join("\n"), heading, pos: passages.length, kind: "code" });
|
|
100
|
+
if (body.join("\n").length <= 1200) return emit(body);
|
|
101
|
+
let chunk: string[] = [];
|
|
102
|
+
let size = 0;
|
|
103
|
+
for (const l of body) {
|
|
104
|
+
if (size + l.length > 900 && chunk.length) {
|
|
105
|
+
emit(chunk);
|
|
106
|
+
chunk = [];
|
|
107
|
+
size = 0;
|
|
108
|
+
}
|
|
109
|
+
chunk.push(l);
|
|
110
|
+
size += l.length + 1;
|
|
111
|
+
}
|
|
112
|
+
if (chunk.length) emit(chunk);
|
|
113
|
+
}
|
|
114
|
+
|
|
56
115
|
/** Long blobs (infoboxes, template junk, minified docs) split on sentence boundaries so scoring can discriminate. */
|
|
57
116
|
function push(raw: string) {
|
|
58
117
|
const t = raw.trim();
|
|
59
118
|
if (t.length < MIN_PASSAGE) return;
|
|
60
119
|
if (t.length <= 1200) {
|
|
61
|
-
passages.push({ text: t, heading, pos: passages.length });
|
|
120
|
+
passages.push({ text: t, heading, pos: passages.length, kind: "prose" });
|
|
62
121
|
return;
|
|
63
122
|
}
|
|
64
123
|
const sentences = t.match(/[^.!?\n]+[.!?]?\s*/g) ?? [t];
|
|
65
124
|
let cur = "";
|
|
66
125
|
for (const sen of sentences) {
|
|
67
126
|
if (cur.length + sen.length > 900 && cur.length >= MIN_PASSAGE) {
|
|
68
|
-
passages.push({ text: cur.trim(), heading, pos: passages.length });
|
|
127
|
+
passages.push({ text: cur.trim(), heading, pos: passages.length, kind: "prose" });
|
|
69
128
|
cur = sen;
|
|
70
129
|
} else {
|
|
71
130
|
cur += sen;
|
|
72
131
|
}
|
|
73
132
|
}
|
|
74
|
-
if (cur.trim().length >= MIN_PASSAGE) passages.push({ text: cur.trim(), heading, pos: passages.length });
|
|
133
|
+
if (cur.trim().length >= MIN_PASSAGE) passages.push({ text: cur.trim(), heading, pos: passages.length, kind: "prose" });
|
|
75
134
|
}
|
|
76
135
|
}
|
|
77
136
|
|
|
@@ -89,28 +148,33 @@ export function scorePassages(passages: Passage[], query: string): Array<{ p: Pa
|
|
|
89
148
|
const qTokens = [...new Set(tokenize(query))];
|
|
90
149
|
if (qTokens.length === 0) return passages.map((p) => ({ p, score: 0 }));
|
|
91
150
|
|
|
92
|
-
const
|
|
151
|
+
const tfs: Array<Map<string, number>> = passages.map((p) => termFreq(tokenize(p.text)));
|
|
93
152
|
const N = passages.length;
|
|
94
153
|
const avgLen = passages.reduce((a, p) => a + p.text.length, 0) / Math.max(N, 1);
|
|
95
154
|
const k1 = 1.5;
|
|
96
155
|
const b = 0.75;
|
|
97
156
|
const wantCode = queryCodeish(query);
|
|
157
|
+
// document frequency per query token, computed once
|
|
158
|
+
const df = new Map<string, number>(qTokens.map((q) => [q, tfs.reduce((n, m) => n + (m.has(q) ? 1 : 0), 0)]));
|
|
159
|
+
// phrase bonus: multi-token query occurring verbatim in the passage
|
|
160
|
+
const phrase = query.toLowerCase().replace(/\s+/g, " ").trim();
|
|
98
161
|
|
|
99
162
|
return passages.map((p, i) => {
|
|
100
|
-
const
|
|
163
|
+
const tf = tfs[i];
|
|
101
164
|
const len = p.text.length;
|
|
102
165
|
// junk penalty: template/URL soup — real prose has few of these per 100 chars
|
|
103
166
|
const junk = (p.text.match(/\{\{|\}\}|\[\[|\]\]|https?:\/\//g) ?? []).length;
|
|
104
167
|
const junkDensity = junk / Math.max(len / 100, 1);
|
|
105
168
|
let score = 0;
|
|
106
169
|
for (const q of qTokens) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
170
|
+
const n = tf.get(q) ?? 0;
|
|
171
|
+
if (!n) continue;
|
|
172
|
+
const dfq = df.get(q)!;
|
|
110
173
|
const idf = Math.log(1 + (N - dfq + 0.5) / (dfq + 0.5));
|
|
111
|
-
score += (idf *
|
|
174
|
+
score += (idf * n * (k1 + 1)) / (n + k1 * (1 - b + b * (len / avgLen)));
|
|
112
175
|
}
|
|
113
176
|
if (p.heading && qTokens.some((q) => p.heading.toLowerCase().includes(q))) score *= 1.5;
|
|
177
|
+
if (qTokens.length >= 2 && phrase.length >= 6 && p.text.toLowerCase().replace(/\s+/g, " ").includes(phrase)) score *= 1.5;
|
|
114
178
|
if (wantCode && /```|\t|=>|function |const |def |class /.test(p.text)) score *= 1.2;
|
|
115
179
|
if (junkDensity > 1) score *= 0.3;
|
|
116
180
|
else if (junkDensity > 0.4) score *= 0.6;
|
|
@@ -118,6 +182,12 @@ export function scorePassages(passages: Passage[], query: string): Array<{ p: Pa
|
|
|
118
182
|
});
|
|
119
183
|
}
|
|
120
184
|
|
|
185
|
+
export function termFreq(tokens: string[]): Map<string, number> {
|
|
186
|
+
const m = new Map<string, number>();
|
|
187
|
+
for (const t of tokens) m.set(t, (m.get(t) ?? 0) + 1);
|
|
188
|
+
return m;
|
|
189
|
+
}
|
|
190
|
+
|
|
121
191
|
export interface PickedPassage {
|
|
122
192
|
heading: string;
|
|
123
193
|
text: string;
|
|
@@ -128,17 +198,20 @@ export interface PickedPassage {
|
|
|
128
198
|
* Pick the passages worth showing for a query within a char budget:
|
|
129
199
|
* document intro first, then top-scoring passages in original order.
|
|
130
200
|
* `total` = passages in the full document (for the "N of M shown" footer).
|
|
201
|
+
* `passages` = the picked set incl. scores — lets callers re-rank (e.g. deep
|
|
202
|
+
* mode picks the single best-scoring passage instead of the intro).
|
|
131
203
|
*/
|
|
132
204
|
export function topPassages(
|
|
133
205
|
text: string,
|
|
134
206
|
query: string,
|
|
135
207
|
budgetChars = 6000,
|
|
136
208
|
introChars = 600,
|
|
137
|
-
): { picked: PickedPassage[]; total: number } {
|
|
209
|
+
): { picked: PickedPassage[]; total: number; passages: PickedPassage[] } {
|
|
138
210
|
const all = splitPassages(text);
|
|
139
|
-
if (all.length === 0) return { picked: [], total: 0 };
|
|
211
|
+
if (all.length === 0) return { picked: [], total: 0, passages: [] };
|
|
140
212
|
if (!query.trim()) {
|
|
141
|
-
|
|
213
|
+
const introOnly = { heading: all[0].heading, text: text.slice(0, budgetChars), score: 0 };
|
|
214
|
+
return { picked: [introOnly], total: all.length, passages: [introOnly] };
|
|
142
215
|
}
|
|
143
216
|
|
|
144
217
|
const scored = scorePassages(all, query);
|
|
@@ -165,5 +238,5 @@ export function topPassages(
|
|
|
165
238
|
.sort((a, b) => a[0] - b[0])
|
|
166
239
|
.map(([, v]) => v);
|
|
167
240
|
picked.unshift({ heading: intro.heading, text: intro.text.slice(0, introChars), score: 0 });
|
|
168
|
-
return { picked, total: all.length };
|
|
241
|
+
return { picked, total: all.length, passages: picked };
|
|
169
242
|
}
|
package/lib/safe.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-safe URL validation: protocol/credential/name checks plus DNS
|
|
3
|
+
* resolution — a public-looking name that resolves to a private address is
|
|
4
|
+
* rejected (nip.io / lvh.me style bypasses), and redirect hops are re-checked
|
|
5
|
+
* by the fetcher before each new connection.
|
|
6
|
+
*
|
|
7
|
+
* Residual risk (documented, deferred to WP-10): the address is validated at
|
|
8
|
+
* lookup time, not pinned into the TCP connection, so a DNS-rebinding race
|
|
9
|
+
* between resolveSafe() and connect() remains theoretically open. Node's
|
|
10
|
+
* global fetch exposes no supported way to inject a custom dns.lookup.
|
|
11
|
+
*/
|
|
12
|
+
import { BlockList, isIP } from "node:net";
|
|
13
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
14
|
+
|
|
15
|
+
export type Lookup = (host: string) => Promise<Array<{ address: string; family: number }>>;
|
|
16
|
+
|
|
17
|
+
/** Test seam: tests replace `lookup` with a table; production leaves it alone. */
|
|
18
|
+
export const safeConfig: { lookup: Lookup } = { lookup: (h) => dnsLookup(h, { all: true, verbatim: true }) };
|
|
19
|
+
|
|
20
|
+
const PRIVATE = new BlockList();
|
|
21
|
+
for (const [net, bits] of [
|
|
22
|
+
["127.0.0.0", 8],
|
|
23
|
+
["10.0.0.0", 8],
|
|
24
|
+
["172.16.0.0", 12],
|
|
25
|
+
["192.168.0.0", 16],
|
|
26
|
+
["169.254.0.0", 16],
|
|
27
|
+
["100.64.0.0", 10],
|
|
28
|
+
["0.0.0.0", 8],
|
|
29
|
+
] as const)
|
|
30
|
+
PRIVATE.addSubnet(net, bits, "ipv4");
|
|
31
|
+
for (const [net, bits] of [
|
|
32
|
+
["::1", 128],
|
|
33
|
+
["::", 128],
|
|
34
|
+
["fc00::", 7],
|
|
35
|
+
["fe80::", 10],
|
|
36
|
+
["64:ff9b::", 96],
|
|
37
|
+
] as const)
|
|
38
|
+
PRIVATE.addSubnet(net, bits, "ipv6");
|
|
39
|
+
// NOTE: deliberately NOT adding ::ffff:0:0/96 — Node compares v4-mapped IPv6
|
|
40
|
+
// against the v4 rules above (8.8.8.8 stays allowed, ::ffff:127.0.0.1 blocked).
|
|
41
|
+
|
|
42
|
+
const BLOCKED_NAME = /^(localhost|localhost\.localdomain)$|\.(local|internal|localhost|home\.arpa)$/i;
|
|
43
|
+
|
|
44
|
+
export function isPrivateAddress(addr: string): boolean {
|
|
45
|
+
const fam = isIP(addr);
|
|
46
|
+
if (fam === 0) return true; // unparseable addresses fail closed
|
|
47
|
+
return PRIVATE.check(addr, fam === 6 ? "ipv6" : "ipv4");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Protocol, credentials, name and *resolved address* check. Throws `Blocked …`; returns the parsed URL. */
|
|
51
|
+
export async function resolveSafe(input: string | URL, lookup: Lookup = safeConfig.lookup): Promise<URL> {
|
|
52
|
+
let url: URL;
|
|
53
|
+
try {
|
|
54
|
+
url = typeof input === "string" ? new URL(input) : input;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`Invalid URL: ${String(input)}`);
|
|
57
|
+
}
|
|
58
|
+
if (!/^https?:$/.test(url.protocol)) throw new Error(`Blocked protocol: ${url.protocol} (use http/https)`);
|
|
59
|
+
if (url.username || url.password) throw new Error("Blocked URL: credentials in URL");
|
|
60
|
+
const host = url.hostname.replace(/\.$/, "").toLowerCase();
|
|
61
|
+
const literal = host.startsWith("[") ? host.slice(1, -1) : host;
|
|
62
|
+
if (BLOCKED_NAME.test(host)) throw new Error(`Blocked host (SSRF protection): ${host}`);
|
|
63
|
+
if (isIP(literal)) {
|
|
64
|
+
if (isPrivateAddress(literal)) throw new Error(`Blocked host (SSRF protection): ${host}`);
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
let addrs: Array<{ address: string; family: number }>;
|
|
68
|
+
try {
|
|
69
|
+
addrs = await lookup(host);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new Error(`DNS lookup failed for ${host} (${(e as NodeJS.ErrnoException)?.code ?? "unknown"})`);
|
|
72
|
+
}
|
|
73
|
+
if (addrs.length === 0) throw new Error(`DNS lookup failed for ${host} (no addresses)`);
|
|
74
|
+
const bad = addrs.find((a) => isPrivateAddress(a.address));
|
|
75
|
+
if (bad) throw new Error(`Blocked host (SSRF protection): ${host} resolves to ${bad.address}`);
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Synchronous subset (protocol, credentials, blocked names, literal IPs) — used for cache keys and adapter routing. */
|
|
80
|
+
export function assertSafeUrl(rawUrl: string): URL {
|
|
81
|
+
let url: URL;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(rawUrl);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error(`Invalid URL: ${rawUrl}`);
|
|
86
|
+
}
|
|
87
|
+
if (!/^https?:$/.test(url.protocol)) throw new Error(`Blocked protocol: ${url.protocol} (use http/https)`);
|
|
88
|
+
if (url.username || url.password) throw new Error("Blocked URL: credentials in URL");
|
|
89
|
+
const host = url.hostname.replace(/\.$/, "").toLowerCase();
|
|
90
|
+
const literal = host.startsWith("[") ? host.slice(1, -1) : host;
|
|
91
|
+
if (BLOCKED_NAME.test(host)) throw new Error(`Blocked host (SSRF protection): ${host}`);
|
|
92
|
+
if (isIP(literal) && isPrivateAddress(literal)) throw new Error(`Blocked host (SSRF protection): ${host}`);
|
|
93
|
+
return url;
|
|
94
|
+
}
|
package/lib/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Single source of truth for the tool's version, used in UA strings.
|
|
2
2
|
// Kept in sync with package.json manually on release (release checklist item).
|
|
3
|
-
export const VERSION = "0.
|
|
3
|
+
export const VERSION = "0.6.0";
|
|
4
4
|
export const TOOL_UA = `pi-webfind/${VERSION} (free web research toolkit for pi coding agent; +https://github.com/jawwadzafar/pi-webfind)`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-webfind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Claude Code-style web research for the pi coding agent \u2014 WebSearch, query-aware fetch (PDFs, Wayback, site adapters, markdown extraction), Stack Overflow, GitHub, HN, Wikipedia, npm. 100% free, no API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -20,37 +20,41 @@
|
|
|
20
20
|
"type": "git",
|
|
21
21
|
"url": "git+https://github.com/jawwadzafar/pi-webfind.git"
|
|
22
22
|
},
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test tests/*.test.ts",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"docs:dev": "vitepress dev docs",
|
|
27
|
+
"docs:build": "vitepress build docs",
|
|
28
|
+
"docs:preview": "vitepress preview docs"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@earendil-works/pi-coding-agent": "*",
|
|
32
32
|
"typebox": "*"
|
|
33
33
|
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
36
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
37
|
+
"typebox": "1.3.7",
|
|
38
|
+
"vitepress": "^1.6.3"
|
|
39
|
+
},
|
|
34
40
|
"files": [
|
|
35
41
|
"extensions/",
|
|
36
42
|
"lib/",
|
|
37
43
|
"README.md",
|
|
38
|
-
"LICENSE"
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"themes/"
|
|
39
46
|
],
|
|
40
47
|
"engines": {
|
|
41
48
|
"node": ">=20"
|
|
42
49
|
},
|
|
43
50
|
"author": "Jawwad Zafar <zafarjawwad@gmail.com>",
|
|
44
51
|
"homepage": "https://jawwadzafar.github.io/pi-webfind",
|
|
45
|
-
"scripts": {
|
|
46
|
-
"docs:dev": "vitepress dev docs",
|
|
47
|
-
"docs:build": "vitepress build docs",
|
|
48
|
-
"docs:preview": "vitepress preview docs"
|
|
49
|
-
},
|
|
50
|
-
"devDependencies": {
|
|
51
|
-
"vitepress": "^1.6.3"
|
|
52
|
-
},
|
|
53
52
|
"bugs": {
|
|
54
53
|
"url": "https://github.com/jawwadzafar/pi-webfind/issues"
|
|
54
|
+
},
|
|
55
|
+
"pi": {
|
|
56
|
+
"themes": [
|
|
57
|
+
"./themes"
|
|
58
|
+
]
|
|
55
59
|
}
|
|
56
60
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "Claude Dark",
|
|
4
|
+
"vars": {
|
|
5
|
+
"cyan": "#d7c197",
|
|
6
|
+
"blue": "#7aa2f7",
|
|
7
|
+
"green": "#6ec876",
|
|
8
|
+
"red": "#ff6b80",
|
|
9
|
+
"yellow": "#d9a05b",
|
|
10
|
+
"text": "#e8e4dd",
|
|
11
|
+
"gray": "#8a857c",
|
|
12
|
+
"dimGray": "#5e594f",
|
|
13
|
+
"darkGray": "#3e3a34",
|
|
14
|
+
"accent": "#d7c197",
|
|
15
|
+
"selectedBg": "#3a3630",
|
|
16
|
+
"userMsgBg": "#2d2a26",
|
|
17
|
+
"toolPendingBg": "#262320",
|
|
18
|
+
"toolSuccessBg": "#232a23",
|
|
19
|
+
"toolErrorBg": "#33241f",
|
|
20
|
+
"customMsgBg": "#2a2724"
|
|
21
|
+
},
|
|
22
|
+
"colors": {
|
|
23
|
+
"accent": "accent",
|
|
24
|
+
"border": "blue",
|
|
25
|
+
"borderAccent": "cyan",
|
|
26
|
+
"borderMuted": "darkGray",
|
|
27
|
+
"success": "green",
|
|
28
|
+
"error": "red",
|
|
29
|
+
"warning": "yellow",
|
|
30
|
+
"muted": "gray",
|
|
31
|
+
"dim": "dimGray",
|
|
32
|
+
"text": "text",
|
|
33
|
+
"thinkingText": "gray",
|
|
34
|
+
"selectedBg": "selectedBg",
|
|
35
|
+
"scrollbarTrack": "darkGray",
|
|
36
|
+
"scrollbarThumb": "text",
|
|
37
|
+
"searchMatchBg": "selectedBg",
|
|
38
|
+
"searchMatchText": "text",
|
|
39
|
+
"userMessageBg": "userMsgBg",
|
|
40
|
+
"userMessageText": "text",
|
|
41
|
+
"customMessageBg": "customMsgBg",
|
|
42
|
+
"customMessageText": "text",
|
|
43
|
+
"customMessageLabel": "#d7c197",
|
|
44
|
+
"toolPendingBg": "toolPendingBg",
|
|
45
|
+
"toolSuccessBg": "toolSuccessBg",
|
|
46
|
+
"toolErrorBg": "toolErrorBg",
|
|
47
|
+
"toolTitle": "#e8e4dd",
|
|
48
|
+
"toolOutput": "gray",
|
|
49
|
+
"mdHeading": "#e8e4dd",
|
|
50
|
+
"mdLink": "#d7c197",
|
|
51
|
+
"mdLinkUrl": "#8a857c",
|
|
52
|
+
"mdCode": "accent",
|
|
53
|
+
"mdCodeBlock": "green",
|
|
54
|
+
"mdCodeBlockBorder": "gray",
|
|
55
|
+
"mdQuote": "gray",
|
|
56
|
+
"mdQuoteBorder": "gray",
|
|
57
|
+
"mdHr": "gray",
|
|
58
|
+
"mdListBullet": "accent",
|
|
59
|
+
"toolDiffAdded": "green",
|
|
60
|
+
"toolDiffRemoved": "red",
|
|
61
|
+
"toolDiffContext": "gray",
|
|
62
|
+
"syntaxComment": "#6A9955",
|
|
63
|
+
"syntaxKeyword": "#569CD6",
|
|
64
|
+
"syntaxFunction": "#DCDCAA",
|
|
65
|
+
"syntaxVariable": "#9CDCFE",
|
|
66
|
+
"syntaxString": "#CE9178",
|
|
67
|
+
"syntaxNumber": "#B5CEA8",
|
|
68
|
+
"syntaxType": "#4EC9B0",
|
|
69
|
+
"syntaxOperator": "#D4D4D4",
|
|
70
|
+
"syntaxPunctuation": "#D4D4D4",
|
|
71
|
+
"thinkingOff": "darkGray",
|
|
72
|
+
"thinkingMinimal": "#6e6e6e",
|
|
73
|
+
"thinkingLow": "#5f87af",
|
|
74
|
+
"thinkingMedium": "#81a2be",
|
|
75
|
+
"thinkingHigh": "#b294bb",
|
|
76
|
+
"thinkingXhigh": "#d183e8",
|
|
77
|
+
"thinkingMax": "#ff5fff",
|
|
78
|
+
"bashMode": "green"
|
|
79
|
+
}
|
|
80
|
+
}
|