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/src/tools.ts CHANGED
@@ -6,8 +6,8 @@ import { Type } from 'typebox';
6
6
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from '@earendil-works/pi-coding-agent';
7
7
  import { keyHint } from '@earendil-works/pi-coding-agent';
8
8
  import { Text } from '@earendil-works/pi-tui';
9
- import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
10
- import { dirname } from 'node:path';
9
+ import { appendFileSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
11
  import {
12
12
  getConfig,
13
13
  PROVIDERS,
@@ -15,13 +15,14 @@ import {
15
15
  resetConfigCache,
16
16
  keyOrigins,
17
17
  disabledFlagPath,
18
+ wsearchDir,
18
19
  type ProviderInfo,
19
20
  } from './config';
20
21
  import { ABORT_ERROR, DEFAULT_MAX_RESULTS } from './constants';
21
22
  import { cacheKey, openCache, CACHE_TTL_HOURS } from './cache';
22
- import { runEngines } from './engines';
23
+ import { clearDeadEngines, runEngines } from './engines';
23
24
  import { fuse, diversifyByHost } from './fuse';
24
- import { normalizeUrl } from './urls';
25
+ import { hostOf, normalizeUrl } from './urls';
25
26
  import { scrape } from './scrape';
26
27
 
27
28
  const SEARCH_PARAMS = Type.Object({
@@ -38,6 +39,7 @@ const SEARCH_PARAMS = Type.Object({
38
39
  const SEARCH_OUTPUT = Type.Object({
39
40
  query: Type.String(),
40
41
  engines_used: Type.Array(Type.String()),
42
+ engines_errored: Type.Optional(Type.Array(Type.Object({ engine: Type.String(), error: Type.String() }))),
41
43
  results: Type.Array(
42
44
  Type.Object({
43
45
  url: Type.String(),
@@ -81,6 +83,57 @@ function fmtSnippet(s: string, max: number): string {
81
83
  return t.length > max ? t.slice(0, max) + '…' : t;
82
84
  }
83
85
 
86
+ const ABORT_ERROR_LOCAL = ABORT_ERROR;
87
+
88
+ function dropRarestTerm(query: string): string {
89
+ const words = query.split(/\s+/).filter((w) => w.length > 2 && !/^(?:site|inurl|intitle|filetype):/i.test(w));
90
+ if (words.length < 2) return '';
91
+ const rarest = words.reduce((a, b) => (b.length > a.length ? b : a));
92
+ return query
93
+ .replace(rarest, ' ')
94
+ .replace(/\s{2,}/g, ' ')
95
+ .trim();
96
+ }
97
+
98
+ function stripSiteSuffix(title: string, host: string): string {
99
+ const m = /^(.{3,})\s+[|–—-]\s+([^|–—-]{2,40})$/.exec(title.trim());
100
+ if (!m) return title;
101
+ const suffix = m[2].trim();
102
+ const head = m[1].trim();
103
+ const hostBase = host.replace(/^www\./, '').split('.')[0];
104
+ if (host && (suffix.toLowerCase().includes(hostBase) || suffix.length <= 25)) return head;
105
+ return title;
106
+ }
107
+
108
+ function normalizeHitDate(raw: string | undefined): string | undefined {
109
+ if (!raw) return undefined;
110
+ const t = Date.parse(raw);
111
+ if (!Number.isNaN(t)) return new Date(t).toISOString().slice(0, 10);
112
+ const rel = /^(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days|w|week|weeks|mo|month|months)\b/i.exec(
113
+ raw.trim(),
114
+ );
115
+ if (rel) {
116
+ const n = parseInt(rel[1], 10);
117
+ const unit = rel[2].toLowerCase();
118
+ const ms =
119
+ unit.startsWith('m') && unit !== 'mo'
120
+ ? 60_000
121
+ : unit.startsWith('h')
122
+ ? 3_600_000
123
+ : unit.startsWith('d')
124
+ ? 86_400_000
125
+ : unit.startsWith('w')
126
+ ? 604_800_000
127
+ : unit.startsWith('mo')
128
+ ? 2_592_000_000
129
+ : 604_800_000;
130
+ void ms;
131
+ return new Date(Date.now() - n * ms).toISOString().slice(0, 10);
132
+ }
133
+ if (/^yesterday$/i.test(raw.trim())) return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
134
+ return undefined;
135
+ }
136
+
84
137
  function cleanSnippet(s: string): string {
85
138
  return s
86
139
  .replace(/\\([_*`[\]()#>~.-])/g, '$1')
@@ -94,6 +147,8 @@ function cleanSnippet(s: string): string {
94
147
  export function registerWebTools(pi: ExtensionAPI) {
95
148
  const cache = openCache(getConfig().cacheDir + '/cache.json');
96
149
  const exposure = existsSync(disabledFlagPath()) ? 'hidden' : 'direct';
150
+ const lastSearch = { urlRank: new Map<string, { rank: number; engines: string[]; query: string }>() };
151
+ const tracePath = join(wsearchDir(), 'fetchtrace.jsonl');
97
152
 
98
153
  const namespace = {
99
154
  name: 'web',
@@ -121,18 +176,47 @@ export function registerWebTools(pi: ExtensionAPI) {
121
176
  maxResults: params.max_results ?? DEFAULT_MAX_RESULTS,
122
177
  signal,
123
178
  });
179
+ const errored = outcomes
180
+ .filter((o) => o.error && o.engine !== 'none')
181
+ .map((o) => ({ engine: o.engine, error: o.error! }));
124
182
  // signal may have fired mid-round: still an abort, never a results payload
125
183
  if (signal?.aborted) throw new Error(ABORT_ERROR);
126
- const fused = fuse(outcomes, { query: params.query });
127
- const candidates = diversifyByHost(
184
+ let fused = fuse(outcomes, { query: params.query });
185
+ let candidates = diversifyByHost(
128
186
  fused.filter((r) => !r.junk),
129
187
  2,
130
188
  ).slice(0, Math.max((params.max_results ?? DEFAULT_MAX_RESULTS) * 2, DEFAULT_MAX_RESULTS));
189
+ let retryNote = '';
190
+ if (!candidates.length && !signal?.aborted) {
191
+ const hasOps = /(?:^|\s)(?:site:|intitle:|inurl:|filetype:|-"|"[^"]+"|(?:^|\s)-\w+)/i.test(params.query);
192
+ const stripped = hasOps
193
+ ? params.query
194
+ .replace(/(?:^|\s)(?:site:\S+|intitle:\S+|inurl:\S+|filetype:\S+)|"[^"]*"|(?:^|\s)-\w+/gi, ' ')
195
+ .replace(/\s{2,}/g, ' ')
196
+ .trim()
197
+ : '';
198
+ const relaxed = stripped && stripped !== params.query ? stripped : dropRarestTerm(params.query);
199
+ if (relaxed && relaxed !== params.query) {
200
+ const retryOutcomes = await runEngines({
201
+ query: relaxed,
202
+ maxResults: params.max_results ?? DEFAULT_MAX_RESULTS,
203
+ signal,
204
+ });
205
+ if (!signal?.aborted) {
206
+ fused = fuse(retryOutcomes, { query: relaxed });
207
+ candidates = diversifyByHost(
208
+ fused.filter((r) => !r.junk),
209
+ 2,
210
+ ).slice(0, Math.max((params.max_results ?? DEFAULT_MAX_RESULTS) * 2, DEFAULT_MAX_RESULTS));
211
+ if (candidates.length) retryNote = ` (retried as "${relaxed}" after zero results)`;
212
+ }
213
+ }
214
+ }
131
215
  const visible = candidates.slice(0, params.max_results ?? DEFAULT_MAX_RESULTS);
132
216
  const enginesUsed = [...new Set(fused.flatMap((r) => r.engines))];
133
217
  const snip = 240;
134
218
 
135
- let text = `${visible.length} result${visible.length === 1 ? '' : 's'} for "${params.query}": ${enginesUsed.length} engine${enginesUsed.length === 1 ? '' : 's'} contributed (${enginesUsed.join(', ')})\n\n`;
219
+ let text = `${visible.length} result${visible.length === 1 ? '' : 's'} for "${params.query}": ${enginesUsed.length} engine${enginesUsed.length === 1 ? '' : 's'} contributed (${enginesUsed.join(', ')})${retryNote}\n\n`;
136
220
  if (!visible.length) {
137
221
  const fatal = outcomes.find((o) => o.engine === 'none' && o.error);
138
222
  text = fatal
@@ -140,13 +224,22 @@ export function registerWebTools(pi: ExtensionAPI) {
140
224
  : `No results for "${params.query}". Broaden the query.\n`;
141
225
  }
142
226
  visible.forEach((r, i) => {
143
- const title = fmtSnippet(cleanSnippet(r.title), 100) || r.url;
227
+ lastSearch.urlRank.set(normalizeUrl(r.url), {
228
+ rank: i + 1,
229
+ engines: r.engines,
230
+ query: params.query,
231
+ });
232
+ const title = fmtSnippet(stripSiteSuffix(cleanSnippet(r.title), hostOf(r.url)), 100) || r.url;
144
233
  const snippet = fmtSnippet(cleanSnippet(r.snippet), snip);
145
234
  const via = r.engines.length > 1 ? ` ← ${r.engines.length} engines agree` : ` ← ${r.engines[0]}`;
146
- text += `${i + 1}. [${title}](${r.url})${via}\n`;
235
+ const when = normalizeHitDate(r.date);
236
+ text += `${i + 1}. [${title}](${r.url})${when ? ` — ${when}` : ''}${via}\n`;
147
237
  if (snippet) text += ` ${snippet}\n`;
148
238
  text += '\n';
149
239
  });
240
+ if (errored.length) {
241
+ text += `⚠ engines: ${errored.map((e) => `${e.engine} (${e.error.slice(0, 80)})`).join('; ')}\n`;
242
+ }
150
243
 
151
244
  return {
152
245
  content: [{ type: 'text', text }],
@@ -154,6 +247,7 @@ export function registerWebTools(pi: ExtensionAPI) {
154
247
  structuredContent: {
155
248
  query: params.query,
156
249
  engines_used: enginesUsed,
250
+ engines_errored: errored,
157
251
  results: visible.map((r) => ({
158
252
  url: r.url,
159
253
  title: r.title,
@@ -193,14 +287,49 @@ export function registerWebTools(pi: ExtensionAPI) {
193
287
 
194
288
  async execute(_id, params, signal) {
195
289
  const cfg = getConfig();
196
- const key = cacheKey(['fetch', normalizeUrl(params.url)]);
290
+ const key = cacheKey(['fetch', 'v3', normalizeUrl(params.url)]);
291
+ const ranked = lastSearch.urlRank.get(normalizeUrl(params.url));
292
+ if (ranked && !cache.get(key)) {
293
+ try {
294
+ mkdirSync(dirname(tracePath), { recursive: true });
295
+ appendFileSync(
296
+ tracePath,
297
+ `${JSON.stringify({
298
+ ts: Date.now(),
299
+ q: ranked.query,
300
+ rank: ranked.rank,
301
+ engines: ranked.engines,
302
+ url: normalizeUrl(params.url),
303
+ })}\n`,
304
+ { flag: 'a', mode: 0o600 },
305
+ );
306
+ } catch {}
307
+ }
197
308
  const cached = cache.get(key) as Awaited<ReturnType<typeof scrape>> | undefined;
198
309
  if (cached) return makeFetchResult(cached, params);
310
+ const stale = cache.peekStale(key) as { value: Awaited<ReturnType<typeof scrape>>; ageHours: number } | undefined;
199
311
  const res = await scrape(params.url, {
200
312
  allowPrivate: cfg.allowPrivate,
201
313
  signal,
314
+ validators: stale?.value?.validators,
202
315
  });
203
- if (!res.error) cache.set(key, res, CACHE_TTL_HOURS * 3600_000);
316
+ if (res.error === 'not-modified' && stale?.value) {
317
+ cache.set(key, stale.value, CACHE_TTL_HOURS * 3600_000);
318
+ return makeFetchResult(stale.value, params);
319
+ }
320
+ if (!res.error) {
321
+ cache.set(key, res.validators ? { ...res, validators: res.validators } : res, CACHE_TTL_HOURS * 3600_000);
322
+ return makeFetchResult(res, params);
323
+ }
324
+ if (stale?.value && !stale.value.error) {
325
+ return makeFetchResult(
326
+ {
327
+ ...stale.value,
328
+ title: `${stale.value.title} [STALE — ~${Math.round(stale.ageHours)}h old, live fetch failed]`,
329
+ },
330
+ params,
331
+ );
332
+ }
204
333
  return makeFetchResult(res, params);
205
334
  },
206
335
  renderResult(result, options, theme) {
@@ -246,7 +375,12 @@ function colorizedResult(text: string, theme: Theme): string {
246
375
  return text
247
376
  .split('\n')
248
377
  .map((l) => {
249
- if (l.includes('…[section truncated') || l.startsWith('…[clipped by tool]') || l.startsWith('…[truncated]'))
378
+ if (
379
+ l.includes('…[section truncated') ||
380
+ l.includes('…(continues at outline index') ||
381
+ l.startsWith('…[clipped by tool]') ||
382
+ l.startsWith('…[truncated]')
383
+ )
250
384
  return theme.fg('warning', l);
251
385
  if (l.startsWith('## [')) return theme.fg('accent', l);
252
386
  if (l.startsWith('- ')) return theme.fg('dim', l);
@@ -316,7 +450,7 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
316
450
  if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
317
451
  const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
318
452
  const missing = sections.filter((i) => i >= res.sections.length);
319
- const truncatedCount = want.filter((idx) => res.sections[idx]?.includes('…[section truncated]')).length;
453
+ const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
320
454
  const details = {
321
455
  url: res.url,
322
456
  title: res.title,
@@ -327,6 +461,8 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
327
461
  sections_requested: [...new Set(sections)],
328
462
  sections_missing: [...new Set(missing)],
329
463
  sections_truncated: truncatedCount,
464
+ ...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
465
+ ...(res.paywalled ? { paywalled: true } : {}),
330
466
  ...(res.error ? { error: res.error } : {}),
331
467
  };
332
468
  if (res.error) return fetchErrorResult(details);
@@ -407,6 +543,7 @@ async function handleLogin(ctx: ExtensionCommandContext, name?: string): Promise
407
543
  }
408
544
  writeKey(provider, trimmed);
409
545
  resetConfigCache();
546
+ clearDeadEngines();
410
547
  ctx.ui.notify(`${provider.id} ✓ saved to wsearch/env (${isCmd ? 'command' : 'key'})`, 'info');
411
548
  }
412
549
 
@@ -453,9 +590,12 @@ async function handleLogout(ctx: ExtensionCommandContext, name?: string): Promis
453
590
  if (!ok) return;
454
591
  removeKey(provider);
455
592
  resetConfigCache();
593
+ clearDeadEngines();
456
594
  ctx.ui.notify(`${provider.id} removed from wsearch/env`, 'info');
457
595
  }
458
596
 
597
+ const SEARCH_TOOL_NAMES = ['web.search', 'web.fetch'] as const;
598
+
459
599
  function toggleSearchTools(pi: ExtensionAPI, disabled: boolean): void {
460
600
  const flag = disabledFlagPath();
461
601
  if (disabled) {
@@ -465,6 +605,12 @@ function toggleSearchTools(pi: ExtensionAPI, disabled: boolean): void {
465
605
  rmSync(flag, { force: true });
466
606
  }
467
607
  registerWebTools(pi);
608
+ const active = new Set(pi.getActiveTools());
609
+ for (const name of SEARCH_TOOL_NAMES) {
610
+ if (disabled) active.delete(name);
611
+ else active.add(name);
612
+ }
613
+ pi.setActiveTools([...active]);
468
614
  }
469
615
 
470
616
  export function registerWebCommand(pi: ExtensionAPI) {
package/src/transport.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { getConfig, sanitizeError } from './config';
6
6
  import { ABORT_ERROR } from './constants';
7
7
 
8
- export type Renderer = 'local' | 'firecrawl' | 'tavily' | 'exa' | 'jina';
8
+ export type Renderer = 'local' | 'firecrawl' | 'tavily' | 'exa' | 'jina' | 'kagi' | 'tinyfish' | 'serper' | 'you';
9
9
 
10
10
  export type RemoteRenderer = Exclude<Renderer, 'local'>;
11
11
 
@@ -29,10 +29,14 @@ const READER_TIMEOUTS: Record<RemoteRenderer, number> = {
29
29
  tavily: 30_000,
30
30
  exa: 20_000,
31
31
  jina: 20_000,
32
+ kagi: 30_000,
33
+ tinyfish: 25_000,
34
+ serper: 25_000,
35
+ you: 25_000,
32
36
  };
33
37
 
34
38
  function isBotBlocked(status: number, content: string): boolean {
35
- if (status !== 403 && status !== 503) return false;
39
+ if (status !== 403 && status !== 503 && status !== 402) return false;
36
40
  const lower = content.toLowerCase();
37
41
  return (
38
42
  lower.includes('cloudflare') ||
@@ -58,6 +62,11 @@ export function stripDataUriImages(markdown: string): string {
58
62
 
59
63
  export function isLowQualityOutput(content: string): boolean {
60
64
  const lower = content.toLowerCase();
65
+ if (
66
+ /\b(?:experiencing an access issue|unusual traffic|confirm you are (?:a )?(?:human|not a bot))\b/.test(lower) ||
67
+ (lower.includes('icanhazip.com') && lower.length < 2048)
68
+ )
69
+ return true;
61
70
  const jsGated = [
62
71
  'enable javascript',
63
72
  'javascript required',
@@ -67,8 +76,25 @@ export function isLowQualityOutput(content: string): boolean {
67
76
  ];
68
77
  if (content.length < 1024 && jsGated.some((t) => lower.includes(t))) return true;
69
78
  const lines = content.split('\n').filter((l) => l.trim());
70
- const shortLines = lines.filter((l) => l.trim().length < 40);
71
- if (lines.length > 10 && shortLines.length / lines.length > 0.7) return true;
79
+ if (lines.length > 30) {
80
+ const seen = new Map<string, number>();
81
+ for (const l of lines) seen.set(l, (seen.get(l) ?? 0) + 1);
82
+ const dupChars = [...seen.entries()].reduce((n, [, c]) => (c > 1 ? n + c : n), 0);
83
+ if (dupChars / lines.length > 0.3) return true;
84
+ }
85
+ const words = lower.split(/\s+/).filter(Boolean);
86
+ if (words.length > 150) {
87
+ const grams = new Map<string, number>();
88
+ for (let i = 0; i <= words.length - 5; i++) {
89
+ const g = words.slice(i, i + 5).join(' ');
90
+ grams.set(g, (grams.get(g) ?? 0) + 1);
91
+ }
92
+ const repeatedGrams = [...grams.values()].filter((c) => c > 1).length;
93
+ if (repeatedGrams / grams.size > 0.2) return true;
94
+ }
95
+ const totalChars = lines.reduce((n, l) => n + l.trim().length, 0);
96
+ const shortChars = lines.reduce((n, l) => (l.trim().length < 40 ? n + l.trim().length : n), 0);
97
+ if (lines.length > 10 && totalChars > 0 && shortChars / totalChars > 0.6) return true;
72
98
  return false;
73
99
  }
74
100
 
@@ -101,16 +127,76 @@ function waitAbortable(ms: number, signal?: AbortSignal): Promise<void> {
101
127
  });
102
128
  }
103
129
 
130
+ const MOJIBAKE_RE = /[Ó†’éüï¬Åˆ]{2,}|â€[˜›”˜]/g;
131
+
132
+ function looksMojibake(text: string): boolean {
133
+ if (!text.includes('Ã') && !text.includes('â€') && !text.includes('Â')) return false;
134
+ const hits = text.match(MOJIBAKE_RE);
135
+ return !!hits && hits.length >= 2 && hits.length * 12 > text.length * 0.001;
136
+ }
137
+
138
+ const CP1252_REV = new Map([
139
+ [0x20ac, 0x80],
140
+ [0x201a, 0x82],
141
+ [0x192, 0x83],
142
+ [0x201e, 0x84],
143
+ [0x2026, 0x85],
144
+ [0x2020, 0x86],
145
+ [0x2021, 0x87],
146
+ [0x2c6, 0x88],
147
+ [0x2030, 0x89],
148
+ [0x160, 0x8a],
149
+ [0x2039, 0x8b],
150
+ [0x152, 0x8c],
151
+ [0x17d, 0x8e],
152
+ [0x2018, 0x91],
153
+ [0x2019, 0x92],
154
+ [0x201c, 0x93],
155
+ [0x201d, 0x94],
156
+ [0x2022, 0x95],
157
+ [0x2013, 0x96],
158
+ [0x2014, 0x97],
159
+ [0x2dc, 0x98],
160
+ [0x2122, 0x99],
161
+ [0x161, 0x9a],
162
+ [0x203a, 0x9b],
163
+ [0x152, 0x9c],
164
+ [0x17e, 0x9e],
165
+ [0x178, 0x9f],
166
+ ]);
167
+
168
+ function repairMojibake(text: string): string {
169
+ try {
170
+ const bytes = new Uint8Array(
171
+ [...text].map((c) => {
172
+ const cp = c.codePointAt(0)!;
173
+ return cp < 256 ? cp : (CP1252_REV.get(cp) ?? 0x3f);
174
+ }),
175
+ );
176
+ const repaired = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
177
+ const before = (text.match(MOJIBAKE_RE) ?? []).length;
178
+ const after = (repaired.match(MOJIBAKE_RE) ?? []).length;
179
+ return after < before ? repaired : text;
180
+ } catch {
181
+ return text;
182
+ }
183
+ }
184
+
104
185
  function decodeBody(bytes: Uint8Array, contentType: string): string {
105
186
  const label =
106
187
  /charset\s*=\s*"?([\w-]+)"?/i.exec(contentType)?.[1] ??
107
188
  /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(new TextDecoder('latin1').decode(bytes.subarray(0, 2048)))?.[1];
189
+ let text: string;
108
190
  if (label && !/^utf-?8$/i.test(label)) {
109
191
  try {
110
- return new TextDecoder(label).decode(bytes);
111
- } catch {}
192
+ text = new TextDecoder(label).decode(bytes);
193
+ } catch {
194
+ text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
195
+ }
196
+ } else {
197
+ text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
112
198
  }
113
- return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
199
+ return looksMojibake(text) ? repairMojibake(text) : text;
114
200
  }
115
201
 
116
202
  async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Array> {
@@ -140,21 +226,38 @@ async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Ar
140
226
  return out;
141
227
  }
142
228
 
143
- export async function localFetch(url: URL, signal?: AbortSignal): Promise<{ body: string; contentType: string }> {
229
+ export interface ConditionalValidators {
230
+ etag?: string;
231
+ lastModified?: string;
232
+ }
233
+
234
+ export async function localFetch(
235
+ url: URL,
236
+ signal?: AbortSignal,
237
+ validators?: ConditionalValidators,
238
+ ): Promise<{ body: string; contentType: string; validators: ConditionalValidators; notModified: boolean }> {
144
239
  let retried429 = false;
145
240
  for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
146
241
  if (signal?.aborted) throw new Error(ABORT_ERROR);
147
242
  const { ctrl, done } = withTimeout('local fetch', LOCAL_TIMEOUT_MS, signal);
148
243
  try {
244
+ const condHeaders: Record<string, string> = {};
245
+ if (validators?.etag) condHeaders['If-None-Match'] = validators.etag;
246
+ if (validators?.lastModified) condHeaders['If-Modified-Since'] = validators.lastModified;
149
247
  const res = await fetch(url, {
150
248
  headers: {
151
249
  'User-Agent': USER_AGENTS[attempt],
152
- Accept: 'text/html,application/xhtml+xml,text/plain,text/markdown,*/*;q=0.8',
250
+ Accept: 'text/markdown,text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.5',
153
251
  'Accept-Language': 'en-US,en;q=0.5',
252
+ ...condHeaders,
154
253
  },
155
254
  redirect: 'follow',
156
255
  signal: ctrl.signal,
157
256
  });
257
+ if (res.status === 304 && attempt === 0) {
258
+ done();
259
+ return { body: '', contentType: '', validators: validators ?? {}, notModified: true };
260
+ }
158
261
  const ct = res.headers.get('content-type') ?? '';
159
262
  const mime = ct.split(';')[0]?.trim().toLowerCase() ?? '';
160
263
  if (
@@ -177,7 +280,15 @@ export async function localFetch(url: URL, signal?: AbortSignal): Promise<{ body
177
280
  }
178
281
  const bytes = await readBodyBounded(res, MAX_HTML_BYTES);
179
282
  if (bytes.byteLength === 0) throw new Error('empty body');
180
- return { body: decodeBody(bytes, ct), contentType: ct };
283
+ return {
284
+ body: decodeBody(bytes, ct),
285
+ contentType: ct,
286
+ validators: {
287
+ etag: res.headers.get('etag') ?? undefined,
288
+ lastModified: res.headers.get('last-modified') ?? undefined,
289
+ },
290
+ notModified: false,
291
+ };
181
292
  } catch (e) {
182
293
  if (signal?.aborted) throw new Error(ABORT_ERROR, { cause: e });
183
294
  const clean = sanitizeError(e);
@@ -197,7 +308,11 @@ export async function localFetch(url: URL, signal?: AbortSignal): Promise<{ body
197
308
 
198
309
  // Per-attempt timeout + pre-abort: an already-aborted signal never fires
199
310
  // abort listeners, so the request must reject immediately on its own.
200
- function withTimeout(label: string, ms: number, signal?: AbortSignal): { ctrl: AbortController; done: () => void } {
311
+ export function withTimeout(
312
+ label: string,
313
+ ms: number,
314
+ signal?: AbortSignal,
315
+ ): { ctrl: AbortController; done: () => void } {
201
316
  const ctrl = new AbortController();
202
317
  const t = setTimeout(() => ctrl.abort(new Error(`${label} timeout`)), ms);
203
318
  const onAbort = () => ctrl.abort(signal?.reason ?? new Error(ABORT_ERROR));
@@ -320,6 +435,97 @@ async function jinaRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
320
435
  done();
321
436
  }
322
437
  }
438
+ async function kagiRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
439
+ const key = getConfig().kagiKey!;
440
+ const { ctrl, done } = withTimeout('kagi', READER_TIMEOUTS.kagi, signal);
441
+ try {
442
+ const d = await readJson<{
443
+ data?: { output?: string; references?: { title?: string; url?: string }[] };
444
+ }>(
445
+ 'https://kagi.com/api/v0/summarize',
446
+ {
447
+ method: 'POST',
448
+ headers: { Authorization: `Bot ${key}`, 'Content-Type': 'application/json' },
449
+ body: JSON.stringify({ url: url.toString(), summary_type: 'takeaway' }),
450
+ },
451
+ ctrl.signal,
452
+ );
453
+ const output = d.data?.output?.trim() ?? '';
454
+ if (output.length < 100) throw new Error('kagi summarizer returned no usable summary');
455
+ const refs = (d.data?.references ?? []).filter((r) => r?.url);
456
+ const markdown = refs.length
457
+ ? `${output}\n\nSources:\n${refs.map((r) => `- [${r.title ?? r.url}](${r.url})`).join('\n')}`
458
+ : output;
459
+ return { title: '', markdown };
460
+ } finally {
461
+ done();
462
+ }
463
+ }
464
+ async function tinyfishRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
465
+ const key = getConfig().tinyfishKey!;
466
+ const { ctrl, done } = withTimeout('tinyfish', READER_TIMEOUTS.tinyfish, signal);
467
+ try {
468
+ const d = await readJson<{ results?: { url?: string; title?: string; text?: string; markdown?: string }[] }>(
469
+ 'https://api.fetch.tinyfish.ai',
470
+ {
471
+ method: 'POST',
472
+ headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
473
+ body: JSON.stringify({ urls: [url.toString()] }),
474
+ },
475
+ ctrl.signal,
476
+ );
477
+ const doc = (d.results ?? []).find((r) => r && !!r.text);
478
+ if (!doc?.text?.trim()) throw new Error('tinyfish fetch returned no content');
479
+ return { title: doc.title ?? '', markdown: doc.markdown ?? doc.text };
480
+ } finally {
481
+ done();
482
+ }
483
+ }
484
+ async function serperRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
485
+ const key = getConfig().serperKey!;
486
+ const { ctrl, done } = withTimeout('serper', READER_TIMEOUTS.serper, signal);
487
+ try {
488
+ const d = await readJson<Record<string, unknown>>(
489
+ 'https://scrape.serper.dev/',
490
+ {
491
+ method: 'POST',
492
+ headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
493
+ body: JSON.stringify({ url: url.toString(), includeMarkdown: true }),
494
+ },
495
+ ctrl.signal,
496
+ );
497
+ const data = (typeof d.data === 'object' && d.data ? (d.data as Record<string, unknown>) : {}) as Record<
498
+ string,
499
+ unknown
500
+ >;
501
+ const md = (typeof data.markdown === 'string' ? data.markdown : (d.markdown ?? d.text ?? '')) as string | undefined;
502
+ const markdown = (md ?? '').trim();
503
+ if (markdown.length < 100) throw new Error('serper scrape returned no usable content');
504
+ return { title: typeof d.title === 'string' ? d.title : '', markdown };
505
+ } finally {
506
+ done();
507
+ }
508
+ }
509
+ async function youRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
510
+ const key = getConfig().youKey!;
511
+ const { ctrl, done } = withTimeout('you', READER_TIMEOUTS.you, signal);
512
+ try {
513
+ const rows = await readJson<{ url?: string; title?: string; markdown?: string }[]>(
514
+ 'https://ydc-index.io/v1/contents',
515
+ {
516
+ method: 'POST',
517
+ headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
518
+ body: JSON.stringify({ urls: [url.toString()], formats: ['markdown'] }),
519
+ },
520
+ ctrl.signal,
521
+ );
522
+ const doc = (rows ?? []).find((r) => r && !!r.markdown);
523
+ if (!doc?.markdown?.trim()) throw new Error('you contents returned no markdown');
524
+ return { title: doc.title ?? '', markdown: doc.markdown };
525
+ } finally {
526
+ done();
527
+ }
528
+ }
323
529
 
324
530
  interface Reader {
325
531
  available(): boolean;
@@ -330,10 +536,17 @@ const READERS: Record<RemoteRenderer, Reader> = {
330
536
  firecrawl: { available: () => !!getConfig().firecrawlKey, read: firecrawlRead },
331
537
  tavily: { available: () => !!getConfig().tavilyKey, read: tavilyRead },
332
538
  exa: { available: () => !!getConfig().exaKey, read: exaRead },
333
- jina: { available: () => !!getConfig().jinaKey, read: jinaRead },
539
+ jina: {
540
+ available: () => !!getConfig().jinaKey || (getConfig().fetchChain ?? '').split(',').includes('jina'),
541
+ read: jinaRead,
542
+ },
543
+ kagi: { available: () => !!getConfig().kagiKey, read: kagiRead },
544
+ tinyfish: { available: () => !!getConfig().tinyfishKey, read: tinyfishRead },
545
+ serper: { available: () => !!getConfig().serperKey, read: serperRead },
546
+ you: { available: () => !!getConfig().youKey, read: youRead },
334
547
  };
335
548
 
336
- const REMOTE_ORDER: RemoteRenderer[] = ['firecrawl', 'tavily', 'exa', 'jina'];
549
+ const REMOTE_ORDER: RemoteRenderer[] = ['jina', 'tinyfish', 'firecrawl', 'tavily', 'exa', 'serper', 'you', 'kagi'];
337
550
 
338
551
  function readerOrder(): RemoteRenderer[] {
339
552
  const raw = getConfig().fetchChain;
@@ -367,7 +580,7 @@ export async function remoteChain(
367
580
  usable: (markdown: string) => boolean,
368
581
  ): Promise<ChainResult> {
369
582
  const order = readerOrder();
370
- let lastError: string | undefined;
583
+ const errors: string[] = [];
371
584
  for (const id of order) {
372
585
  if (signal?.aborted) return { ok: false, aborted: true, error: ABORT_ERROR };
373
586
  const reader = READERS[id];
@@ -378,12 +591,12 @@ export async function remoteChain(
378
591
  return { ok: true, doc: { ...doc, renderer: id } };
379
592
  } catch (e) {
380
593
  if (signal?.aborted) return { ok: false, aborted: true, error: ABORT_ERROR };
381
- lastError = sanitizeError(e);
594
+ errors.push(`${id}: ${sanitizeError(e)}`);
382
595
  }
383
596
  }
384
597
  return {
385
598
  ok: false,
386
599
  aborted: false,
387
- error: lastError ? `reader chain exhausted: ${lastError}` : 'no reader backend configured',
600
+ error: errors.length ? `reader chain exhausted: ${errors.join('; ')}` : 'no reader backend configured',
388
601
  };
389
602
  }