pi-wsearch 0.1.0 → 0.2.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 +4 -4
- package/package.json +14 -3
- package/src/cache.ts +10 -11
- package/src/config.ts +2 -0
- package/src/engines.ts +33 -4
- package/src/fuse.ts +33 -0
- package/src/keys.ts +4 -1
- package/src/scrape.ts +395 -38
- package/src/tools.ts +159 -13
- package/src/transport.ts +229 -16
- package/src/urls.ts +20 -1
- package/justfile +0 -31
package/README.md
CHANGED
|
@@ -34,10 +34,10 @@ you to find the signal. `web.fetch` breaks any page into an outline of every
|
|
|
34
34
|
section and answers by number: `{url, sections: [0, 3]}` fetches just two
|
|
35
35
|
sections of the fetched page. The agent surveys the map, picks what matters,
|
|
36
36
|
and reads only those. No
|
|
37
|
-
full reads, no 40 KB dumps.
|
|
38
|
-
|
|
39
|
-
capped; the final response is clipped.
|
|
40
|
-
|
|
37
|
+
full reads, no 40 KB dumps. Sections over 3,000 characters split into
|
|
38
|
+
addressable continuation entries (`Section (cont. 1)`), so no tail is ever
|
|
39
|
+
unreachable; pages that overflow are capped; the final response is clipped.
|
|
40
|
+
Every cut is marked — nothing is silently lost.
|
|
41
41
|
|
|
42
42
|
**Reads are free.** Page parsing is built in, nothing billed per read, no key
|
|
43
43
|
required. Real headings become the map, TOC echoes get dropped, so reads come
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-wsearch",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Pi extension: web.search + web.fetch",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,5 +59,16 @@
|
|
|
59
59
|
"oxlint": "^1.86.0",
|
|
60
60
|
"typescript": "^7.0.2"
|
|
61
61
|
},
|
|
62
|
-
"dependencies": {}
|
|
63
|
-
|
|
62
|
+
"dependencies": {},
|
|
63
|
+
"main": "index.ts",
|
|
64
|
+
"files": [
|
|
65
|
+
"index.ts",
|
|
66
|
+
"src/",
|
|
67
|
+
"README.md",
|
|
68
|
+
"LICENSE",
|
|
69
|
+
"!CHANGELOG.md"
|
|
70
|
+
],
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/cache.ts
CHANGED
|
@@ -28,11 +28,6 @@ export class TtlCache {
|
|
|
28
28
|
const raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
29
29
|
if (raw && typeof raw === 'object' && typeof raw.entries === 'object') {
|
|
30
30
|
this.store = raw;
|
|
31
|
-
const now = Date.now();
|
|
32
|
-
// purge expired entries AND their order slots: dead keys must not grow across restarts
|
|
33
|
-
for (const k of Object.keys(this.store.entries)) {
|
|
34
|
-
if ((this.store.entries[k]?.exp ?? 0) <= now) delete this.store.entries[k];
|
|
35
|
-
}
|
|
36
31
|
this.store.order = this.store.order.filter((k) => this.store.entries[k] !== undefined);
|
|
37
32
|
}
|
|
38
33
|
} catch {
|
|
@@ -51,17 +46,21 @@ export class TtlCache {
|
|
|
51
46
|
}
|
|
52
47
|
get(key: string): unknown | undefined {
|
|
53
48
|
const e = this.store.entries[key];
|
|
54
|
-
if (!e) return undefined;
|
|
55
|
-
if (e.exp < Date.now()) {
|
|
56
|
-
delete this.store.entries[key];
|
|
57
|
-
this.store.order = this.store.order.filter((k) => k !== key);
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
49
|
+
if (!e || e.exp < Date.now()) return undefined;
|
|
60
50
|
const i = this.store.order.indexOf(key);
|
|
61
51
|
if (i >= 0) this.store.order.splice(i, 1);
|
|
62
52
|
this.store.order.push(key);
|
|
63
53
|
return e.value;
|
|
64
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Expired copy WITHOUT deleting it (stale-if-error reserve). */
|
|
57
|
+
peekStale(key: string): { value: unknown; ageHours: number } | undefined {
|
|
58
|
+
const e = this.store.entries[key];
|
|
59
|
+
if (!e) return undefined;
|
|
60
|
+
const ageHours = (Date.now() - e.exp) / 3600_000;
|
|
61
|
+
if (ageHours <= 0) return undefined;
|
|
62
|
+
return { value: e.value, ageHours };
|
|
63
|
+
}
|
|
65
64
|
set(key: string, value: unknown, ttlMs: number) {
|
|
66
65
|
this.store.entries[key] = { exp: Date.now() + ttlMs, value };
|
|
67
66
|
const i = this.store.order.indexOf(key);
|
package/src/config.ts
CHANGED
|
@@ -181,6 +181,8 @@ function wsearchDir(): string {
|
|
|
181
181
|
return process.env.WSEARCH_CACHE_DIR ?? join(homedir(), '.pi', 'agent', 'wsearch');
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
export { wsearchDir };
|
|
185
|
+
|
|
184
186
|
let cached: WebConfig | undefined;
|
|
185
187
|
let origins: Record<string, 'env' | 'wsearch' | 'shell'> = {};
|
|
186
188
|
export function resetConfigCache(): void {
|
package/src/engines.ts
CHANGED
|
@@ -75,6 +75,18 @@ async function request<T>(
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function extractSiteOperator(query: string): { query: string; domain?: string } {
|
|
79
|
+
const m = /(?:^|\s)site:([a-z0-9.-]+[a-z0-9])(?:\s|$)/i.exec(query);
|
|
80
|
+
if (!m) return { query };
|
|
81
|
+
return {
|
|
82
|
+
query: query
|
|
83
|
+
.replace(m[0], ' ')
|
|
84
|
+
.replace(/\s{2,}/g, ' ')
|
|
85
|
+
.trim(),
|
|
86
|
+
domain: m[1].toLowerCase(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
async function serper(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
79
91
|
const t0 = Date.now();
|
|
80
92
|
try {
|
|
@@ -104,12 +116,14 @@ async function serper(key: string, opts: SearchOptions, signal?: AbortSignal): P
|
|
|
104
116
|
async function tavily(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
105
117
|
const t0 = Date.now();
|
|
106
118
|
try {
|
|
119
|
+
const { query, domain } = extractSiteOperator(opts.query);
|
|
107
120
|
const body: Record<string, unknown> = {
|
|
108
|
-
query
|
|
121
|
+
query,
|
|
109
122
|
max_results: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
110
123
|
search_depth: 'basic',
|
|
111
124
|
topic: 'general',
|
|
112
125
|
};
|
|
126
|
+
if (domain) body.include_domains = [domain];
|
|
113
127
|
// Docs: auth = Authorization: Bearer header
|
|
114
128
|
const d = await postJson<{
|
|
115
129
|
results?: { title?: string; url?: string; content?: string; published_date?: string }[];
|
|
@@ -132,11 +146,13 @@ async function tavily(key: string, opts: SearchOptions, signal?: AbortSignal): P
|
|
|
132
146
|
async function exa(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
133
147
|
const t0 = Date.now();
|
|
134
148
|
try {
|
|
149
|
+
const { query, domain } = extractSiteOperator(opts.query);
|
|
135
150
|
const body: Record<string, unknown> = {
|
|
136
|
-
query
|
|
151
|
+
query,
|
|
137
152
|
numResults: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
138
153
|
contents: { highlights: true },
|
|
139
154
|
};
|
|
155
|
+
if (domain) body.includeDomains = [domain];
|
|
140
156
|
const d = await postJson<{
|
|
141
157
|
results?: { title?: string; url?: string; highlights?: string[]; publishedDate?: string }[];
|
|
142
158
|
}>('https://api.exa.ai/search', body, jsonHeaders({ 'x-api-key': key }), signal);
|
|
@@ -360,6 +376,13 @@ function parseEngineList(raw: string | undefined): EngineName[] | undefined {
|
|
|
360
376
|
return out.length ? out : undefined;
|
|
361
377
|
}
|
|
362
378
|
|
|
379
|
+
const deadEngines = new Set<string>();
|
|
380
|
+
const STABLE_ENGINE_ERROR = /HTTP 40[123]/;
|
|
381
|
+
|
|
382
|
+
export function clearDeadEngines(): void {
|
|
383
|
+
deadEngines.clear();
|
|
384
|
+
}
|
|
385
|
+
|
|
363
386
|
function selectEngines(opts: SearchOptions): EngineDef[] {
|
|
364
387
|
const cfg = getConfig();
|
|
365
388
|
const wanted = opts.engines ?? parseEngineList(cfg.searchEngines);
|
|
@@ -372,7 +395,7 @@ function selectEngines(opts: SearchOptions): EngineDef[] {
|
|
|
372
395
|
|
|
373
396
|
export async function runEngines(opts: SearchOptions): Promise<EngineOutcome[]> {
|
|
374
397
|
const cfg = getConfig();
|
|
375
|
-
const chosen = selectEngines(opts);
|
|
398
|
+
const chosen = selectEngines(opts).filter((def) => !deadEngines.has(def.id));
|
|
376
399
|
if (chosen.length === 0) {
|
|
377
400
|
const wanted = opts.engines ?? parseEngineList(getConfig().searchEngines);
|
|
378
401
|
const error = wanted?.length
|
|
@@ -380,5 +403,11 @@ export async function runEngines(opts: SearchOptions): Promise<EngineOutcome[]>
|
|
|
380
403
|
: 'no search engine keys configured; run /websearch to add one';
|
|
381
404
|
return [{ engine: 'none', hits: [], error, latencyMs: 0 }];
|
|
382
405
|
}
|
|
383
|
-
return Promise.all(
|
|
406
|
+
return Promise.all(
|
|
407
|
+
chosen.map(async (def) => {
|
|
408
|
+
const outcome = await def.search(cfg[def.key] as string, opts, opts.signal);
|
|
409
|
+
if (outcome.error && STABLE_ENGINE_ERROR.test(outcome.error)) deadEngines.add(def.id);
|
|
410
|
+
return outcome;
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
384
413
|
}
|
package/src/fuse.ts
CHANGED
|
@@ -148,6 +148,36 @@ function exactMatchBoost(hit: { title: string; snippet: string }, query: string
|
|
|
148
148
|
return 0.3 * Math.min(1, matched / terms.length);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
function bm25Score(hit: { title: string; snippet: string }, terms: string[]): number {
|
|
152
|
+
if (!terms.length) return 0;
|
|
153
|
+
const title = hit.title.toLowerCase();
|
|
154
|
+
const snip = hit.snippet.toLowerCase();
|
|
155
|
+
let score = 0;
|
|
156
|
+
for (const t of terms) {
|
|
157
|
+
const tfTitle = countTerm(t, title);
|
|
158
|
+
const tfSnip = countTerm(t, snip);
|
|
159
|
+
if (!tfTitle && !tfSnip) continue;
|
|
160
|
+
const idf = 1 + Math.min(1, 12 / Math.max(t.length, 1));
|
|
161
|
+
const saturate = (tf: number) => (tf * 2.2) / (tf + 1.2);
|
|
162
|
+
score += idf * (2 * saturate(tfTitle) + saturate(tfSnip));
|
|
163
|
+
}
|
|
164
|
+
return score;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function countTerm(term: string, text: string): number {
|
|
168
|
+
let n = 0;
|
|
169
|
+
let i = 0;
|
|
170
|
+
for (;;) {
|
|
171
|
+
const m = text.indexOf(term, i);
|
|
172
|
+
if (m < 0) break;
|
|
173
|
+
const before = m === 0 ? ' ' : text[m - 1];
|
|
174
|
+
const after = m + term.length >= text.length ? ' ' : text[m + term.length];
|
|
175
|
+
if (!/[a-z0-9]/.test(before) && !/[a-z0-9]/.test(after)) n++;
|
|
176
|
+
i = m + term.length;
|
|
177
|
+
}
|
|
178
|
+
return n;
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
function normTitle(s: string): string {
|
|
152
182
|
return s
|
|
153
183
|
.toLowerCase()
|
|
@@ -188,6 +218,7 @@ function collapseNearDuplicates(fused: FusedHit[]): FusedHit[] {
|
|
|
188
218
|
|
|
189
219
|
export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHit[] {
|
|
190
220
|
const { query } = opts;
|
|
221
|
+
const terms = query ? queryTerms(query) : [];
|
|
191
222
|
const rank: Record<string, { hit: SearchHit; ranks: { engine: string; rank: number }[] }> = {};
|
|
192
223
|
let answering = 0;
|
|
193
224
|
for (const oc of outcomes) {
|
|
@@ -212,6 +243,8 @@ export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHi
|
|
|
212
243
|
const consensus = answering > 0 ? ranks.length / answering : 0;
|
|
213
244
|
let score = rrf + CONSENSUS_TIEBREAK * consensus;
|
|
214
245
|
score *= 1 + exactMatchBoost(hit, query);
|
|
246
|
+
const bm25 = bm25Score(hit, terms);
|
|
247
|
+
if (bm25 > 0) score *= 1 + Math.min(0.25, 0.045 * bm25);
|
|
215
248
|
if (hit.snippet.trim().length < 30) score *= 0.92;
|
|
216
249
|
if (junk) score *= 0.25;
|
|
217
250
|
fused.push({
|
package/src/keys.ts
CHANGED
|
@@ -83,7 +83,9 @@ export function statusText(): string {
|
|
|
83
83
|
armed.length ? ` armed: ${armed.map((id) => `${id} ✓(${origins[id] ?? '?'})`).join(', ')}` : ' armed: (none)',
|
|
84
84
|
);
|
|
85
85
|
lines.push(` dormant: ${dormant.length ? dormant.join(', ') : '(none)'}`);
|
|
86
|
-
const chain = cfg.fetchChain
|
|
86
|
+
const chain = cfg.fetchChain
|
|
87
|
+
? cfg.fetchChain.replace(/,/g, ', ')
|
|
88
|
+
: 'jina, tinyfish, firecrawl, tavily, exa, serper, you, kagi';
|
|
87
89
|
lines.push(` fetch: local → ${chain}`);
|
|
88
90
|
const knobs = [];
|
|
89
91
|
if (cfg.searchEngines) knobs.push(`WSEARCH_ENGINES=${cfg.searchEngines}`);
|
|
@@ -97,6 +99,7 @@ export function statusText(): string {
|
|
|
97
99
|
lines.push(' · exa → exa.ai (1,000 free/mo)');
|
|
98
100
|
lines.push(' · firecrawl → firecrawl.dev (1,000 free credits/mo)');
|
|
99
101
|
lines.push(' · tinyfish → tinyfish.ai (free at any balance)');
|
|
102
|
+
lines.push(' · jina → jina.ai (free tier, search + reader)');
|
|
100
103
|
lines.push(' 2. /websearch login, saved to wsearch/env');
|
|
101
104
|
lines.push(' 3. run /websearch again; it should appear under "armed"');
|
|
102
105
|
}
|