pi-webfind 0.5.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/lib/rank.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Query-aware passage ranking — the keyless stand-in for Claude Code's
3
+ * model-over-the-page pass. Splits extracted text into passages, scores
4
+ * each against the query (BM25 + heading boost), returns the intro plus
5
+ * top passages within a char budget, in original document order.
6
+ * Zero dependencies, no model calls.
7
+ */
8
+
9
+ const WORD_RE = /[a-z0-9_#+.-]+/g;
10
+
11
+ function stem(t: string): string {
12
+ if (t.length > 4 && t.endsWith("ing")) return t.slice(0, -3);
13
+ if (t.length > 3 && t.endsWith("ed")) return t.slice(0, -2);
14
+ if (t.length > 3 && t.endsWith("s") && !t.endsWith("ss")) return t.slice(0, -1);
15
+ return t;
16
+ }
17
+
18
+ function tokenize(s: string): string[] {
19
+ return (s.toLowerCase().match(WORD_RE) ?? []).map(stem).filter((t) => t.length > 0);
20
+ }
21
+
22
+ export interface Passage {
23
+ text: string;
24
+ /** nearest heading above this passage ("" if none) */
25
+ heading: string;
26
+ /** index in document order */
27
+ pos: number;
28
+ }
29
+
30
+ const MIN_PASSAGE = 24;
31
+
32
+ /** Split extracted text into passages on blank lines, tracking the nearest heading. */
33
+ export function splitPassages(text: string): Passage[] {
34
+ const passages: Passage[] = [];
35
+ const lines = text.split("\n");
36
+ let heading = "";
37
+ let buf: string[] = [];
38
+ for (const line of lines) {
39
+ if (line.trim() === "") {
40
+ push(buf.join("\n"));
41
+ buf = [];
42
+ continue;
43
+ }
44
+ const h = line.match(/^#{1,6}\s+(.{3,120})\s*$/);
45
+ if (h) {
46
+ push(buf.join("\n"));
47
+ buf = [];
48
+ heading = (h[1] ?? "").trim();
49
+ continue;
50
+ }
51
+ buf.push(line);
52
+ }
53
+ push(buf.join("\n").trim());
54
+ return passages;
55
+
56
+ /** Long blobs (infoboxes, template junk, minified docs) split on sentence boundaries so scoring can discriminate. */
57
+ function push(raw: string) {
58
+ const t = raw.trim();
59
+ if (t.length < MIN_PASSAGE) return;
60
+ if (t.length <= 1200) {
61
+ passages.push({ text: t, heading, pos: passages.length });
62
+ return;
63
+ }
64
+ const sentences = t.match(/[^.!?\n]+[.!?]?\s*/g) ?? [t];
65
+ let cur = "";
66
+ for (const sen of sentences) {
67
+ if (cur.length + sen.length > 900 && cur.length >= MIN_PASSAGE) {
68
+ passages.push({ text: cur.trim(), heading, pos: passages.length });
69
+ cur = sen;
70
+ } else {
71
+ cur += sen;
72
+ }
73
+ }
74
+ if (cur.trim().length >= MIN_PASSAGE) passages.push({ text: cur.trim(), heading, pos: passages.length });
75
+ }
76
+ }
77
+
78
+ /** Whether the query looks like it wants code (identifiers, signatures, errors). */
79
+ function queryCodeish(query: string): boolean {
80
+ return /[(){}\[\];=.\\/>]|undefined|null|function|const|error|npm|import/i.test(query);
81
+ }
82
+
83
+ /**
84
+ * BM25 (k1=1.5, b=0.75) of passages against query tokens.
85
+ * ×1.5 if the passage's heading matches a query token;
86
+ * ×1.2 if the passage is code-like and the query is code-ish.
87
+ */
88
+ export function scorePassages(passages: Passage[], query: string): Array<{ p: Passage; score: number }> {
89
+ const qTokens = [...new Set(tokenize(query))];
90
+ if (qTokens.length === 0) return passages.map((p) => ({ p, score: 0 }));
91
+
92
+ const tokenSets: Array<Set<string>> = passages.map((p) => new Set(tokenize(p.text)));
93
+ const N = passages.length;
94
+ const avgLen = passages.reduce((a, p) => a + p.text.length, 0) / Math.max(N, 1);
95
+ const k1 = 1.5;
96
+ const b = 0.75;
97
+ const wantCode = queryCodeish(query);
98
+
99
+ return passages.map((p, i) => {
100
+ const tokens = tokenSets[i];
101
+ const len = p.text.length;
102
+ // junk penalty: template/URL soup — real prose has few of these per 100 chars
103
+ const junk = (p.text.match(/\{\{|\}\}|\[\[|\]\]|https?:\/\//g) ?? []).length;
104
+ const junkDensity = junk / Math.max(len / 100, 1);
105
+ let score = 0;
106
+ for (const q of qTokens) {
107
+ if (!tokens.has(q)) continue;
108
+ let dfq = 0;
109
+ for (const ts of tokenSets) if (ts.has(q)) dfq++;
110
+ const idf = Math.log(1 + (N - dfq + 0.5) / (dfq + 0.5));
111
+ score += (idf * 1 * (k1 + 1)) / (1 + k1 * (1 - b + b * (len / avgLen)));
112
+ }
113
+ if (p.heading && qTokens.some((q) => p.heading.toLowerCase().includes(q))) score *= 1.5;
114
+ if (wantCode && /```|\t|=>|function |const |def |class /.test(p.text)) score *= 1.2;
115
+ if (junkDensity > 1) score *= 0.3;
116
+ else if (junkDensity > 0.4) score *= 0.6;
117
+ return { p, score };
118
+ });
119
+ }
120
+
121
+ export interface PickedPassage {
122
+ heading: string;
123
+ text: string;
124
+ score: number;
125
+ }
126
+
127
+ /**
128
+ * Pick the passages worth showing for a query within a char budget:
129
+ * document intro first, then top-scoring passages in original order.
130
+ * `total` = passages in the full document (for the "N of M shown" footer).
131
+ */
132
+ export function topPassages(
133
+ text: string,
134
+ query: string,
135
+ budgetChars = 6000,
136
+ introChars = 600,
137
+ ): { picked: PickedPassage[]; total: number } {
138
+ const all = splitPassages(text);
139
+ if (all.length === 0) return { picked: [], total: 0 };
140
+ if (!query.trim()) {
141
+ return { picked: [{ heading: all[0].heading, text: text.slice(0, budgetChars), score: 0 }], total: all.length };
142
+ }
143
+
144
+ const scored = scorePassages(all, query);
145
+ const intro = all[0];
146
+
147
+ // top-scoring passages within budget
148
+ const ranked = [...scored].sort((a, b) => b.score - a.score);
149
+ const keep = new Map<number, PickedPassage>();
150
+ let remaining = budgetChars;
151
+ for (const { p, score } of ranked) {
152
+ if (score <= 0 || remaining <= 0) break;
153
+ const cost = Math.min(p.text.length, remaining);
154
+ if (cost < 80 && p.text.length > cost) break;
155
+ keep.set(p.pos, {
156
+ heading: p.heading,
157
+ text: p.text.length > cost ? `${p.text.slice(0, cost)}…` : p.text,
158
+ score,
159
+ });
160
+ remaining -= cost + p.heading.length + 20;
161
+ }
162
+ // drop the intro from keep — we prepend it explicitly below
163
+ keep.delete(0);
164
+ const picked = [...keep.entries()]
165
+ .sort((a, b) => a[0] - b[0])
166
+ .map(([, v]) => v);
167
+ picked.unshift({ heading: intro.heading, text: intro.text.slice(0, introChars), score: 0 });
168
+ return { picked, total: all.length };
169
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-webfind",
3
+ "version": "0.5.0",
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
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "web-search",
9
+ "websearch",
10
+ "claude-code",
11
+ "research",
12
+ "free",
13
+ "duckduckgo",
14
+ "brave",
15
+ "fetch",
16
+ "webfind"
17
+ ],
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/jawwadzafar/pi-webfind.git"
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./extensions"
26
+ ],
27
+ "image": "https://jawwadzafar.github.io/pi-webfind/screenshot.png",
28
+ "video": "https://jawwadzafar.github.io/pi-webfind/demo.mp4"
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "typebox": "*"
33
+ },
34
+ "files": [
35
+ "extensions/",
36
+ "lib/",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "author": "Jawwad Zafar <zafarjawwad@gmail.com>",
44
+ "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
+ "bugs": {
54
+ "url": "https://github.com/jawwadzafar/pi-webfind/issues"
55
+ }
56
+ }