pi-wsearch 0.1.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Added
8
+
9
+ - Over-cap sections split into addressable `(cont. N)` outline entries; no section tail is ever unreachable.
10
+ - Promo/nag blocks (premium upsells, newsletter signups) filtered from outline and section bodies.
11
+ - Adjacent verbatim-duplicate paragraphs dropped.
12
+ - Headingless pages get a synthesized `(lead)` outline entry.
13
+ - Heading-only stub sections (page chrome like "## Latest commit", "### Stars" with no body) dropped — a real section carries prose under its heading.
14
+ - GitHub repo pages serve the raw README under the original URL (repo pages are ~90% UI chrome); filename candidates tried in order, graceful fall-through to the normal page path on failure; non-root paths (blob/tree/issues) unaffected.
15
+ - Local extraction parity with paid readers: HTML tables → markdown pipe tables (structure preserved instead of flattened to word soup); MathML formulas → embedded TeX when annotated; KaTeX span soup (client-rendered, no TeX source in HTML) dropped instead of linearizing into scrambled fragments.
16
+ - News-page hard-sample cleanup: per-line junk filtering (photo credits like "Chip Somodevilla / Getty Images file", orphan "38d ago" card fragments, IE-browser nags, "Skip to Content" links, "FOR SUBSCRIBERS" nags), ALL-CAPS card kickers, empty `##` heading debris, bullet leader-line collapsing, and `<title>`-echo blocks dropped — NBC/CBS-class hub pages now open directly on their story list.
17
+ - Broader hard-sample sweep (forums, docs SPAs, helpdesks, recipes, e-commerce): newsletter signup modules ("#### Email Newsletter" + email-field trust lines) dropped; access-notice pages (People Inc-style HTTP 402 with an HTML notice) treated as bot-walls — UA retry + chain fallback, and their notice text can never pass as content.
18
+ - Keyless jina reader tier: runs without a key when jina is explicitly named in `WSEARCH_FETCH_CHAIN` (opt-in, rate-limited ~20 req/min); a configured key also enables it.
19
+ - Reader backends for kagi (Universal Summarizer — last in chain: lossy summaries never pre-empt full extraction; covers PDFs/office docs/YouTube), TinyFish (Fetch API, free at any balance), serper (Webpage Scrape) and You.com (Contents API) — key-gated, docs-verified contracts.
20
+ - Junk list covers social-syndication mirrors (twiscan, twstalker, unrollnow, threadreaderapp, nitter, upd.dev); any numeric `status|thread/<id>` path on any host is junk (uniform rule, no favored-host allowlist).
21
+ - `engines_errored` structured field plus `⚠ engines:` diagnostics line on search output; engines with a stable 401/402/403 error are skipped for the rest of the process (memo cleared on `/websearch login|logout`).
22
+ - Reader-chain exhaustion now reports every backend's error, not just the last one.
23
+ - Intrinsic BM25-flavored lexical rescoring in the fuse stage (title hits 2x, IDF-lite rarity via token length, bounded 0.25 multiplier): single-engine hits with strong query overlap reorder near-ties; multi-engine agreement still dominates. Deterministic, model-free.
24
+ - `/websearch` onboarding lists jina's free tier among the cheapest paths.
25
+ - Observe-only fetch-through ledger (`wsearch/fetchtrace.jsonl`): search records each result's rank/engine-agreement; a successful first fetch of a search-surfaced URL appends the tuple (query, rank, engines, url). Pure measurement — zero behavior change — the ground-truth layer for judging future ranking changes (top-3 fetch-decision precision).
26
+ - URL canonicalization depth-up: paths case-folded and duplicate slashes collapsed (server-equivalent spellings no longer split RRF agreement across "different" URLs).
27
+ - Mojibake detection and repair in the byte-decode path: UTF-8-read-as-CP1252 double-encoding (declared charset lies, upstream proxy mangling) is detected by signature density and re-decoded; clean and declared-latin1 pages pass through untouched.
28
+ - Stale-if-error cache reserve: expired entries survive as a reserve instead of being purged; when a previously-readable page's live fetch fails (wall tightened, host down), the expired copy is served with a `[STALE — ~Nh old]` title marker rather than an error. `get()` never serves expired copies; the reserve is replaced on overwrite and capped by the LRU cap.
29
+ - FineWeb/Gopher repetition gates on extracted text: >30% duplicate lines or >20% repeated unique 5-grams marks the output low-quality (catches link-farms and menu-soup that survive every pattern filter; published thresholds, not invented ones).
30
+ - Conditional revalidation (ETag/If-None-Match, Last-Modified/If-Modified-Since): cached copies store their validators; an expired copy revalidates before re-download — a 304 re-marks it fresh for free (local path only; reader-chain results carry no validators).
31
+ - Fetched pages now carry `publishedAt` (JSON-LD datePublished → article:published_time meta → <time datetime>, first match wins) and a `paywalled: true` flag when soft-paywall overlay signatures (fc-ab-root, metering-modal, isAccessibleForFree:false) are present — detection only, never bypass.
32
+ - GitHub blob URLs (single source files) serve the raw file content under the original URL, same contract as the README preference — the repo-page path buried files under navigation UI.
33
+ - Cookie-consent banner boilerplate ("We use cookies to improve…", "By continuing to browse…", "Accept all", "Manage preferences") filtered.
34
+ - Page-wide duplicate-paragraph removal: paragraphs repeated verbatim anywhere on a page are dropped from their later occurrences (adjacent-dedupe caught only back-to-back copies; hub pages re-serve card text across sections — pure token waste).
35
+ - Accept header prefers `text/markdown` over HTML: sites with server-side content negotiation (agent-readability ecosystem) now hand over their clean markdown rendition; non-negotiating servers ignore the header entirely.
36
+ - Zero-result recovery: when a search returns nothing, one automatic retry runs with operators stripped (`site:`, quotes, `-term`) or the rarest term dropped — the agent no longer burns a round-trip to do it manually; the retry is noted in the output.
37
+ - `site:` operator normalization: routed to native domain-filter params where supported (tavily `include_domains`, exa `includeDomains`), passed through verbatim for engines with native operator support (serper, brave, kagi) — no more diluting domain-restricted queries into full-web text search.
38
+ - Search result titles strip the site-name suffix ("Page Title | Site Name") — display noise and title-token pollution.
39
+ - Search results display a normalized date (ISO, from engine date fields incl. relative forms like "3 days ago") when the engine provides one.
40
+ - Archive fallback for dead pages: a 404 (dead host, removed docs) tries archive.org's Wayback availability API and serves the snapshot through the normal local path, title-marked `[ARCHIVED copy <date> — live page is gone]`. Doctrine line: 404/dead-host only, never 403-walls; best-effort (lookup failure → plain error as before).
41
+
42
+ ### Changed
43
+
44
+ - Fetch cache key bumped to `v3` (extraction pipeline shape changed).
45
+ - `OUTLINE_CAP` raised from 14 to 20.
46
+ - Reader chain default order: jina (free tier) → tinyfish (free at any balance) → firecrawl → tavily → exa → serper → you → kagi (lossy summarizer last).
47
+ - Permanent local-failure guard narrowed to 404/oversized/empty: 403 bot-walls now advance to the chain (renderers exist to read them); `r.jina.ai`/`s.jina.ai` proxy URLs never enter the chain.
48
+ - Search engines pin `WSEARCH_ENGINES` and readers pin `WSEARCH_FETCH_CHAIN` at runtime as before; chain status display mirrors the new default order.
49
+
50
+ ### Fixed
51
+
52
+ - `splitSection` infinite loop on space-free runs (base64 blobs, packed table cells) — cut enforces a hard progress floor.
53
+ - Dropped tiny preambles (e.g. leaked `<title>` text) no longer shift heading names off by one.
54
+ - Reader chain was spent on permanent local failures (404/oversized/empty URLs) — now returns the real status instantly instead of burning paid backends.
55
+ - Jina reader gate contradicted its keyless-capable read implementation (blocked keyless reads despite tolerant code) — gate aligned to the explicit opt-in.
56
+ - Dead engines (e.g. firecrawl with exhausted credits) dragged every search fan-out ~7s — skipped after the first stable failure.
57
+ - `/websearch off|on` withdrew/restored tools at registration only, leaving them declared to the model until a manual reload — the active tool set is now synced via `pi.setActiveTools()` so the change applies on the next model request.
58
+ - Fetching `r.jina.ai/<target>` sent paid renderers at a proxy URL (a renderer cannot render a proxy) — now fails fast with the proxy's own status.
59
+
60
+ ### Removed
61
+
62
+ - `CAP_BREAK_GRACE` boundary heuristic (subsumed by the progress floor).
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.0",
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": {}
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
+ }
63
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
  }