pi-wsearch 0.2.2 → 0.3.2

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
@@ -7,9 +7,11 @@
7
7
  **One search, many judges.** Most web tools hand you a single engine's
8
8
  ranking. `web.search` runs every engine you've configured in parallel and
9
9
  merges their results by reciprocal rank fusion (RRF). A hit only earns its
10
- place by surviving several independent judgments. The response shows how
11
- many: `← 3 engines agree` means three separate indexes ranked that page.
12
- Agreement is the spam defense; generative-SEO can stuff one index, not three.
10
+ place by surviving several independent judgments. The response shows the
11
+ corroboration in detail: `⇐ serper#1, exa#2, you#4` means three separate
12
+ indexes ranked that page — and where each one placed it. Single-engine hits
13
+ are labeled `single-engine: unverified`. Agreement is the spam defense;
14
+ generative-SEO can stuff one index, not three.
13
15
 
14
16
  **Pick your engines.** Nine are supported; three or four is plenty. Use
15
17
  whatever keys you have, even one, because what follows doesn't depend on how
@@ -37,12 +39,19 @@ and reads only those. No
37
39
  full reads, no 40 KB dumps. Sections over 3,000 characters split into
38
40
  addressable continuation entries (`Section (cont. 1)`), so no tail is ever
39
41
  unreachable; pages that overflow are capped; the final response is clipped.
40
- Every cut is marked — nothing is silently lost.
42
+ Every cut is marked — nothing is silently lost. Codemode consumers get the same
43
+ view via structuredContent: per-section `{index, heading, text}`, continuation
44
+ cuts counted in `sections_truncated`, local budget cuts listed in
45
+ `sections_clipped`.
41
46
 
42
47
  **Reads are free.** Page parsing is built in, nothing billed per read, no key
43
48
  required. Real headings become the map, TOC echoes get dropped, so reads come
44
49
  back as clean as a paid one at zero cost. Paid readers only step in for
45
- JavaScript heavy pages or when the local read fails.
50
+ JavaScript heavy pages or when the local read fails. Reader output is
51
+ laundered too: standalone photo credits (REUTERS/, AP Photo/, Getty), video
52
+ durations, carousel markers and relative-time chips are stripped from news
53
+ pages (whole-line rules only — prose is untouched), and a reader that returns
54
+ a 404-page template is skipped so the chain moves on to the next backend.
46
55
 
47
56
  **Tracking links cache as one page.** `?utm_source=…` variants of the same URL
48
57
  share one 24 hour cache entry. Faster repeats, smaller disk.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-wsearch",
3
- "version": "0.2.2",
3
+ "version": "0.3.2",
4
4
  "description": "Pi extension: web.search + web.fetch",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "check": "oxlint && oxfmt --check && tsc --noEmit && knip && bun test --jobs 1 test/",
37
37
  "test": "bun test --jobs 1 test/",
38
38
  "typecheck": "tsc --noEmit",
39
- "knip": "knip",
39
+ "knip": "bun scripts/knip.mjs",
40
40
  "coverage": "bun test --jobs 1 test/ --coverage"
41
41
  },
42
42
  "pi": {
@@ -57,7 +57,7 @@
57
57
  "knip": "^6.39.0",
58
58
  "oxfmt": "^0.71.0",
59
59
  "oxlint": "^1.86.0",
60
- "typescript": "^7.0.2"
60
+ "typescript": "^5.9.3"
61
61
  },
62
62
  "dependencies": {},
63
63
  "main": "index.ts",
package/src/cache.ts CHANGED
@@ -44,9 +44,11 @@ export class TtlCache {
44
44
  /* cache writes are best-effort */
45
45
  }
46
46
  }
47
+ /** Entry valid while unexpired; non-numeric/NaN exp (corrupt store) must NOT
48
+ * count as "never expires" — treat it as expired so a poisoned entry dies. */
47
49
  get(key: string): unknown | undefined {
48
50
  const e = this.store.entries[key];
49
- if (!e || e.exp < Date.now()) return undefined;
51
+ if (!e || typeof e.exp !== 'number' || !Number.isFinite(e.exp) || e.exp < Date.now()) return undefined;
50
52
  const i = this.store.order.indexOf(key);
51
53
  if (i >= 0) this.store.order.splice(i, 1);
52
54
  this.store.order.push(key);
@@ -56,7 +58,7 @@ export class TtlCache {
56
58
  /** Expired copy WITHOUT deleting it (stale-if-error reserve). */
57
59
  peekStale(key: string): { value: unknown; ageHours: number } | undefined {
58
60
  const e = this.store.entries[key];
59
- if (!e) return undefined;
61
+ if (!e || typeof e.exp !== 'number' || !Number.isFinite(e.exp)) return undefined;
60
62
  const ageHours = (Date.now() - e.exp) / 3600_000;
61
63
  if (ageHours <= 0) return undefined;
62
64
  return { value: e.value, ageHours };
package/src/engines.ts CHANGED
@@ -395,12 +395,18 @@ function selectEngines(opts: SearchOptions): EngineDef[] {
395
395
 
396
396
  export async function runEngines(opts: SearchOptions): Promise<EngineOutcome[]> {
397
397
  const cfg = getConfig();
398
- const chosen = selectEngines(opts).filter((def) => !deadEngines.has(def.id));
398
+ const selected = selectEngines(opts);
399
+ const chosen = selected.filter((def) => !deadEngines.has(def.id));
399
400
  if (chosen.length === 0) {
400
401
  const wanted = opts.engines ?? parseEngineList(getConfig().searchEngines);
401
- const error = wanted?.length
402
- ? `no configured engines match the requested set (${wanted.join(', ')})`
403
- : 'no search engine keys configured; run /websearch to add one';
402
+ // selected-but-dead means KEYS EXIST and the engines were temp-banned
403
+ // (stable HTTP 401/402/403): the old wording lied about the cause.
404
+ const error =
405
+ selected.length > 0
406
+ ? 'all configured engines are temporarily unavailable (HTTP 401/402/403); try again later'
407
+ : wanted?.length
408
+ ? `no configured engines match the requested set (${wanted.join(', ')})`
409
+ : 'no search engine keys configured; run /websearch to add one';
404
410
  return [{ engine: 'none', hits: [], error, latencyMs: 0 }];
405
411
  }
406
412
  return Promise.all(
package/src/fuse.ts CHANGED
@@ -3,6 +3,8 @@ import { isJunk, normalizeUrl, hostOf } from './urls';
3
3
 
4
4
  export interface FusedHit extends SearchHit {
5
5
  engines: string[];
6
+ /** Per-engine rank of this hit: {serper: 1} = serper ranked it #1. */
7
+ engineRanks: { engine: string; rank: number }[];
6
8
  rrfScore: number;
7
9
  finalScore: number;
8
10
  junk: boolean;
@@ -207,6 +209,12 @@ function collapseNearDuplicates(fused: FusedHit[]): FusedHit[] {
207
209
  const twin = kept.find((k) => isNearDuplicate(k, h));
208
210
  if (twin) {
209
211
  twin.engines = [...new Set([...twin.engines, ...h.engines])];
212
+ const best = new Map(twin.engineRanks.map((r) => [r.engine, r]));
213
+ for (const r of h.engineRanks) {
214
+ const cur = best.get(r.engine);
215
+ if (!cur || r.rank < cur.rank) best.set(r.engine, r);
216
+ }
217
+ twin.engineRanks = [...best.values()];
210
218
  if (h.snippet.length > twin.snippet.length) twin.snippet = h.snippet;
211
219
  if (h.title.length > twin.title.length && !twin.title.includes(h.title)) twin.title = h.title;
212
220
  continue;
@@ -250,6 +258,7 @@ export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHi
250
258
  fused.push({
251
259
  ...hit,
252
260
  engines: ranks.map((r) => r.engine),
261
+ engineRanks: [...ranks].toSorted((a, b) => a.rank - b.rank),
253
262
  rrfScore: rrf,
254
263
  finalScore: Math.round(score * 1e5) / 1e5,
255
264
  junk,
package/src/scrape.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { getConfig, sanitizeError } from './config';
7
7
  import { ABORT_ERROR } from './constants';
8
+ import { isPrivateHost, validateUrl } from './urls';
8
9
  import {
9
10
  localFetch,
10
11
  looksLikeHtml,
@@ -33,6 +34,8 @@ export interface ScrapeResult {
33
34
  error?: string;
34
35
  }
35
36
 
37
+ export { isPrivateHost, validateUrl } from './urls'; // public surface unchanged; the gates live in the URL leaf
38
+
36
39
  const DEFAULT_MAX_CHARS = 12000;
37
40
 
38
41
  function stripToAscii(s: string): string {
@@ -91,66 +94,6 @@ export function decodeHtmlEntities(s: string): string {
91
94
  });
92
95
  }
93
96
 
94
- function isPrivateIpv4(h: string): boolean {
95
- const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
96
- if (!ipv4) return false;
97
- const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
98
- if (a === 10) return true;
99
- if (a === 127) return true;
100
- if (a === 0) return true;
101
- if (a === 169 && b === 254) return true;
102
- if (a === 172 && b >= 16 && b <= 31) return true;
103
- if (a === 192 && b === 168) return true;
104
- return false;
105
- }
106
-
107
- function numericIpv4(h: string): string | null {
108
- if (!/^[0-9.]+$/.test(h) && !/^0[xX][0-9a-fA-F.]+$/.test(h)) return null;
109
- const fields = h.split('.');
110
- if (fields.length > 4) return null;
111
- const parts: number[] = [];
112
- for (const f of fields) {
113
- if (/^0[xX][0-9a-fA-F]+$/.test(f)) parts.push(parseInt(f, 16));
114
- else if (/^0[0-7]+$/.test(f)) parts.push(parseInt(f, 8));
115
- else if (/^\d+$/.test(f)) parts.push(Number(f));
116
- else return null;
117
- }
118
- const last = parts[parts.length - 1];
119
- if (parts.length === 1) {
120
- if (last >= 2 ** 32) return null;
121
- return [(last >>> 24) & 0xff, (last >>> 16) & 0xff, (last >>> 8) & 0xff, last & 0xff].join('.');
122
- }
123
- if (parts.slice(0, -1).some((n) => n > 255)) return null;
124
- if (last >= 2 ** (8 * (4 - parts.length + 1))) return null;
125
- if (parts.length === 2) return [parts[0], 0, 0, last].join('.');
126
- if (parts.length === 3) return [parts[0], parts[1], (last >>> 8) & 0xff, last & 0xff].join('.');
127
- return parts.join('.');
128
- }
129
-
130
- export function isPrivateHost(host: string): boolean {
131
- const h = host.toLowerCase().replace(/^\[(.*)\]$/, '$1');
132
- if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost')) return true;
133
- const mapped = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
134
- if (mapped) {
135
- const [a, b] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
136
- if (isPrivateIpv4(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`)) return true;
137
- }
138
- const ipv4 = numericIpv4(h);
139
- if (ipv4 && isPrivateIpv4(ipv4)) return true;
140
- if (/^[0-9a-f:]+$/.test(h) && h.includes(':')) {
141
- if (h.startsWith('::1') || h === '0:0:0:0:0:0:0:1') return true;
142
- if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
143
- }
144
- return false;
145
- }
146
-
147
- export function validateUrl(raw: string, allowPrivate = false): URL {
148
- const u = new URL(raw);
149
- if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`unsupported protocol: ${u.protocol}`);
150
- if (!allowPrivate && isPrivateHost(u.hostname)) throw new Error('private/loopback address rejected');
151
- return u;
152
- }
153
-
154
97
  function stripKatexSpans(h: string): string {
155
98
  let out = '';
156
99
  let i = 0;
@@ -468,8 +411,7 @@ export function sliceSections(
468
411
  const depth = hm![1].length;
469
412
  if (cur.trim()) sections.push(cur.trim());
470
413
  cur = `${'#'.repeat(depth)} ${name}\n`;
471
- if (outlineNames.length < OUTLINE_CAP && depth <= 4)
472
- outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
414
+ if (outlineNames.length < OUTLINE_CAP) outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
473
415
  continue;
474
416
  }
475
417
  seenParas.add(paraKey);
@@ -486,7 +428,7 @@ export function sliceSections(
486
428
  const depth = hm[1].length;
487
429
  const rest = (hm[3] ?? '').replace(/^[\n\s]+/, '');
488
430
  cur = `${'#'.repeat(depth)} ${name}` + (rest ? `\n${rest}` : '') + '\n';
489
- if (outlineNames.length < OUTLINE_CAP && depth <= 4) outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
431
+ if (outlineNames.length < OUTLINE_CAP) outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
490
432
  continue;
491
433
  }
492
434
  cur += scrubNavBullets(b) + '\n\n';
@@ -685,7 +627,7 @@ function fromChain(
685
627
  sectionCap: number,
686
628
  maxChars: number,
687
629
  ): ScrapeResult {
688
- let markdown = stripDataUriImages(doc.markdown);
630
+ let markdown = cleanReaderMarkdown(stripDataUriImages(doc.markdown));
689
631
  let title = doc.title;
690
632
  if (looksLikeHtml(markdown)) {
691
633
  const extracted = extractHtml(markdown);
@@ -714,25 +656,26 @@ async function archivedFetch(
714
656
  ): Promise<ScrapeResult | undefined> {
715
657
  if (signal?.aborted) return undefined;
716
658
  if (isPrivateHost(url.hostname)) return undefined;
659
+ let snapshotUrl: string | undefined;
660
+ let snapTs: string | undefined;
661
+ const { ctrl, done } = withTimeout('archive lookup', 10_000, signal);
717
662
  try {
718
- const { ctrl, done } = withTimeout('archive lookup', 10_000, signal);
719
- let snapshotUrl: string | undefined;
720
- try {
721
- const res = await fetch(`https://archive.org/wayback/available?url=${encodeURIComponent(url.toString())}`, {
722
- signal: ctrl.signal,
723
- headers: { Accept: 'application/json' },
724
- });
725
- if (res.ok) {
726
- const d = (await res.json()) as {
727
- archived_snapshots?: { closest?: { url?: string; status?: string; timestamp?: string } };
728
- };
729
- const snap = d.archived_snapshots?.closest;
730
- if (snap?.url && snap.status === '200') snapshotUrl = snap.url;
731
- var snapTs = snap?.timestamp;
732
- }
733
- } finally {
734
- done();
663
+ const res = await fetch(`https://archive.org/wayback/available?url=${encodeURIComponent(url.toString())}`, {
664
+ signal: ctrl.signal,
665
+ headers: { Accept: 'application/json' },
666
+ });
667
+ if (res.ok) {
668
+ const d = (await res.json()) as {
669
+ archived_snapshots?: { closest?: { url?: string; status?: string; timestamp?: string } };
670
+ };
671
+ const snap = d.archived_snapshots?.closest;
672
+ if (snap?.url && snap.status === '200') snapshotUrl = snap.url;
673
+ snapTs = snap?.timestamp;
735
674
  }
675
+ } finally {
676
+ done();
677
+ }
678
+ try {
736
679
  if (!snapshotUrl || signal?.aborted) return undefined;
737
680
  const { body, contentType } = await localFetch(new URL(snapshotUrl), signal);
738
681
  if (!/html|text/.test(contentType)) return undefined;
@@ -786,8 +729,10 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
786
729
  }
787
730
 
788
731
  const usable = (markdown: string): boolean => {
789
- if (markdown.replace(/\s+/g, '').length < 100) return false;
790
- const { sections } = sliceSections(markdown, DEFAULT_SECTION_CAP);
732
+ const md = cleanReaderMarkdown(markdown);
733
+ if (md.replace(/\s+/g, '').length < 100) return false;
734
+ if (notFoundDoc(md)) return false;
735
+ const { sections } = sliceSections(md, DEFAULT_SECTION_CAP);
791
736
  return sections.length > 0;
792
737
  };
793
738
 
@@ -830,6 +775,43 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
830
775
  return fromChain(url, chainResult.doc, sectionCap, maxChars);
831
776
  }
832
777
 
778
+ /** Whole-line news junk in reader output: photo credits, carousel markers,
779
+ * video durations, relative-time chips. Prose lines are untouched. */
780
+ const READER_JUNK_LINE_RE = [
781
+ // bare credits: "REUTERS/Sofiia Gatilova", "AP Photo/John Smith"
782
+ /^(?:reuters|ap photo|getty images|afp|epa)\/(?:[a-z0-9][\w.' -]{1,80})$/i,
783
+ // handout/file photo tags at line end: "…REUTERS/Rachel Wisniewski/File Photo"
784
+ /\/(?:file|handout)\s+photo\s*\.*$/i,
785
+ // carousel markers: "Item 1 of 15"
786
+ /^item\s+\d{1,3}\s+of\s+\d{1,3}\s*$/i,
787
+ // video durations: "0:48"
788
+ /^\d{1,3}:\d{2}$/,
789
+ // relative-time chips: "* 1 hour ago", "just now", "yesterday"
790
+ /^[*\-–—]?\s*(?:just now|today|yesterday|\d+\s*(?:m|min(?:ute)?s?|hrs?|h|hours?|d|days?|w|weeks?)\s*ago\.?)\s*$/i,
791
+ ];
792
+
793
+ const NOT_FOUND_DOC_RE =
794
+ /(?:page|url|requested page|webpage)[^.]{0,40}(?:not found|not be found|cannot be found|does not exist|doesn't exist|no longer exists|error 404|404 error|404 not found)|we (?:couldn't|could not|can't) find(?: the)? (?:page|requested|article|that)|it looks like there isn't a page/i;
795
+
796
+ /** Best-effort scrub of reader markdown (news sites leak credits/captions). */
797
+ export function cleanReaderMarkdown(markdown: string): string {
798
+ const out: string[] = [];
799
+ let inFence = false;
800
+ for (const line of markdown.split('\n')) {
801
+ if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence;
802
+ const t = line.trim();
803
+ if (!inFence && t && t.length <= 200 && READER_JUNK_LINE_RE.some((re) => re.test(t))) continue;
804
+ out.push(line);
805
+ }
806
+ return out.join('\n').replace(/\n{3,}/g, '\n\n');
807
+ }
808
+
809
+ /** True for a 404-style reader stub; the chain skips it and tries the next backend. */
810
+ export function notFoundDoc(markdown: string): boolean {
811
+ if (markdown.replace(/\s+/g, '').length > 500) return false;
812
+ return NOT_FOUND_DOC_RE.test(markdown.slice(0, 300));
813
+ }
814
+
833
815
  /** Compose outline + sections; the cap is honest (marker included). */
834
816
  export function joinSections(
835
817
  outline: string[],
package/src/tools.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  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
- import { Text } from '@earendil-works/pi-tui';
8
+ import { Text, type Component } from '@earendil-works/pi-tui';
9
9
  import { appendFileSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
11
  import {
@@ -20,9 +20,10 @@ import {
20
20
  } from './config';
21
21
  import { ABORT_ERROR, DEFAULT_MAX_RESULTS } from './constants';
22
22
  import { cacheKey, openCache, CACHE_TTL_HOURS } from './cache';
23
- import { clearDeadEngines, runEngines } from './engines';
23
+ import { clearDeadEngines, runEngines, type EngineOutcome } from './engines';
24
24
  import { fuse, diversifyByHost } from './fuse';
25
- import { hostOf, normalizeUrl } from './urls';
25
+ import { normalizeUrl } from './urls';
26
+ import { buildSearchText, READ_FIRST_TAG, wrapAll } from './view';
26
27
  import { scrape } from './scrape';
27
28
 
28
29
  const SEARCH_PARAMS = Type.Object({
@@ -70,21 +71,22 @@ const FETCH_OUTPUT = Type.Object({
70
71
  title: Type.String(),
71
72
  renderer: Type.String(),
72
73
  outline: Type.Array(Type.String()),
74
+ sections: Type.Array(
75
+ Type.Object({
76
+ index: Type.Integer({ minimum: 0 }),
77
+ heading: Type.String(),
78
+ text: Type.String(),
79
+ }),
80
+ ),
73
81
  section_count: Type.Number(),
74
82
  chars: Type.Number(),
75
83
  sections_requested: Type.Optional(Type.Array(Type.Number())),
76
84
  sections_missing: Type.Optional(Type.Array(Type.Number())),
77
85
  sections_truncated: Type.Optional(Type.Number()),
86
+ sections_clipped: Type.Optional(Type.Array(Type.Integer({ minimum: 0 }))),
78
87
  error: Type.Optional(Type.String()),
79
88
  });
80
89
 
81
- function fmtSnippet(s: string, max: number): string {
82
- const t = s.replace(/\s+/g, ' ').trim();
83
- return t.length > max ? t.slice(0, max) + '…' : t;
84
- }
85
-
86
- const ABORT_ERROR_LOCAL = ABORT_ERROR;
87
-
88
90
  function dropRarestTerm(query: string): string {
89
91
  const words = query.split(/\s+/).filter((w) => w.length > 2 && !/^(?:site|inurl|intitle|filetype):/i.test(w));
90
92
  if (words.length < 2) return '';
@@ -95,55 +97,6 @@ function dropRarestTerm(query: string): string {
95
97
  .trim();
96
98
  }
97
99
 
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
-
137
- function cleanSnippet(s: string): string {
138
- return s
139
- .replace(/\\([_*`[\]()#>~.-])/g, '$1')
140
- .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
141
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
142
- .replace(/(^|\s)([*]{1,3}|_{1,2}|`)(\S(?:[^*`_]*\S)?)\2(?=\s|$|[.,;:!?)]|\b)/g, '$1$3')
143
- .replace(/\s+/g, ' ')
144
- .trim();
145
- }
146
-
147
100
  export function registerWebTools(pi: ExtensionAPI) {
148
101
  const cache = openCache(getConfig().cacheDir + '/cache.json');
149
102
  const exposure = existsSync(disabledFlagPath()) ? 'hidden' : 'direct';
@@ -169,6 +122,13 @@ export function registerWebTools(pi: ExtensionAPI) {
169
122
  parameters: SEARCH_PARAMS,
170
123
  outputSchema: SEARCH_OUTPUT,
171
124
 
125
+ renderCall(args, theme) {
126
+ const header = theme.fg('toolTitle', theme.bold('web.search'));
127
+ const q = typeof args.query === 'string' ? args.query : '…';
128
+ const tail = typeof args.max_results === 'number' ? theme.fg('muted', ` max=${args.max_results}`) : '';
129
+ return new Text(`${header} ${theme.fg('accent', q)}${tail}`, 0, 0);
130
+ },
131
+
172
132
  async execute(_id, params, signal, _onUpdate, _ctx) {
173
133
  if (signal?.aborted) throw new Error(ABORT_ERROR);
174
134
  const outcomes = await runEngines({
@@ -208,38 +168,32 @@ export function registerWebTools(pi: ExtensionAPI) {
208
168
  fused.filter((r) => !r.junk),
209
169
  2,
210
170
  ).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)`;
171
+ if (candidates.length) retryNote = `retried as "${relaxed}"`;
212
172
  }
213
173
  }
214
174
  }
215
175
  const visible = candidates.slice(0, params.max_results ?? DEFAULT_MAX_RESULTS);
216
176
  const enginesUsed = [...new Set(fused.flatMap((r) => r.engines))];
217
- const snip = 240;
218
-
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`;
220
- if (!visible.length) {
221
- const fatal = outcomes.find((o) => o.engine === 'none' && o.error);
222
- text = fatal
223
- ? `No results for "${params.query}": ${fatal.error}\n`
224
- : `No results for "${params.query}". Broaden the query.\n`;
225
- }
226
177
  visible.forEach((r, i) => {
227
178
  lastSearch.urlRank.set(normalizeUrl(r.url), {
228
179
  rank: i + 1,
229
180
  engines: r.engines,
230
181
  query: params.query,
231
182
  });
232
- const title = fmtSnippet(stripSiteSuffix(cleanSnippet(r.title), hostOf(r.url)), 100) || r.url;
233
- const snippet = fmtSnippet(cleanSnippet(r.snippet), snip);
234
- const via = r.engines.length > 1 ? ` ← ${r.engines.length} engines agree` : ` ← ${r.engines[0]}`;
235
- const when = normalizeHitDate(r.date);
236
- text += `${i + 1}. [${title}](${r.url})${when ? ` — ${when}` : ''}${via}\n`;
237
- if (snippet) text += ` ${snippet}\n`;
238
- text += '\n';
239
183
  });
240
- if (errored.length) {
241
- text += `⚠ engines: ${errored.map((e) => `${e.engine} (${e.error.slice(0, 80)})`).join('; ')}\n`;
242
- }
184
+ const text = buildSearchText(
185
+ {
186
+ query: params.query,
187
+ hits: visible,
188
+ enginesUsed,
189
+ errored,
190
+ fatal: visible.length
191
+ ? undefined
192
+ : outcomes.find((o): o is EngineOutcome & { error: string } => o.engine === 'none' && !!o.error),
193
+ retryNote,
194
+ },
195
+ { wrap: terminalWrapWidth() },
196
+ );
243
197
 
244
198
  return {
245
199
  content: [{ type: 'text', text }],
@@ -252,15 +206,14 @@ export function registerWebTools(pi: ExtensionAPI) {
252
206
  url: r.url,
253
207
  title: r.title,
254
208
  engines: r.engines,
209
+ ranks: r.engineRanks,
255
210
  score: r.finalScore,
256
211
  })),
257
212
  },
258
213
  };
259
214
  },
260
215
  renderResult(result, options, theme) {
261
- const lines = resultText(result).split('\n');
262
- if (!options.expanded) return new Text(previewWithHint(lines, theme), 0, 0);
263
- return new Text(colorizedResult(resultText(result), theme), 0, 0);
216
+ return new WrappedSearchText(resultText(result), theme, options.expanded);
264
217
  },
265
218
  });
266
219
 
@@ -282,10 +235,13 @@ export function registerWebTools(pi: ExtensionAPI) {
282
235
  const url = typeof args.url === 'string' ? args.url : '…';
283
236
  const s = Array.isArray(args.sections) ? args.sections.join(', ') : '…';
284
237
  const tail = args.sections ? ` sections=[${s}]` : '';
285
- return new Text(`${header} ${theme.fg('muted', url + tail)}`, 0, 0);
238
+ return new Text(`${header} ${theme.fg('accent', url)}${theme.fg('muted', tail)}`, 0, 0);
286
239
  },
287
240
 
288
241
  async execute(_id, params, signal) {
242
+ // Abort = throw: a stale signal must never START a fetch, and an abort
243
+ // mid-flight must never be masked by the stale-if-error reserve below.
244
+ if (signal?.aborted) throw new Error(ABORT_ERROR);
289
245
  const cfg = getConfig();
290
246
  const key = cacheKey(['fetch', 'v3', normalizeUrl(params.url)]);
291
247
  const ranked = lastSearch.urlRank.get(normalizeUrl(params.url));
@@ -313,6 +269,7 @@ export function registerWebTools(pi: ExtensionAPI) {
313
269
  signal,
314
270
  validators: stale?.value?.validators,
315
271
  });
272
+ if (signal?.aborted) throw new Error(ABORT_ERROR); // mid-flight abort: never a payload, never stale
316
273
  if (res.error === 'not-modified' && stale?.value) {
317
274
  cache.set(key, stale.value, CACHE_TTL_HOURS * 3600_000);
318
275
  return makeFetchResult(stale.value, params);
@@ -351,11 +308,13 @@ type FetchDetails = {
351
308
  title: string;
352
309
  renderer: string;
353
310
  outline: string[];
311
+ sections: { index: number; heading: string; text: string }[];
354
312
  section_count: number;
355
313
  chars?: number;
356
314
  sections_requested?: number[];
357
315
  sections_missing?: number[];
358
316
  sections_truncated?: number;
317
+ sections_clipped?: number[];
359
318
  error?: string;
360
319
  };
361
320
 
@@ -366,11 +325,8 @@ interface FetchResult {
366
325
  isError?: boolean;
367
326
  }
368
327
 
369
- function fmtSize(chars: number): string {
370
- return chars >= 1024 ? `${(chars / 1024).toFixed(1)} KB` : `${chars} chars`;
371
- }
372
-
373
328
  const PREVIEW_LINES = 10;
329
+ const SEARCH_PREVIEW_LINES = 8;
374
330
 
375
331
  /** Display-only: the model-facing markers (`indices …`, `- *N*`) render as
376
332
  * genuine ANSI italics in the TUI; the content text itself is untouched. */
@@ -380,23 +336,70 @@ function italicizeIndices(text: string, theme: Theme): string {
380
336
  .replace(/^- \*(\d+)\* /gm, (_, n: string) => `- ${theme.italic(n)} `);
381
337
  }
382
338
 
339
+ function italicizeRanks(text: string, theme: Theme): string {
340
+ return text.replace(/^(\s*)(\d+)\.(?=\s)/gm, (m, pad: string, n: string) => `${pad}${theme.italic(n)}.`);
341
+ }
342
+
343
+ const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
344
+
345
+ const DEFAULT_WRAP_COLS = 90;
346
+ const MAX_WRAP_COLS = 240;
347
+
348
+ function terminalWrapWidth(): number {
349
+ const cols = process.stdout.columns ?? DEFAULT_WRAP_COLS;
350
+ return Math.min(MAX_WRAP_COLS, Math.max(60, cols - 4));
351
+ }
352
+
353
+ class WrappedSearchText implements Component {
354
+ private raw: string;
355
+ private theme: Theme;
356
+ private expanded: boolean;
357
+ constructor(raw: string, theme: Theme, expanded: boolean) {
358
+ this.raw = raw;
359
+ this.theme = theme;
360
+ this.expanded = expanded;
361
+ }
362
+ invalidate(): void {}
363
+ render(width: number): string[] {
364
+ const wrapped = wrapAll(this.raw, Math.max(40, width));
365
+ const lines = wrapped.split('\n');
366
+ let shown = lines;
367
+ let hint = '';
368
+ if (!this.expanded && lines.length > SEARCH_PREVIEW_LINES) {
369
+ shown = lines.slice(0, SEARCH_PREVIEW_LINES);
370
+ hint = moreLinesHint(shown.length, lines.length, this.theme);
371
+ }
372
+ const colored = italicizeRanks(shown.join('\n'), this.theme)
373
+ .split('\n')
374
+ .map((l) => colorLine(l, this.theme))
375
+ .join('\n');
376
+ return replaceTabs(colored + hint).split('\n');
377
+ }
378
+ }
379
+
380
+ function colorLine(l: string, theme: Theme): string {
381
+ const plain = l.replace(ANSI_SGR, '');
382
+ if (
383
+ plain.includes('…[section truncated') ||
384
+ plain.includes('…(continues at outline index') ||
385
+ plain.startsWith('…[clipped by tool]') ||
386
+ plain.startsWith('…[truncated]')
387
+ )
388
+ return theme.fg('warning', l);
389
+ if (plain.startsWith('## ') && plain.endsWith(READ_FIRST_TAG))
390
+ return theme.fg('accent', l.slice(0, l.length - READ_FIRST_TAG.length)) + theme.bold(READ_FIRST_TAG);
391
+ if (/^\s*· recommended first read$/.test(plain)) return theme.bold(l.trimEnd());
392
+ if (plain.startsWith('## ')) return theme.fg('accent', l);
393
+ if (plain.startsWith('- ')) return theme.fg('dim', l);
394
+ if (plain.startsWith('[rendered via')) return theme.fg('muted', l);
395
+ if (/^\s*\d+\. /.test(plain)) return theme.fg('dim', l);
396
+ return l;
397
+ }
398
+
383
399
  function colorizedResult(text: string, theme: Theme): string {
384
400
  return text
385
401
  .split('\n')
386
- .map((l) => {
387
- if (
388
- l.includes('…[section truncated') ||
389
- l.includes('…(continues at outline index') ||
390
- l.startsWith('…[clipped by tool]') ||
391
- l.startsWith('…[truncated]')
392
- )
393
- return theme.fg('warning', l);
394
- if (l.startsWith('## [')) return theme.fg('accent', l);
395
- if (l.startsWith('- ')) return theme.fg('dim', l);
396
- if (l.startsWith('[rendered via')) return theme.fg('muted', l);
397
- if (/^\d+\. /.test(l)) return theme.fg('toolOutput', l);
398
- return l;
399
- })
402
+ .map((l) => colorLine(l, theme))
400
403
  .join('\n');
401
404
  }
402
405
 
@@ -404,13 +407,16 @@ function resultText(result: { content: { type: string; text?: string }[] }): str
404
407
  return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
405
408
  }
406
409
 
407
- function previewWithHint(lines: string[], theme: Theme): string {
408
- const shown = lines.slice(0, PREVIEW_LINES);
409
- const remaining = lines.length - shown.length;
410
+ function moreLinesHint(shown: number, total: number, theme: Theme): string {
411
+ const remaining = total - shown;
412
+ if (remaining <= 0) return '';
413
+ return theme.fg('muted', `\n… (${remaining} more lines, ${keyHint('app.tools.expand', 'to expand')})`);
414
+ }
415
+
416
+ function previewWithHint(lines: string[], theme: Theme, maxLines = PREVIEW_LINES): string {
417
+ const shown = lines.slice(0, maxLines);
410
418
  const text = shown.map((l) => theme.fg('toolOutput', replaceTabs(l))).join('\n');
411
- if (remaining > 0)
412
- return text + theme.fg('muted', `\n… (${remaining} more lines, ${keyHint('app.tools.expand', 'to expand')})`);
413
- return text;
419
+ return text + moreLinesHint(shown.length, lines.length, theme);
414
420
  }
415
421
 
416
422
  function replaceTabs(text: string): string {
@@ -429,7 +435,6 @@ function fetchErrorResult(details: FetchDetails): FetchResult {
429
435
 
430
436
  function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: number[]): FetchResult {
431
437
  const want = [...new Set(sections.map((i) => Math.max(0, Math.min(res.sections.length - 1, i))))];
432
- const chosen = want.map((i) => res.sections[i]).filter(Boolean);
433
438
  const body = res.outline.length
434
439
  ? `indices - 0 - ${res.outline.length - 1} section${res.outline.length === 1 ? '' : 's'}\n${res.outline.map((h, i) => `- *${i}* ${h}`).join('\n')}\n\n`
435
440
  : '';
@@ -461,17 +466,48 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
461
466
  if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
462
467
  const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
463
468
  const missing = sections.filter((i) => i >= res.sections.length);
469
+ // continuation cuts (res.continued) vs local budget clips (clippedSections)
464
470
  const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
471
+
472
+ // same budget as the text view; headings and the '…' marker count against it
473
+ let budget = MAX_FETCH_TEXT;
474
+ const structuredSections: { index: number; heading: string; text: string }[] = [];
475
+ const clippedSections: number[] = [];
476
+ for (const idx of want) {
477
+ if (budget <= 0) {
478
+ clippedSections.push(idx);
479
+ continue;
480
+ }
481
+ const s = res.sections[idx];
482
+ if (s === undefined) continue;
483
+ const heading = res.outline[idx] ?? '';
484
+ const fullCost = heading.length + s.length;
485
+ if (fullCost <= budget) {
486
+ structuredSections.push({ index: idx, heading, text: s });
487
+ budget -= fullCost;
488
+ continue;
489
+ }
490
+ if (heading.length >= budget) {
491
+ structuredSections.push({ index: idx, heading: heading.slice(0, Math.max(0, budget - 1)) + '…', text: '' });
492
+ } else {
493
+ const textLen = Math.max(0, budget - heading.length - 1);
494
+ structuredSections.push({ index: idx, heading, text: s.slice(0, textLen) + (textLen < s.length ? '…' : '') });
495
+ }
496
+ clippedSections.push(idx);
497
+ budget = 0;
498
+ }
465
499
  const details = {
466
500
  url: res.url,
467
501
  title: res.title,
468
502
  renderer: res.renderer,
469
503
  outline: res.outline,
470
- section_count: chosen.length,
504
+ sections: structuredSections,
505
+ section_count: structuredSections.length,
471
506
  chars: clipped.length,
472
507
  sections_requested: [...new Set(sections)],
473
508
  sections_missing: [...new Set(missing)],
474
509
  sections_truncated: truncatedCount,
510
+ sections_clipped: clippedSections,
475
511
  ...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
476
512
  ...(res.paywalled ? { paywalled: true } : {}),
477
513
  ...(res.error ? { error: res.error } : {}),
package/src/transport.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Transport for web.fetch: UA-rotating local HTTP + the key-gated reader
3
- * backends. Text processing lives in scrape.ts (one-way import).
3
+ * backends. Text processing lives in scrape.ts (one-way import). SSRF: every
4
+ * redirect hop is re-validated against the private/loopback gates on the
5
+ * local path (see urls.isPrivateHost for the deliberate scope).
4
6
  */
5
7
  import { getConfig, sanitizeError } from './config';
6
8
  import { ABORT_ERROR } from './constants';
9
+ import { isPrivateHost } from './urls';
7
10
 
8
11
  export type Renderer = 'local' | 'firecrawl' | 'tavily' | 'exa' | 'jina' | 'kagi' | 'tinyfish' | 'serper' | 'you';
9
12
 
@@ -24,6 +27,7 @@ const USER_AGENTS = [
24
27
  const MAX_HTML_BYTES = 5 * 1024 * 1024;
25
28
  const LOCAL_TIMEOUT_MS = 15_000;
26
29
  const RETRY_AFTER_MAX_MS = 10_000;
30
+ const MAX_REDIRECTS = 8;
27
31
  const READER_TIMEOUTS: Record<RemoteRenderer, number> = {
28
32
  firecrawl: 45_000,
29
33
  tavily: 30_000,
@@ -241,24 +245,44 @@ export async function localFetch(
241
245
  if (signal?.aborted) throw new Error(ABORT_ERROR);
242
246
  const { ctrl, done } = withTimeout('local fetch', LOCAL_TIMEOUT_MS, signal);
243
247
  try {
248
+ const headers: Record<string, string> = {
249
+ 'User-Agent': USER_AGENTS[attempt],
250
+ Accept: 'text/markdown,text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.5',
251
+ 'Accept-Language': 'en-US,en;q=0.5',
252
+ };
244
253
  const condHeaders: Record<string, string> = {};
245
254
  if (validators?.etag) condHeaders['If-None-Match'] = validators.etag;
246
255
  if (validators?.lastModified) condHeaders['If-Modified-Since'] = validators.lastModified;
247
- const res = await fetch(url, {
248
- headers: {
249
- 'User-Agent': USER_AGENTS[attempt],
250
- Accept: 'text/markdown,text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.5',
251
- 'Accept-Language': 'en-US,en;q=0.5',
252
- ...condHeaders,
253
- },
254
- redirect: 'follow',
255
- signal: ctrl.signal,
256
- });
257
- if (res.status === 304 && attempt === 0) {
256
+ // 'manual' + explicit per-hop re-validation: a hostile page must never
257
+ // aim the agent at its own machine via a Location header. Conditional
258
+ // headers describe the FIRST resource only, so they drop after hop 0.
259
+ let current = url;
260
+ let res: Response | undefined;
261
+ for (let hop = 0; ; hop++) {
262
+ if (signal?.aborted) throw new Error(ABORT_ERROR);
263
+ res = await fetch(current, {
264
+ redirect: 'manual',
265
+ headers: hop === 0 ? { ...headers, ...condHeaders } : headers,
266
+ signal: ctrl.signal,
267
+ });
268
+ if (!isRedirect(res.status)) break;
269
+ if (hop >= MAX_REDIRECTS) throw new Error(`too many redirects (HTTP ${res.status})`);
270
+ const loc = res.headers.get('location');
271
+ await res.body?.cancel().catch(() => {});
272
+ if (!loc) throw new Error(`redirect without Location (HTTP ${res.status})`);
273
+ const next = new URL(loc, current);
274
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
275
+ throw new Error(`unsupported redirect protocol: ${next.protocol}`);
276
+ }
277
+ if (isPrivateHost(next.hostname)) throw new Error('private/loopback address rejected (redirect)');
278
+ current = next;
279
+ }
280
+ const final = res!;
281
+ if (final.status === 304 && attempt === 0) {
258
282
  done();
259
283
  return { body: '', contentType: '', validators: validators ?? {}, notModified: true };
260
284
  }
261
- const ct = res.headers.get('content-type') ?? '';
285
+ const ct = final.headers.get('content-type') ?? '';
262
286
  const mime = ct.split(';')[0]?.trim().toLowerCase() ?? '';
263
287
  if (
264
288
  !/html|xml|text/.test(mime) &&
@@ -267,25 +291,25 @@ export async function localFetch(
267
291
  ) {
268
292
  throw new Error(`not a readable page (${mime || '?'})`);
269
293
  }
270
- if (res.status === 429 && !retried429) {
294
+ if (final.status === 429 && !retried429) {
271
295
  retried429 = true;
272
- await waitAbortable(parseRetryAfter(res.headers.get('retry-after')), signal);
296
+ await waitAbortable(parseRetryAfter(final.headers.get('retry-after')), signal);
273
297
  attempt--;
274
298
  continue;
275
299
  }
276
- if (!res.ok) {
277
- const sample = await res.text().catch(() => '');
278
- if (isBotBlocked(res.status, sample) && attempt < USER_AGENTS.length - 1) continue;
279
- throw new Error(`HTTP ${res.status}`);
300
+ if (!final.ok) {
301
+ const sample = await final.text().catch(() => '');
302
+ if (isBotBlocked(final.status, sample) && attempt < USER_AGENTS.length - 1) continue;
303
+ throw new Error(`HTTP ${final.status}`);
280
304
  }
281
- const bytes = await readBodyBounded(res, MAX_HTML_BYTES);
305
+ const bytes = await readBodyBounded(final, MAX_HTML_BYTES);
282
306
  if (bytes.byteLength === 0) throw new Error('empty body');
283
307
  return {
284
308
  body: decodeBody(bytes, ct),
285
309
  contentType: ct,
286
310
  validators: {
287
- etag: res.headers.get('etag') ?? undefined,
288
- lastModified: res.headers.get('last-modified') ?? undefined,
311
+ etag: final.headers.get('etag') ?? undefined,
312
+ lastModified: final.headers.get('last-modified') ?? undefined,
289
313
  },
290
314
  notModified: false,
291
315
  };
@@ -306,6 +330,11 @@ export async function localFetch(
306
330
  throw new Error('local fetch failed');
307
331
  }
308
332
 
333
+ // 3xx redirect statuses; manual following must not treat these as content.
334
+ function isRedirect(status: number): boolean {
335
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
336
+ }
337
+
309
338
  // Per-attempt timeout + pre-abort: an already-aborted signal never fires
310
339
  // abort listeners, so the request must reject immediately on its own.
311
340
  export function withTimeout(
package/src/urls.ts CHANGED
@@ -1,4 +1,5 @@
1
- /** URL facts leaf: normalization (dedupe + cache keys), junk classification. */
1
+ /** URL facts leaf: normalization (dedupe + cache keys), junk classification,
2
+ * and the SSRF private-host gates (shared by scrape + transport). */
2
3
 
3
4
  const JUNK_HOSTS = [
4
5
  'quora.com',
@@ -19,6 +20,7 @@ const JUNK_HOSTS = [
19
20
  'threadreaderapp.com',
20
21
  'nitter.net',
21
22
  'upd.dev',
23
+ 'fb.watch',
22
24
  ];
23
25
  const JUNK_TLDS = new Set(['xyz', 'top', 'loan', 'click', 'work', 'gq', 'icu', 'rest', 'cyou']);
24
26
 
@@ -47,28 +49,45 @@ export function hostOf(raw: string): string {
47
49
  }
48
50
  }
49
51
 
50
- /** Keeps scheme+port deliberately (http vs https twins are different pages). */
52
+ /** Keeps scheme+port deliberately (http vs https twins are different pages).
53
+ * Path CASE and query ENCODING are preserved verbatim: on case-sensitive hosts
54
+ * `/README` and `/readme` are different resources, and `?q=a%2Fb` is not
55
+ * `?q=a/b`. Folding them would collide fetch cache keys and merge distinct
56
+ * hits in fuse, so the raw query pairs are only filtered and sorted, never
57
+ * re-serialized (URLSearchParams would decode the encoding away). */
51
58
  export function normalizeUrl(raw: string): string {
52
59
  try {
53
60
  const u = new URL(raw);
54
61
  const host = u.hostname.toLowerCase().replace(/^www\./, '');
55
- for (const p of Array.from(u.searchParams.keys())) {
56
- if (TRACKING_PARAMS.has(p.toLowerCase())) u.searchParams.delete(p);
57
- }
58
62
  u.hash = '';
59
- const pairs = Array.from(u.searchParams.entries()).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
60
- const q = pairs.map(([k, v]) => `${k}=${v}`).join('&');
63
+ let q = '';
64
+ if (u.search) {
65
+ const kept = u.search
66
+ .slice(1)
67
+ .split('&')
68
+ .filter(Boolean)
69
+ .filter((pair) => !TRACKING_PARAMS.has(decodeKey(pair)));
70
+ kept.sort((a, b) => (decodeKey(a) < decodeKey(b) ? -1 : decodeKey(a) > decodeKey(b) ? 1 : 0));
71
+ q = kept.join('&');
72
+ }
61
73
  const port = u.port ? `:${u.port}` : '';
62
- const path = u.pathname
63
- .replace(/\/{2,}/g, '/')
64
- .replace(/\/+$/, '')
65
- .toLowerCase();
74
+ const path = u.pathname.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
66
75
  return `${u.protocol}//${host}${port}${path}${q ? '?' + q : ''}`;
67
76
  } catch {
68
77
  return raw.toLowerCase();
69
78
  }
70
79
  }
71
80
 
81
+ /** Decoded query KEY (for tracking-param matching); malformed stays raw. */
82
+ function decodeKey(pair: string): string {
83
+ const key = (pair.split('=')[0] ?? '').trim();
84
+ try {
85
+ return decodeURIComponent(key).toLowerCase();
86
+ } catch {
87
+ return key.toLowerCase();
88
+ }
89
+ }
90
+
72
91
  export function isJunk(raw: string): boolean {
73
92
  const host = hostOf(raw);
74
93
  if (JUNK_HOSTS.some((j) => host === j || host.endsWith('.' + j))) return true;
@@ -85,3 +104,77 @@ function pathOf(raw: string): string {
85
104
  return '';
86
105
  }
87
106
  }
107
+
108
+ // ── SSRF gates: shared by scrape.ts and transport.ts; this leaf imports ──────
109
+ // ── nothing, so both consumers stay cycle-free. ───────────────────────────────
110
+
111
+ function isPrivateIpv4(ipv4: string): boolean {
112
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4);
113
+ if (!m) return false;
114
+ const [a, b] = [Number(m[1]), Number(m[2])];
115
+ return (
116
+ a === 0 ||
117
+ a === 10 ||
118
+ a === 127 ||
119
+ (a === 169 && b === 254) ||
120
+ (a === 172 && b >= 16 && b <= 31) ||
121
+ (a === 192 && b === 168)
122
+ );
123
+ }
124
+
125
+ function numericIpv4(host: string): string | null {
126
+ if (!/^[0-9.]+$/.test(host) && !/^0[xX][0-9a-fA-F.]+$/.test(host)) return null;
127
+ const fields = host.split('.');
128
+ if (fields.length > 4) return null;
129
+ const parts: number[] = [];
130
+ for (const f of fields) {
131
+ if (/^0[xX][0-9a-fA-F]+$/.test(f)) parts.push(parseInt(f, 16));
132
+ else if (/^0[0-7]+$/.test(f)) parts.push(parseInt(f, 8));
133
+ else if (/^\d+$/.test(f)) parts.push(Number(f));
134
+ else return null;
135
+ }
136
+ const last = parts[parts.length - 1];
137
+ if (parts.length === 1) {
138
+ if (last >= 2 ** 32) return null;
139
+ return [(last >>> 24) & 0xff, (last >>> 16) & 0xff, (last >>> 8) & 0xff, last & 0xff].join('.');
140
+ }
141
+ if (parts.slice(0, -1).some((n) => n > 255)) return null;
142
+ if (last >= 2 ** (8 * (4 - parts.length + 1))) return null;
143
+ if (parts.length === 2) return [parts[0], 0, 0, last].join('.');
144
+ if (parts.length === 3) return [parts[0], parts[1], (last >>> 8) & 0xff, last & 0xff].join('.');
145
+ return parts.join('.');
146
+ }
147
+
148
+ /** True for loopback/private/link-local numeric literals AND IPv6 forms that
149
+ * embed them (`::ffff:a.b.c.d`, and the `::aabb:ccdd` IPv4-compatible
150
+ * shorthand WHATWG serializes `::127.0.0.1` into). Scope is deliberate:
151
+ * hostnames that merely RESOLVE into private space are out of the threat
152
+ * model — the hostile web points the agent at literals, not at their DNS. */
153
+ export function isPrivateHost(host: string): boolean {
154
+ const h = host.toLowerCase().replace(/^\[(.*)\]$/, '$1');
155
+ if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost')) return true;
156
+ const mapped = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
157
+ if (mapped) {
158
+ const [a, b] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
159
+ if (isPrivateIpv4(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`)) return true;
160
+ }
161
+ const compat = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
162
+ if (compat) {
163
+ const [a, b] = [parseInt(compat[1], 16), parseInt(compat[2], 16)];
164
+ if (isPrivateIpv4(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`)) return true;
165
+ }
166
+ const ipv4 = numericIpv4(h);
167
+ if (ipv4 && isPrivateIpv4(ipv4)) return true;
168
+ if (/^[0-9a-f:]+$/.test(h) && h.includes(':')) {
169
+ if (h.startsWith('::1') || h === '0:0:0:0:0:0:0:1') return true;
170
+ if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ export function validateUrl(raw: string, allowPrivate = false): URL {
176
+ const u = new URL(raw);
177
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`unsupported protocol: ${u.protocol}`);
178
+ if (!allowPrivate && isPrivateHost(u.hostname)) throw new Error('private/loopback address rejected');
179
+ return u;
180
+ }
package/src/view.ts ADDED
@@ -0,0 +1,223 @@
1
+ import type { FusedHit } from './fuse';
2
+ import { hostOf } from './urls';
3
+
4
+ const SNIPPET_MAX = 240;
5
+ const CHAPTER_TITLE_MAX = 100;
6
+ const TOC_TITLE_MAX = 60;
7
+ const ERROR_SNIP = 40;
8
+ const DEFAULT_WRAP = 100;
9
+ const MAX_WRAP = 240;
10
+
11
+ export const READ_FIRST_TAG = ' · recommended first read';
12
+
13
+ interface ErroredEngine {
14
+ engine: string;
15
+ error: string;
16
+ }
17
+
18
+ export interface SearchViewOpts {
19
+ wrap?: number;
20
+ }
21
+
22
+ export interface SearchView {
23
+ query: string;
24
+ hits: FusedHit[];
25
+ enginesUsed: string[];
26
+ errored: ErroredEngine[];
27
+ fatal?: { engine: string; error: string };
28
+ retryNote?: string;
29
+ }
30
+
31
+ function fmtSnippet(s: string, max: number): string {
32
+ const t = s.replace(/\s+/g, ' ').trim();
33
+ return t.length > max ? t.slice(0, max) + '…' : t;
34
+ }
35
+
36
+ function cleanSnippet(s: string): string {
37
+ return s
38
+ .replace(/\\(.)/g, '$1')
39
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
40
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
41
+ .replace(/\[\[\d+\]\]\([^)]*\)\)?/g, '')
42
+ .replace(/\[\[\d+\]\]/g, '')
43
+ .replace(/\(https?:\/\/[^)]*\)/g, ' ')
44
+ .replace(/#{2,6}\s+/g, '')
45
+ .replace(/\|/g, ' ')
46
+ .replace(/(^|\s)-+(?=\s|$)/g, '$1')
47
+ .replace(/(^|[()?!:;])\s?\d{1,2}:\d{2}\s+/g, '$1 ')
48
+ .replace(/\(\s*\)/g, '')
49
+ .replace(/\((?=[0-9+\-–·\s]*[+\-–·])[0-9+\-–·\s]*\)/g, ' ')
50
+ .replace(/\d{1,4}[KM]?\s*•\s*\d{1,4}(?:mo|d|w|y|h)\s+ago/gi, ' ')
51
+ .replace(/\s+([?:!;])/g, '$1')
52
+ .replace(/(^|\s)([*]{1,3}|_{1,2}|`)(\S(?:[^*`_]*\S)?)\2(?=\s|$|[.,;:!?)]|\b)/g, '$1$3')
53
+ .replace(/\s+/g, ' ')
54
+ .trim();
55
+ }
56
+
57
+ function stripSiteSuffix(title: string, host: string): string {
58
+ const m = /^(.{3,})\s+[|–—-]\s+([^|–—-]{2,40})$/.exec(title.trim());
59
+ if (!m) return title;
60
+ const suffix = m[2].trim();
61
+ const head = m[1].trim();
62
+ const hostBase = host.replace(/^www\./, '').split('.')[0];
63
+ if (host && suffix.toLowerCase().includes(hostBase)) return head;
64
+ if (suffix.length <= 25 && !/\s/.test(suffix)) return head;
65
+ return title;
66
+ }
67
+
68
+ function normalizeHitDate(raw: string | undefined): string | undefined {
69
+ if (!raw) return undefined;
70
+ const t = Date.parse(raw);
71
+ if (!Number.isNaN(t)) return new Date(t).toISOString().slice(0, 10);
72
+ const rel = /^(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days|w|week|weeks|mo|month|months)\b/i.exec(
73
+ raw.trim(),
74
+ );
75
+ if (rel) {
76
+ const n = parseInt(rel[1], 10);
77
+ const unit = rel[2].toLowerCase();
78
+ const ms = unit.startsWith('h')
79
+ ? 3_600_000
80
+ : unit.startsWith('mo')
81
+ ? 2_592_000_000
82
+ : unit.startsWith('m')
83
+ ? 60_000
84
+ : unit.startsWith('d')
85
+ ? 86_400_000
86
+ : 604_800_000;
87
+ return new Date(Date.now() - n * ms).toISOString().slice(0, 10);
88
+ }
89
+ if (/^yesterday$/i.test(raw.trim())) return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
90
+ return undefined;
91
+ }
92
+
93
+ function formatEvidence(r: FusedHit): string {
94
+ if (!r.engineRanks?.length) return r.engines.length > 1 ? ` ← ${r.engines.length} engines` : '';
95
+ if (r.engineRanks.length === 1) return ` ⇐ ${r.engineRanks[0].engine} only (single-engine: unverified)`;
96
+ const shown = r.engineRanks
97
+ .slice(0, 6)
98
+ .map((e) => `${e.engine}#${e.rank}`)
99
+ .join(', ');
100
+ const rest = r.engineRanks.length > 6 ? `, +${r.engineRanks.length - 6} more` : '';
101
+ return ` ⇐ ${shown}${rest}`;
102
+ }
103
+
104
+ function cleanTitle(s: string, host: string): string {
105
+ return stripSiteSuffix(cleanSnippet(s), host).replace(/\s+[—–]\s+/g, ': ');
106
+ }
107
+
108
+ function tocTitle(r: FusedHit): string {
109
+ return fmtSnippet(cleanTitle(r.title, hostOf(r.url)), TOC_TITLE_MAX) || fmtSnippet(r.url, TOC_TITLE_MAX);
110
+ }
111
+
112
+ function chapterTitle(r: FusedHit): string {
113
+ return fmtSnippet(cleanTitle(r.title, hostOf(r.url)), CHAPTER_TITLE_MAX) || r.url;
114
+ }
115
+
116
+ function compactWarn(e: ErroredEngine): string {
117
+ const status = /HTTP\s+(\d{3})/i.exec(e.error)?.[1];
118
+ return status ? `${status}: ${e.engine}` : `${e.engine}: ${e.error.slice(0, ERROR_SNIP)}`;
119
+ }
120
+
121
+ export function buildSearchText(v: SearchView, opts: SearchViewOpts = {}): string {
122
+ const count = v.hits.length;
123
+ const width = Math.max(40, Math.min(MAX_WRAP, opts.wrap ?? DEFAULT_WRAP));
124
+ const engineWord = v.enginesUsed.length === 1 ? 'engine' : 'engines';
125
+ if (!count) {
126
+ return v.fatal ? `No results: ${v.fatal.error}\n` : `No results. Broaden the query.\n`;
127
+ }
128
+ let text = `${v.enginesUsed.length} ${engineWord} [${v.enginesUsed.join(', ')}]`;
129
+ if (v.retryNote) text += ` · ${v.retryNote}`;
130
+ const warns = v.errored.map(compactWarn);
131
+ if (warns.length) text += ` ⚠ ${warns.join(' · ')}`;
132
+ text += '\n';
133
+ const rows = v.hits.map((r, i) => {
134
+ const host = hostOf(r.url);
135
+ return `${String(i + 1).padStart(2)}. ${tocTitle(r)}${host ? ` · ${host}` : ''}`;
136
+ });
137
+ text += `\n${rows.join('\n')}\n`;
138
+ v.hits.forEach((r, i) => {
139
+ const title = chapterTitle(r);
140
+ const snippet = fmtSnippet(cleanSnippet(r.snippet), SNIPPET_MAX);
141
+ const via = formatEvidence(r).trim();
142
+ const when = normalizeHitDate(r.date);
143
+ let meta = r.url;
144
+ if (when) meta += ` · ${when}`;
145
+ if (via) meta += ` ${via}`;
146
+ text += `\n## ${i + 1}. ${title}${i === 0 && count > 1 ? READ_FIRST_TAG : ''}\n`;
147
+ text += ` ${meta}\n`;
148
+ if (snippet) text += ` ${snippet}\n`;
149
+ });
150
+ return wrapAll(text, width);
151
+ }
152
+
153
+ export function wrapAll(text: string, width: number): string {
154
+ return text
155
+ .split('\n')
156
+ .map((l) => wrapLine(l, width))
157
+ .join('\n');
158
+ }
159
+
160
+ function wrapLine(line: string, width: number): string {
161
+ if (line.length <= width) return line;
162
+ const hasTag = line.endsWith(READ_FIRST_TAG);
163
+ const body = hasTag ? line.slice(0, line.length - READ_FIRST_TAG.length) : line;
164
+ const m = /^(##\s*\d+\.\s+|\s*\d+\.\s+|\s*)/.exec(body);
165
+ const head = m?.[1] ?? '';
166
+ const indent = head.replace(/\S/g, ' ');
167
+ const words = body.slice(head.length).split(' ');
168
+ const limit = width - indent.length;
169
+ const out: string[] = [];
170
+ let cur = '';
171
+ for (const word of words) {
172
+ if (!word) continue;
173
+ const fits = cur ? cur.length + 1 + word.length <= limit : word.length <= limit;
174
+ if (fits) {
175
+ cur = cur ? `${cur} ${word}` : word;
176
+ continue;
177
+ }
178
+ if (cur) {
179
+ out.push(cur);
180
+ cur = '';
181
+ }
182
+ if (word.length > limit) {
183
+ for (const chunk of breakToken(word, limit)) {
184
+ if (cur) {
185
+ out.push(cur);
186
+ cur = '';
187
+ }
188
+ cur = chunk;
189
+ }
190
+ } else {
191
+ cur = word;
192
+ }
193
+ }
194
+ if (cur) out.push(cur);
195
+ const lines = out.map((l, i) => (i ? indent + l : head + l));
196
+ if (hasTag) {
197
+ const last = lines.length - 1;
198
+ if (last >= 0 && lines[last].length + READ_FIRST_TAG.length <= width) {
199
+ lines[last] += READ_FIRST_TAG;
200
+ } else {
201
+ lines.push(indent + READ_FIRST_TAG.trimStart());
202
+ }
203
+ }
204
+ return lines.join('\n');
205
+ }
206
+
207
+ function breakToken(token: string, limit: number): string[] {
208
+ const out: string[] = [];
209
+ let from = 0;
210
+ while (token.length - from > limit) {
211
+ let cut = from + limit;
212
+ for (let i = cut - 1; i > from; i--) {
213
+ if (i < token.length && /[/\-_.?=&]/.test(token[i])) {
214
+ cut = i + 1;
215
+ break;
216
+ }
217
+ }
218
+ out.push(token.slice(from, cut));
219
+ from = cut;
220
+ }
221
+ out.push(token.slice(from));
222
+ return out;
223
+ }