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 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. Truncation is layered: sections over 3,000
38
- characters are cut with the tail marked unreachable; pages that overflow are
39
- capped; the final response is clipped. Every cut is marked; the truncated
40
- part is named, not lost.
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.0",
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: opts.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: opts.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(chosen.map((def) => def.search(cfg[def.key] as string, opts, opts.signal)));
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 ? cfg.fetchChain.replace(/,/g, ', ') : 'firecrawl, tavily, exa, jina';
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
  }