pi-wsearch 0.3.1 → 0.4.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/README.md CHANGED
@@ -39,7 +39,10 @@ and reads only those. No
39
39
  full reads, no 40 KB dumps. Sections over 3,000 characters split into
40
40
  addressable continuation entries (`Section (cont. 1)`), so no tail is ever
41
41
  unreachable; pages that overflow are capped; the final response is clipped.
42
- 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`.
43
46
 
44
47
  **Reads are free.** Page parsing is built in, nothing billed per read, no key
45
48
  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.3.1",
3
+ "version": "0.4.0",
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": {
@@ -45,10 +45,10 @@
45
45
  ]
46
46
  },
47
47
  "peerDependencies": {
48
- "@earendil-works/pi-ai": "*",
49
- "@earendil-works/pi-agent-core": "*",
50
- "@earendil-works/pi-coding-agent": "*",
51
- "@earendil-works/pi-tui": "*",
48
+ "@earendil-works/pi-ai": "^1.3.0",
49
+ "@earendil-works/pi-agent-core": "^1.3.0",
50
+ "@earendil-works/pi-coding-agent": "^1.3.0",
51
+ "@earendil-works/pi-tui": "^1.3.0",
52
52
  "typebox": "*"
53
53
  },
54
54
  "devDependencies": {
@@ -57,9 +57,13 @@
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
+ },
62
+ "dependencies": {
63
+ "htmlparser2": "^12.0.0",
64
+ "parse5": "^8.0.1",
65
+ "parse5-htmlparser2-tree-adapter": "^8.0.1"
61
66
  },
62
- "dependencies": {},
63
67
  "main": "index.ts",
64
68
  "files": [
65
69
  "index.ts",
package/src/dom.ts ADDED
@@ -0,0 +1,328 @@
1
+ import { parse } from 'parse5';
2
+ import { adapter } from 'parse5-htmlparser2-tree-adapter';
3
+ import { parseDocument } from 'htmlparser2';
4
+
5
+ interface DomNode {
6
+ type: string;
7
+ name?: string;
8
+ attribs?: Record<string, string>;
9
+ data?: string;
10
+ children?: DomNode[];
11
+ }
12
+
13
+ const SKIP = new Set(['script', 'style', 'noscript', 'svg', 'nav', 'footer', 'aside', 'head', 'title']);
14
+
15
+ const BLOCK = new Set(['p', 'div', 'section', 'article', 'blockquote', 'pre']);
16
+
17
+ function isKatex(n: DomNode): boolean {
18
+ return (n.attribs?.['class'] ?? '').split(/\s+/).includes('katex');
19
+ }
20
+
21
+ function toDom(html: string): DomNode {
22
+ return parse(html, { treeAdapter: adapter }) as unknown as DomNode;
23
+ }
24
+
25
+ function inlineOf(n: DomNode): string {
26
+ const t = n.type;
27
+ if (t === 'text') return n.data ?? '';
28
+ if (t === 'comment' || t === 'directive') return '';
29
+ const name = n.name ?? '';
30
+ if (!name || SKIP.has(name) || isKatex(n)) return '';
31
+ if (name === 'math') {
32
+ const tex = annotationTex(n);
33
+ return tex ? `$${tex}$` : '';
34
+ }
35
+ if (name === 'br' || name === 'hr' || name === 'li') return ' ';
36
+ return (n.children ?? []).map(inlineOf).join('');
37
+ }
38
+
39
+ function annotationTex(n: DomNode): string | undefined {
40
+ const found: DomNode[] = [];
41
+ allDescendants(n, 'annotation', found);
42
+ for (const c of found) {
43
+ const enc = (c.attribs?.['encoding'] ?? '').toLowerCase();
44
+ if (enc === 'application/x-tex' || enc.includes('x-tex')) return inlineOf(c).trim();
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function allDescendants(n: DomNode, name: string, out: DomNode[]): void {
50
+ for (const c of n.children ?? []) {
51
+ if (c.type === 'tag') {
52
+ if (c.name === name) out.push(c);
53
+ allDescendants(c, name, out);
54
+ }
55
+ }
56
+ }
57
+
58
+ function listText(n: DomNode, out: string[]): void {
59
+ for (const c of n.children ?? []) {
60
+ if (c.type === 'tag' && c.name === 'li') {
61
+ newline(out);
62
+ out.push('• ');
63
+ liText(c, out);
64
+ }
65
+ }
66
+ }
67
+
68
+ function liText(n: DomNode, out: string[]): void {
69
+ for (const c of n.children ?? []) {
70
+ const t = c.type;
71
+ if (t === 'text') {
72
+ out.push((c.data ?? '').replace(/\s+/g, ' '));
73
+ continue;
74
+ }
75
+ if (t === 'comment' || t === 'directive') continue;
76
+ const name = c.name ?? '';
77
+ if (SKIP.has(name) || isKatex(c)) continue;
78
+ if (name === 'li') {
79
+ newline(out);
80
+ out.push('• ');
81
+ liText(c, out);
82
+ continue;
83
+ }
84
+ if (name === 'ul' || name === 'ol') {
85
+ listText(c, out);
86
+ continue;
87
+ }
88
+ if (name === 'br' || name === 'hr') {
89
+ out.push('\n');
90
+ continue;
91
+ }
92
+ if (name === 'pre') {
93
+ out.push('\n');
94
+ out.push(inlineOf(c).replace(/\n/g, '\uE000'));
95
+ continue;
96
+ }
97
+ if (name === 'math') {
98
+ const tex = annotationTex(c);
99
+ if (tex) out.push(`$${tex}$`);
100
+ continue;
101
+ }
102
+ if (BLOCK.has(name) || name === 'table') {
103
+ newline(out);
104
+ liText(c, out);
105
+ continue;
106
+ }
107
+ out.push(inlineOf(c));
108
+ }
109
+ }
110
+
111
+ function renderTable(n: DomNode, out: string[]): void {
112
+ const rows: string[][] = [];
113
+ for (const tr of (() => {
114
+ const found: DomNode[] = [];
115
+ allDescendants(n, 'tr', found);
116
+ return found;
117
+ })()) {
118
+ const cells: string[] = [];
119
+ for (const cell of tr.children ?? []) {
120
+ if (cell.type === 'tag' && (cell.name === 'td' || cell.name === 'th')) {
121
+ cells.push(inlineOf(cell).replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'));
122
+ }
123
+ }
124
+ if (cells.length) rows.push(cells);
125
+ }
126
+ newline(out);
127
+ if (rows.length < 2 || Math.max(...rows.map((r) => r.length)) < 2) {
128
+ out.push(rows.map((r) => r.join(' ')).join('\n'));
129
+ out.push('\n');
130
+ return;
131
+ }
132
+ const cols = Math.max(...rows.map((r) => r.length));
133
+ const line = (r: string[]) => `| ${r.concat(Array(Math.max(0, cols - r.length)).fill(' ')).join(' | ')} |`;
134
+ out.push(
135
+ [line(rows[0]), `| ${Array.from({ length: cols }, () => '---').join(' | ')} |`, ...rows.slice(1).map(line)].join(
136
+ '\n',
137
+ ),
138
+ );
139
+ out.push('\n');
140
+ }
141
+
142
+ function newline(out: string[]): void {
143
+ if (out.length > 0 && !out[out.length - 1].endsWith('\n')) out.push('\n');
144
+ }
145
+
146
+ export function treeToMarkdownish(html: string): string {
147
+ const doc = toDom(html);
148
+ const out: string[] = [];
149
+ renderBlocks(doc.children ?? [], out);
150
+ return out.join('');
151
+ }
152
+
153
+ function renderBlocks(nodes: DomNode[], out: string[]): void {
154
+ for (const n of nodes) renderBlock(n, out);
155
+ }
156
+
157
+ function renderBlock(n: DomNode, out: string[]): void {
158
+ const t = n.type;
159
+ if (t === 'text') {
160
+ out.push(n.data ?? '');
161
+ return;
162
+ }
163
+ if (t === 'comment' || t === 'directive' || t === 'style' || t === 'script') return;
164
+ const name = n.name ?? '';
165
+ if (!name || SKIP.has(name) || isKatex(n)) return;
166
+ switch (name) {
167
+ case 'h1':
168
+ case 'h2':
169
+ case 'h3':
170
+ case 'h4':
171
+ case 'h5':
172
+ case 'h6':
173
+ newline(out);
174
+ out.push(`${'#'.repeat(Number(name[1]))} ${inlineOf(n).trim()}\n`);
175
+ return;
176
+ case 'li':
177
+ newline(out);
178
+ out.push('• ');
179
+ liText(n, out);
180
+ return;
181
+ case 'ul':
182
+ case 'ol':
183
+ newline(out);
184
+ listText(n, out);
185
+ newline(out);
186
+ return;
187
+ case 'pre':
188
+ newline(out);
189
+ out.push(inlineOf(n).replace(/\n/g, '\uE000'));
190
+ newline(out);
191
+ return;
192
+ case 'math': {
193
+ const tex = annotationTex(n);
194
+ if (tex) out.push(` $${tex}$ `);
195
+ return;
196
+ }
197
+ case 'br':
198
+ case 'hr':
199
+ newline(out);
200
+ return;
201
+ case 'table':
202
+ renderTable(n, out);
203
+ return;
204
+ case 'p':
205
+ case 'div':
206
+ case 'section':
207
+ case 'article':
208
+ case 'blockquote':
209
+ newline(out);
210
+ renderBlocks(n.children ?? [], out);
211
+ newline(out);
212
+ return;
213
+ default:
214
+ renderBlocks(n.children ?? [], out);
215
+ }
216
+ }
217
+
218
+ const DATE_KEYWORDS = ['datePublished', 'dateCreated', 'uploadDate', 'publishedAt'];
219
+ const DATE_META_NAMES = [
220
+ 'article:published_time',
221
+ 'datePublished',
222
+ 'publish-date',
223
+ 'pubdate',
224
+ 'parsely-pub-date',
225
+ 'date',
226
+ ];
227
+
228
+ function toIso(v: string | undefined): string | undefined {
229
+ if (!v) return undefined;
230
+ const ms = Date.parse(v.trim());
231
+ if (Number.isNaN(ms) || ms < 0) return undefined;
232
+ return new Date(ms).toISOString().slice(0, 10);
233
+ }
234
+
235
+ function jsonLdDate(nodes: DomNode[]): string | undefined {
236
+ const pick = (value: unknown): string | undefined => {
237
+ if (typeof value === 'string') {
238
+ const iso = toIso(value);
239
+ if (iso) return iso;
240
+ }
241
+ return undefined;
242
+ };
243
+ const walk = (value: unknown): string | undefined => {
244
+ if (Array.isArray(value)) {
245
+ for (const v of value) {
246
+ const hit = walk(v);
247
+ if (hit) return hit;
248
+ }
249
+ return undefined;
250
+ }
251
+ if (typeof value === 'object' && value !== null) {
252
+ for (const key of DATE_KEYWORDS) {
253
+ const hit = pick((value as Record<string, unknown>)[key]);
254
+ if (hit) return hit;
255
+ }
256
+ for (const v of Object.values(value as Record<string, unknown>)) {
257
+ const hit = walk(v);
258
+ if (hit) return hit;
259
+ }
260
+ }
261
+ return undefined;
262
+ };
263
+ const visit = (n: DomNode): string | undefined => {
264
+ if (n.type === 'tag' && n.name === 'script') {
265
+ const type = (n.attribs?.['type'] ?? '').toLowerCase().trim();
266
+ if (type === 'application/ld+json' || type === 'application/json' || type === 'ld+json') {
267
+ try {
268
+ const hit = walk(JSON.parse(n.data ?? ''));
269
+ if (hit) return hit;
270
+ } catch {}
271
+ }
272
+ }
273
+ for (const c of n.children ?? []) {
274
+ const hit = visit(c);
275
+ if (hit) return hit;
276
+ }
277
+ return undefined;
278
+ };
279
+ for (const n of nodes) {
280
+ const hit = visit(n);
281
+ if (hit) return hit;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ export interface HeadScan {
287
+ title: string;
288
+ publishedAt?: string;
289
+ }
290
+
291
+ export function headScan(html: string): HeadScan {
292
+ let doc: DomNode;
293
+ try {
294
+ doc = parseDocument(html) as unknown as DomNode;
295
+ } catch {
296
+ const title = /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim()?.slice(0, 200) ?? '';
297
+ return { title };
298
+ }
299
+ const nodes = doc.children ?? [];
300
+ let title = '';
301
+ let ogTitle = '';
302
+ let metaDate: string | undefined;
303
+ let timeDate: string | undefined;
304
+
305
+ const visit = (n: DomNode): void => {
306
+ if (n.type !== 'tag') return;
307
+ const name = n.name ?? '';
308
+ const attribs = n.attribs ?? {};
309
+ if (name === 'title' && !title) {
310
+ title = (n.children ?? [])
311
+ .filter((c) => c.type === 'text')
312
+ .map((c) => c.data ?? '')
313
+ .join('')
314
+ .trim()
315
+ .slice(0, 200);
316
+ }
317
+ if (name === 'meta') {
318
+ const key = (attribs['property'] ?? attribs['name'] ?? '').toLowerCase().trim();
319
+ if (!ogTitle && key === 'og:title') ogTitle = (attribs['content'] ?? '').trim().slice(0, 200);
320
+ if (!metaDate && DATE_META_NAMES.includes(key)) metaDate = toIso(attribs['content']);
321
+ }
322
+ if (name === 'time' && !timeDate && attribs['datetime']) timeDate = toIso(attribs['datetime']);
323
+ for (const c of n.children ?? []) visit(c);
324
+ };
325
+ for (const n of nodes) visit(n);
326
+
327
+ return { title: title || ogTitle, publishedAt: jsonLdDate(nodes) ?? metaDate ?? timeDate };
328
+ }
package/src/scrape.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type RemoteDoc,
19
19
  type Renderer,
20
20
  } from './transport';
21
+ import { headScan, treeToMarkdownish } from './dom';
21
22
 
22
23
  export interface ScrapeResult {
23
24
  url: string;
@@ -94,35 +95,6 @@ export function decodeHtmlEntities(s: string): string {
94
95
  });
95
96
  }
96
97
 
97
- function stripKatexSpans(h: string): string {
98
- let out = '';
99
- let i = 0;
100
- for (;;) {
101
- const start = /<span[^>]*class="[^"]*katex[^"]*"[^>]*>/i.exec(h.slice(i));
102
- if (!start || start.index === undefined) {
103
- out += h.slice(i);
104
- break;
105
- }
106
- const abs = i + start.index;
107
- out += h.slice(i, abs);
108
- let depth = 1;
109
- let j = abs + start[0].length;
110
- while (depth > 0 && j < h.length) {
111
- const next = /<span\b[^>]*>|<\/span>/gi;
112
- next.lastIndex = j;
113
- const tag = next.exec(h);
114
- if (!tag) {
115
- j = h.length;
116
- break;
117
- }
118
- j = tag.index + tag[0].length;
119
- depth += tag[0].startsWith('</') ? -1 : 1;
120
- }
121
- i = j;
122
- }
123
- return out;
124
- }
125
-
126
98
  export function extractHtml(html: string): { title: string; text: string; publishedAt?: string; paywalled: boolean } {
127
99
  const paywalled =
128
100
  /class="[^"]*(?:fc-ab-root|metering-modal|paywall|pw-overlay|subscription-overlay|regwall)[^"]*"/i.test(html) ||
@@ -130,61 +102,8 @@ export function extractHtml(html: string): { title: string; text: string; publis
130
102
  /<meta[^>]+(?:property|name)=["'](?:isAccessibleForFree|tunnel:[^"']*paywall)["'][^>]+content=["'](?:false|yes)["']/i.test(
131
103
  html,
132
104
  );
133
- const title =
134
- /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim()?.slice(0, 200) ||
135
- /<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i.exec(html)?.[1]?.slice(0, 200) ||
136
- '';
137
- const publishedAt = extractPublishedDate(html);
138
- let h = html
139
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
140
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
141
- .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
142
- .replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
143
- .replace(/<!--[\s\S]*?-->/g, ' ')
144
- .replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
145
- .replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
146
- .replace(/<aside[\s\S]*?<\/aside>/gi, ' ');
147
- h = h.replace(/<pre[\s>][\s\S]*?<\/pre>/gi, (m) => m.replace(/\n/g, '\uE000'));
148
- h = h.replace(/<table[\s>][\s\S]*?<\/table>/gi, (table) => {
149
- const rows = [...table.matchAll(/<tr[\s>][\s\S]*?<\/tr>/gi)].map((r) =>
150
- [...r[0].matchAll(/<t([hd])[\s>][\s\S]*?<\/t\1>/gi)].map((c) =>
151
- c[0]
152
- .replace(/<[^>]+>/g, ' ')
153
- .replace(/\s+/g, ' ')
154
- .replace(/\|/g, '\\|')
155
- .trim(),
156
- ),
157
- );
158
- const grid = rows.filter((r) => r.length);
159
- if (grid.length < 2) return table;
160
- const cols = Math.max(...grid.map((g) => g.length));
161
- if (cols < 2) return table;
162
- const line = (g: string[]) => {
163
- const cells = g.slice(0, cols);
164
- while (cells.length < cols) cells.push(' ');
165
- return `| ${cells.join(' | ')} |`;
166
- };
167
- const md = [line(grid[0]), `| ${Array.from({ length: cols }, () => '---').join(' | ')} |`];
168
- for (const g of grid.slice(1)) md.push(line(g));
169
- return `\n${md.join('\n')}\n`;
170
- });
171
- h = stripKatexSpans(h);
172
- h = h.replace(/<math[\s>][\s\S]*?<\/math>/gi, (m) => {
173
- const tex = /<annotation[^>]*encoding=["']application\/x-tex["'][^>]*>([\s\S]*?)<\/annotation>/i.exec(m)?.[1];
174
- return tex?.trim() ? ` $${tex.trim()}$ ` : ' ';
175
- });
176
- h = h.replace(
177
- /<h([1-6])[^>]*>(.*?)<\/h\1>/gis,
178
- (_, n, inner) => `\n${'#'.repeat(Number(n))} ${inner.replace(/<[^>]+>/g, ' ').trim()}\n`,
179
- );
180
- h = h
181
- .replace(/<li[^>]*>/gi, '\n• ')
182
- .replace(/<\/(p|div|li|tr|section|article|blockquote|table|ul|ol|pre)>/gi, '\n')
183
- .replace(/<(br|hr)[^>]*>/gi, '\n');
184
- // strip tags first, THEN decode entities: escaped markup shown as literal
185
- // text (`&lt;limit...&gt;`) must survive as text, not be stripped as a tag
186
- h = h.replace(/<[^>]+>/g, ' ');
187
- h = h.replace(/\uE000/g, '\n');
105
+ const { title, publishedAt } = headScan(html);
106
+ let h = treeToMarkdownish(html).replace(/\uE000/g, '\n');
188
107
  h = stripToAscii(decodeHtmlEntities(h));
189
108
  const lines = h
190
109
  .split('\n')
@@ -230,21 +149,6 @@ export function extractHtml(html: string): { title: string; text: string; publis
230
149
  return { title, publishedAt, paywalled, text: out.join('\n').replace(/\n{3,}/g, '\n\n') };
231
150
  }
232
151
 
233
- function extractPublishedDate(html: string): string | undefined {
234
- const iso = (v: string | undefined) => {
235
- if (!v) return undefined;
236
- const t = Date.parse(v.trim());
237
- return Number.isNaN(t) || t < 0 ? undefined : new Date(t).toISOString().slice(0, 10);
238
- };
239
- const ld = /"datePublished"\s*:\s*"([^"]+)"/.exec(html)?.[1];
240
- const meta =
241
- /<meta[^>]+(?:property|name)=["'](?:article:published_time|datePublished|publish-date|pubdate|parsely-pub-date|date)["'][^>]+content=["']([^"']+)["']/i.exec(
242
- html,
243
- )?.[1];
244
- const timeEl = /<time[^>]+datetime=["']([^"']+)["']/i.exec(html)?.[1];
245
- return iso(ld) ?? iso(meta) ?? iso(timeEl);
246
- }
247
-
248
152
  const NAV_LABEL_RE =
249
153
  /^(home|menu|about( us)?|docs?|documentation|downloads?|license|licence|support|purchase|buy|pricing|search|login|log ?in|sign ?(in|up|out)|register|account|blog|news|contact|careers?|jobs|store|shop|forum|community|tutorials?|api|guide|guides|reference|examples?|faq|help|terms|privacy|cookies?|sitemap|rss|changelog|releases?|twitter|facebook|github|discord|youtube|linkedin|mastodon|x)$/i;
250
154
 
package/src/tools.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  */
5
5
  import { Type } from 'typebox';
6
6
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from '@earendil-works/pi-coding-agent';
7
- import { keyHint } from '@earendil-works/pi-coding-agent';
8
- import { Text } from '@earendil-works/pi-tui';
7
+ import { keyHint, keyText, rawKeyHint } from '@earendil-works/pi-coding-agent';
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';
24
- import { fuse, diversifyByHost, type FusedHit } from './fuse';
25
- import { hostOf, normalizeUrl } from './urls';
23
+ import { clearDeadEngines, runEngines, type EngineOutcome } from './engines';
24
+ import { fuse, diversifyByHost } from './fuse';
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,19 +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
90
  function dropRarestTerm(query: string): string {
87
91
  const words = query.split(/\s+/).filter((w) => w.length > 2 && !/^(?:site|inurl|intitle|filetype):/i.test(w));
88
92
  if (words.length < 2) return '';
@@ -93,54 +97,6 @@ function dropRarestTerm(query: string): string {
93
97
  .trim();
94
98
  }
95
99
 
96
- function stripSiteSuffix(title: string, host: string): string {
97
- const m = /^(.{3,})\s+[|–—-]\s+([^|–—-]{2,40})$/.exec(title.trim());
98
- if (!m) return title;
99
- const suffix = m[2].trim();
100
- const head = m[1].trim();
101
- const hostBase = host.replace(/^www\./, '').split('.')[0];
102
- if (host && (suffix.toLowerCase().includes(hostBase) || suffix.length <= 25)) return head;
103
- return title;
104
- }
105
-
106
- function normalizeHitDate(raw: string | undefined): string | undefined {
107
- if (!raw) return undefined;
108
- const t = Date.parse(raw);
109
- if (!Number.isNaN(t)) return new Date(t).toISOString().slice(0, 10);
110
- const rel = /^(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days|w|week|weeks|mo|month|months)\b/i.exec(
111
- raw.trim(),
112
- );
113
- if (rel) {
114
- const n = parseInt(rel[1], 10);
115
- const unit = rel[2].toLowerCase();
116
- // 'mo' must win over 'm' BEFORE the minutes branch: the regex captures
117
- // 'months' verbatim, so a bare startsWith('m') misclassifies it ("3
118
- // months ago" used to render as ~today). Divide by unit, in months>…>ms.
119
- const ms = unit.startsWith('h')
120
- ? 3_600_000
121
- : unit.startsWith('mo')
122
- ? 2_592_000_000
123
- : unit.startsWith('m')
124
- ? 60_000
125
- : unit.startsWith('d')
126
- ? 86_400_000
127
- : 604_800_000;
128
- return new Date(Date.now() - n * ms).toISOString().slice(0, 10);
129
- }
130
- if (/^yesterday$/i.test(raw.trim())) return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
131
- return undefined;
132
- }
133
-
134
- function cleanSnippet(s: string): string {
135
- return s
136
- .replace(/\\([_*`[\]()#>~.-])/g, '$1')
137
- .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
138
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
139
- .replace(/(^|\s)([*]{1,3}|_{1,2}|`)(\S(?:[^*`_]*\S)?)\2(?=\s|$|[.,;:!?)]|\b)/g, '$1$3')
140
- .replace(/\s+/g, ' ')
141
- .trim();
142
- }
143
-
144
100
  export function registerWebTools(pi: ExtensionAPI) {
145
101
  const cache = openCache(getConfig().cacheDir + '/cache.json');
146
102
  const exposure = existsSync(disabledFlagPath()) ? 'hidden' : 'direct';
@@ -166,6 +122,13 @@ export function registerWebTools(pi: ExtensionAPI) {
166
122
  parameters: SEARCH_PARAMS,
167
123
  outputSchema: SEARCH_OUTPUT,
168
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
+
169
132
  async execute(_id, params, signal, _onUpdate, _ctx) {
170
133
  if (signal?.aborted) throw new Error(ABORT_ERROR);
171
134
  const outcomes = await runEngines({
@@ -205,38 +168,32 @@ export function registerWebTools(pi: ExtensionAPI) {
205
168
  fused.filter((r) => !r.junk),
206
169
  2,
207
170
  ).slice(0, Math.max((params.max_results ?? DEFAULT_MAX_RESULTS) * 2, DEFAULT_MAX_RESULTS));
208
- if (candidates.length) retryNote = ` (retried as "${relaxed}" after zero results)`;
171
+ if (candidates.length) retryNote = `retried as "${relaxed}"`;
209
172
  }
210
173
  }
211
174
  }
212
175
  const visible = candidates.slice(0, params.max_results ?? DEFAULT_MAX_RESULTS);
213
176
  const enginesUsed = [...new Set(fused.flatMap((r) => r.engines))];
214
- const snip = 240;
215
-
216
- 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`;
217
- if (!visible.length) {
218
- const fatal = outcomes.find((o) => o.engine === 'none' && o.error);
219
- text = fatal
220
- ? `No results for "${params.query}": ${fatal.error}\n`
221
- : `No results for "${params.query}". Broaden the query.\n`;
222
- }
223
177
  visible.forEach((r, i) => {
224
178
  lastSearch.urlRank.set(normalizeUrl(r.url), {
225
179
  rank: i + 1,
226
180
  engines: r.engines,
227
181
  query: params.query,
228
182
  });
229
- const title = fmtSnippet(stripSiteSuffix(cleanSnippet(r.title), hostOf(r.url)), 100) || r.url;
230
- const snippet = fmtSnippet(cleanSnippet(r.snippet), snip);
231
- const via = formatEvidence(r);
232
- const when = normalizeHitDate(r.date);
233
- text += `${i + 1}. [${title}](${r.url})${when ? ` — ${when}` : ''}${via}\n`;
234
- if (snippet) text += ` ${snippet}\n`;
235
- text += '\n';
236
183
  });
237
- if (errored.length) {
238
- text += `⚠ engines: ${errored.map((e) => `${e.engine} (${e.error.slice(0, 80)})`).join('; ')}\n`;
239
- }
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
+ );
240
197
 
241
198
  return {
242
199
  content: [{ type: 'text', text }],
@@ -256,9 +213,7 @@ export function registerWebTools(pi: ExtensionAPI) {
256
213
  };
257
214
  },
258
215
  renderResult(result, options, theme) {
259
- const lines = resultText(result).split('\n');
260
- if (!options.expanded) return new Text(previewWithHint(lines, theme), 0, 0);
261
- return new Text(colorizedResult(resultText(result), theme), 0, 0);
216
+ return new WrappedSearchText(resultText(result), theme, options.expanded);
262
217
  },
263
218
  });
264
219
 
@@ -280,7 +235,7 @@ export function registerWebTools(pi: ExtensionAPI) {
280
235
  const url = typeof args.url === 'string' ? args.url : '…';
281
236
  const s = Array.isArray(args.sections) ? args.sections.join(', ') : '…';
282
237
  const tail = args.sections ? ` sections=[${s}]` : '';
283
- 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);
284
239
  },
285
240
 
286
241
  async execute(_id, params, signal) {
@@ -353,11 +308,13 @@ type FetchDetails = {
353
308
  title: string;
354
309
  renderer: string;
355
310
  outline: string[];
311
+ sections: { index: number; heading: string; text: string }[];
356
312
  section_count: number;
357
313
  chars?: number;
358
314
  sections_requested?: number[];
359
315
  sections_missing?: number[];
360
316
  sections_truncated?: number;
317
+ sections_clipped?: number[];
361
318
  error?: string;
362
319
  };
363
320
 
@@ -369,6 +326,7 @@ interface FetchResult {
369
326
  }
370
327
 
371
328
  const PREVIEW_LINES = 10;
329
+ const SEARCH_PREVIEW_LINES = 8;
372
330
 
373
331
  /** Display-only: the model-facing markers (`indices …`, `- *N*`) render as
374
332
  * genuine ANSI italics in the TUI; the content text itself is untouched. */
@@ -378,49 +336,95 @@ function italicizeIndices(text: string, theme: Theme): string {
378
336
  .replace(/^- \*(\d+)\* /gm, (_, n: string) => `- ${theme.italic(n)} `);
379
337
  }
380
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
+ const expanded = this.expanded || lines.length <= SEARCH_PREVIEW_LINES;
367
+ const shown = expanded ? lines : lines.slice(0, SEARCH_PREVIEW_LINES);
368
+ const colored = italicizeRanks(shown.join('\n'), this.theme)
369
+ .split('\n')
370
+ .map((l) => colorLine(l, this.theme))
371
+ .join('\n');
372
+ const out = replaceTabs(colored).split('\n');
373
+ if (!expanded) {
374
+ const hint = moreLinesHint(shown.length, lines.length, this.theme);
375
+ if (hint) out.push(hint);
376
+ }
377
+ return out;
378
+ }
379
+ }
380
+
381
+ function colorLine(l: string, theme: Theme): string {
382
+ const plain = l.replace(ANSI_SGR, '');
383
+ if (
384
+ plain.includes('…[section truncated') ||
385
+ plain.includes('…(continues at outline index') ||
386
+ plain.startsWith('…[clipped by tool]') ||
387
+ plain.startsWith('…[truncated]')
388
+ )
389
+ return theme.fg('warning', l);
390
+ if (plain.startsWith('## ') && plain.endsWith(READ_FIRST_TAG))
391
+ return theme.fg('accent', l.slice(0, l.length - READ_FIRST_TAG.length)) + theme.bold(READ_FIRST_TAG);
392
+ if (/^\s*· recommended first read$/.test(plain)) return theme.bold(l.trimEnd());
393
+ if (plain.startsWith('## ')) return theme.fg('accent', l);
394
+ if (plain.startsWith('- ')) return theme.fg('dim', l);
395
+ if (plain.startsWith('[rendered via')) return theme.fg('muted', l);
396
+ if (/^\s*\d+\. /.test(plain)) return theme.fg('dim', l);
397
+ return l;
398
+ }
399
+
381
400
  function colorizedResult(text: string, theme: Theme): string {
382
401
  return text
383
402
  .split('\n')
384
- .map((l) => {
385
- if (
386
- l.includes('…[section truncated') ||
387
- l.includes('…(continues at outline index') ||
388
- l.startsWith('…[clipped by tool]') ||
389
- l.startsWith('…[truncated]')
390
- )
391
- return theme.fg('warning', l);
392
- if (l.startsWith('## [')) return theme.fg('accent', l);
393
- if (l.startsWith('- ')) return theme.fg('dim', l);
394
- if (l.startsWith('[rendered via')) return theme.fg('muted', l);
395
- if (/^\d+\. /.test(l)) return theme.fg('toolOutput', l);
396
- return l;
397
- })
403
+ .map((l) => colorLine(l, theme))
398
404
  .join('\n');
399
405
  }
400
406
 
401
- /** Per-hit evidence line: "⇐ serper#1, exa#2"; singles are unverified. */
402
- function formatEvidence(r: FusedHit): string {
403
- if (!r.engineRanks?.length) return r.engines.length > 1 ? ` ← ${r.engines.length} engines` : '';
404
- if (r.engineRanks.length === 1) return ` ⇐ ${r.engineRanks[0].engine} only (single-engine: unverified)`;
405
- const shown = r.engineRanks
406
- .slice(0, 6)
407
- .map((e) => `${e.engine}#${e.rank}`)
408
- .join(', ');
409
- const rest = r.engineRanks.length > 6 ? `, +${r.engineRanks.length - 6} more` : '';
410
- return ` ⇐ ${shown}${rest}`;
411
- }
412
-
413
407
  function resultText(result: { content: { type: string; text?: string }[] }): string {
414
408
  return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
415
409
  }
416
410
 
417
- function previewWithHint(lines: string[], theme: Theme): string {
418
- const shown = lines.slice(0, PREVIEW_LINES);
419
- const remaining = lines.length - shown.length;
411
+ function moreLinesHint(shown: number, total: number, theme: Theme): string {
412
+ const remaining = total - shown;
413
+ if (remaining <= 0) return '';
414
+ let hint: string;
415
+ try {
416
+ hint = keyText('app.tools.expand') ? keyHint('app.tools.expand', 'to expand') : rawKeyHint('ctrl+o', 'to expand');
417
+ } catch {
418
+ hint = rawKeyHint('ctrl+o', 'to expand');
419
+ }
420
+ return theme.fg('muted', `… (${remaining} more lines,`) + ' ' + hint + theme.fg('muted', ')');
421
+ }
422
+
423
+ function previewWithHint(lines: string[], theme: Theme, maxLines = PREVIEW_LINES): string {
424
+ const shown = lines.slice(0, maxLines);
420
425
  const text = shown.map((l) => theme.fg('toolOutput', replaceTabs(l))).join('\n');
421
- if (remaining > 0)
422
- return text + theme.fg('muted', `\n… (${remaining} more lines, ${keyHint('app.tools.expand', 'to expand')})`);
423
- return text;
426
+ const hint = moreLinesHint(shown.length, lines.length, theme);
427
+ return hint ? `${text}\n${hint}` : text;
424
428
  }
425
429
 
426
430
  function replaceTabs(text: string): string {
@@ -439,7 +443,6 @@ function fetchErrorResult(details: FetchDetails): FetchResult {
439
443
 
440
444
  function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: number[]): FetchResult {
441
445
  const want = [...new Set(sections.map((i) => Math.max(0, Math.min(res.sections.length - 1, i))))];
442
- const chosen = want.map((i) => res.sections[i]).filter(Boolean);
443
446
  const body = res.outline.length
444
447
  ? `indices - 0 - ${res.outline.length - 1} section${res.outline.length === 1 ? '' : 's'}\n${res.outline.map((h, i) => `- *${i}* ${h}`).join('\n')}\n\n`
445
448
  : '';
@@ -471,17 +474,48 @@ function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: num
471
474
  if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
472
475
  const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
473
476
  const missing = sections.filter((i) => i >= res.sections.length);
477
+ // continuation cuts (res.continued) vs local budget clips (clippedSections)
474
478
  const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
479
+
480
+ // same budget as the text view; headings and the '…' marker count against it
481
+ let budget = MAX_FETCH_TEXT;
482
+ const structuredSections: { index: number; heading: string; text: string }[] = [];
483
+ const clippedSections: number[] = [];
484
+ for (const idx of want) {
485
+ if (budget <= 0) {
486
+ clippedSections.push(idx);
487
+ continue;
488
+ }
489
+ const s = res.sections[idx];
490
+ if (s === undefined) continue;
491
+ const heading = res.outline[idx] ?? '';
492
+ const fullCost = heading.length + s.length;
493
+ if (fullCost <= budget) {
494
+ structuredSections.push({ index: idx, heading, text: s });
495
+ budget -= fullCost;
496
+ continue;
497
+ }
498
+ if (heading.length >= budget) {
499
+ structuredSections.push({ index: idx, heading: heading.slice(0, Math.max(0, budget - 1)) + '…', text: '' });
500
+ } else {
501
+ const textLen = Math.max(0, budget - heading.length - 1);
502
+ structuredSections.push({ index: idx, heading, text: s.slice(0, textLen) + (textLen < s.length ? '…' : '') });
503
+ }
504
+ clippedSections.push(idx);
505
+ budget = 0;
506
+ }
475
507
  const details = {
476
508
  url: res.url,
477
509
  title: res.title,
478
510
  renderer: res.renderer,
479
511
  outline: res.outline,
480
- section_count: chosen.length,
512
+ sections: structuredSections,
513
+ section_count: structuredSections.length,
481
514
  chars: clipped.length,
482
515
  sections_requested: [...new Set(sections)],
483
516
  sections_missing: [...new Set(missing)],
484
517
  sections_truncated: truncatedCount,
518
+ sections_clipped: clippedSections,
485
519
  ...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
486
520
  ...(res.paywalled ? { paywalled: true } : {}),
487
521
  ...(res.error ? { error: res.error } : {}),
package/src/transport.ts CHANGED
@@ -203,7 +203,7 @@ function decodeBody(bytes: Uint8Array, contentType: string): string {
203
203
  return looksMojibake(text) ? repairMojibake(text) : text;
204
204
  }
205
205
 
206
- async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Array> {
206
+ export async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Array> {
207
207
  const declared = Number(res.headers.get('content-length') ?? 0);
208
208
  if (declared > maxBytes) throw new Error('page too large');
209
209
  const reader = res.body?.getReader();
@@ -218,7 +218,10 @@ async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Ar
218
218
  const { done, value } = await reader.read();
219
219
  if (done) break;
220
220
  size += value.byteLength;
221
- if (size > maxBytes) throw new Error('page too large');
221
+ if (size > maxBytes) {
222
+ await reader.cancel();
223
+ throw new Error('page too large');
224
+ }
222
225
  chunks.push(value);
223
226
  }
224
227
  const out = new Uint8Array(size);
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
+ }