pi-wsearch 0.5.1 → 0.6.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/package.json +1 -1
- package/src/fetch.ts +122 -0
- package/src/fuse.ts +27 -4
- package/src/keys.ts +9 -36
- package/src/login.ts +258 -0
- package/src/render.ts +110 -0
- package/src/tools.ts +26 -363
package/package.json
CHANGED
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { scrape } from './scrape';
|
|
2
|
+
|
|
3
|
+
const MAX_FETCH_TEXT = 16000;
|
|
4
|
+
|
|
5
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
6
|
+
|
|
7
|
+
type FetchDetails = {
|
|
8
|
+
url: string;
|
|
9
|
+
title: string;
|
|
10
|
+
renderer: string;
|
|
11
|
+
outline: string[];
|
|
12
|
+
sections: { index: number; heading: string; text: string }[];
|
|
13
|
+
section_count: number;
|
|
14
|
+
chars?: number;
|
|
15
|
+
sections_requested?: number[];
|
|
16
|
+
sections_missing?: number[];
|
|
17
|
+
sections_truncated?: number;
|
|
18
|
+
sections_clipped?: number[];
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface FetchResult {
|
|
23
|
+
content: { type: 'text'; text: string }[];
|
|
24
|
+
details: FetchDetails;
|
|
25
|
+
structuredContent: JsonValue;
|
|
26
|
+
isError?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fetchErrorResult(details: FetchDetails): FetchResult {
|
|
30
|
+
const error = String(details.error ?? 'unknown error');
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: 'text', text: `WebFetch failed: ${error}` }],
|
|
33
|
+
details,
|
|
34
|
+
structuredContent: { ...details },
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: number[]): FetchResult {
|
|
40
|
+
const want = [...new Set(sections.map((i) => Math.max(0, Math.min(res.sections.length - 1, i))))];
|
|
41
|
+
const body = res.outline.length
|
|
42
|
+
? `indices - 0 - ${res.outline.length - 1} section${res.outline.length === 1 ? '' : 's'}\n${res.outline.map((h, i) => `- *${i}* ${h}`).join('\n')}\n\n`
|
|
43
|
+
: '';
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
want.forEach((idx) => {
|
|
46
|
+
const s = res.sections[idx];
|
|
47
|
+
if (s === undefined) return;
|
|
48
|
+
const head = res.outline[idx] ? `## [${idx}] ${res.outline[idx]}` : `## [${idx}]`;
|
|
49
|
+
parts.push(`${head}\n\n${s}`);
|
|
50
|
+
});
|
|
51
|
+
let text =
|
|
52
|
+
(res.title ? `# ${res.title}\n\n` : '') + body + parts.join('\n\n') + (res.truncated ? '\n…[truncated]' : '');
|
|
53
|
+
text = text.replace(/\n{3,}/g, '\n\n');
|
|
54
|
+
if (res.title) {
|
|
55
|
+
const t = res.title.trim();
|
|
56
|
+
const lines = text.split('\n');
|
|
57
|
+
const headIdx = lines.findIndex((l) => l.startsWith('# '));
|
|
58
|
+
if (headIdx >= 0) {
|
|
59
|
+
for (let i = headIdx + 1; i < Math.min(lines.length, headIdx + 6); i++) {
|
|
60
|
+
const l = lines[i].replace(/^#+\s*/, '').trim();
|
|
61
|
+
if (l === t) {
|
|
62
|
+
lines.splice(i, 1);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
text = lines.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
68
|
+
}
|
|
69
|
+
if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
|
|
70
|
+
const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
|
|
71
|
+
const missing = sections.filter((i) => i >= res.sections.length);
|
|
72
|
+
const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
|
|
73
|
+
|
|
74
|
+
let budget = MAX_FETCH_TEXT;
|
|
75
|
+
const structuredSections: { index: number; heading: string; text: string }[] = [];
|
|
76
|
+
const clippedSections: number[] = [];
|
|
77
|
+
for (const idx of want) {
|
|
78
|
+
if (budget <= 0) {
|
|
79
|
+
clippedSections.push(idx);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const s = res.sections[idx];
|
|
83
|
+
if (s === undefined) continue;
|
|
84
|
+
const heading = res.outline[idx] ?? '';
|
|
85
|
+
const fullCost = heading.length + s.length;
|
|
86
|
+
if (fullCost <= budget) {
|
|
87
|
+
structuredSections.push({ index: idx, heading, text: s });
|
|
88
|
+
budget -= fullCost;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (heading.length >= budget) {
|
|
92
|
+
structuredSections.push({ index: idx, heading: heading.slice(0, Math.max(0, budget - 1)) + '…', text: '' });
|
|
93
|
+
} else {
|
|
94
|
+
const textLen = Math.max(0, budget - heading.length - 1);
|
|
95
|
+
structuredSections.push({ index: idx, heading, text: s.slice(0, textLen) + (textLen < s.length ? '…' : '') });
|
|
96
|
+
}
|
|
97
|
+
clippedSections.push(idx);
|
|
98
|
+
budget = 0;
|
|
99
|
+
}
|
|
100
|
+
const details = {
|
|
101
|
+
url: res.url,
|
|
102
|
+
title: res.title,
|
|
103
|
+
renderer: res.renderer,
|
|
104
|
+
outline: res.outline,
|
|
105
|
+
sections: structuredSections,
|
|
106
|
+
section_count: structuredSections.length,
|
|
107
|
+
chars: clipped.length,
|
|
108
|
+
sections_requested: [...new Set(sections)],
|
|
109
|
+
sections_missing: [...new Set(missing)],
|
|
110
|
+
sections_truncated: truncatedCount,
|
|
111
|
+
sections_clipped: clippedSections,
|
|
112
|
+
...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
|
|
113
|
+
...(res.paywalled ? { paywalled: true } : {}),
|
|
114
|
+
...(res.error ? { error: res.error } : {}),
|
|
115
|
+
};
|
|
116
|
+
if (res.error) return fetchErrorResult(details);
|
|
117
|
+
return { content: [{ type: 'text', text: clipped }], details, structuredContent: { ...details } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function makeFetchResult(res: Awaited<ReturnType<typeof scrape>>, params: { sections: number[] }): FetchResult {
|
|
121
|
+
return buildFetchResult(res, params.sections);
|
|
122
|
+
}
|
package/src/fuse.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface FusedHit extends SearchHit {
|
|
|
5
5
|
engines: string[];
|
|
6
6
|
/** Per-engine rank of this hit: {serper: 1} = serper ranked it #1. */
|
|
7
7
|
engineRanks: { engine: string; rank: number }[];
|
|
8
|
+
twinHosts?: number;
|
|
8
9
|
rrfScore: number;
|
|
9
10
|
finalScore: number;
|
|
10
11
|
junk: boolean;
|
|
@@ -18,6 +19,8 @@ const RRF_K = 60;
|
|
|
18
19
|
// Tiny on purpose: RRF already rewards multi-engine agreement, and the bonus
|
|
19
20
|
// must never scale with how many engines happen to be configured.
|
|
20
21
|
const CONSENSUS_TIEBREAK = 0.003;
|
|
22
|
+
const TWIN_HOST_PENALTY = 0.15;
|
|
23
|
+
const TWIN_HOST_CAP = 2;
|
|
21
24
|
|
|
22
25
|
// Dense/semantic ranking dilutes rare technical tokens (CVE-2026-…, pgvector,
|
|
23
26
|
// error codes): a verbatim title/snippet hit gets a bounded nudge, never total.
|
|
@@ -189,7 +192,7 @@ function normTitle(s: string): string {
|
|
|
189
192
|
.trim();
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
function isNearDuplicate(a:
|
|
195
|
+
function isNearDuplicate(a: { title: string }, b: { title: string }): boolean {
|
|
193
196
|
const A = normTitle(a.title);
|
|
194
197
|
const B = normTitle(b.title);
|
|
195
198
|
// dedupe both sides so a repeated word can't inflate the ratio past 1.0
|
|
@@ -203,11 +206,16 @@ function isNearDuplicate(a: FusedHit, b: FusedHit): boolean {
|
|
|
203
206
|
return (A.startsWith(B) || B.startsWith(A)) && Math.abs(ta.length - tb.length) <= 1;
|
|
204
207
|
}
|
|
205
208
|
|
|
206
|
-
function collapseNearDuplicates(fused: FusedHit[]): FusedHit[] {
|
|
209
|
+
function collapseNearDuplicates(fused: FusedHit[]): { kept: FusedHit[]; twins: number[] } {
|
|
207
210
|
const kept: FusedHit[] = [];
|
|
211
|
+
const twins: number[] = [];
|
|
208
212
|
for (const h of fused) {
|
|
209
213
|
const twin = kept.find((k) => isNearDuplicate(k, h));
|
|
210
214
|
if (twin) {
|
|
215
|
+
if (hostOf(h.url) !== hostOf(twin.url)) {
|
|
216
|
+
const idx = kept.indexOf(twin);
|
|
217
|
+
twins[idx] = (twins[idx] ?? 0) + 1;
|
|
218
|
+
}
|
|
211
219
|
twin.engines = [...new Set([...twin.engines, ...h.engines])];
|
|
212
220
|
const best = new Map(twin.engineRanks.map((r) => [r.engine, r]));
|
|
213
221
|
for (const r of h.engineRanks) {
|
|
@@ -221,7 +229,7 @@ function collapseNearDuplicates(fused: FusedHit[]): FusedHit[] {
|
|
|
221
229
|
}
|
|
222
230
|
kept.push(h);
|
|
223
231
|
}
|
|
224
|
-
return kept;
|
|
232
|
+
return { kept, twins };
|
|
225
233
|
}
|
|
226
234
|
|
|
227
235
|
export function queryTermCoverage(hits: { title: string; snippet: string }[], query: string): number {
|
|
@@ -235,6 +243,15 @@ export function queryTermCoverage(hits: { title: string; snippet: string }[], qu
|
|
|
235
243
|
return matched / terms.length;
|
|
236
244
|
}
|
|
237
245
|
|
|
246
|
+
const COVERAGE_LOW_THRESHOLD = 0.5;
|
|
247
|
+
|
|
248
|
+
export function coverageLow(hits: { title: string; snippet: string }[], query: string): boolean[] {
|
|
249
|
+
if (!query.trim()) return [];
|
|
250
|
+
const setCoverage = queryTermCoverage(hits, query);
|
|
251
|
+
if (setCoverage < COVERAGE_LOW_THRESHOLD) return hits.map(() => false);
|
|
252
|
+
return hits.map((h) => queryTermCoverage([h], query) < COVERAGE_LOW_THRESHOLD);
|
|
253
|
+
}
|
|
254
|
+
|
|
238
255
|
export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHit[] {
|
|
239
256
|
const { query } = opts;
|
|
240
257
|
const terms = query ? queryTerms(query) : [];
|
|
@@ -276,7 +293,13 @@ export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHi
|
|
|
276
293
|
});
|
|
277
294
|
}
|
|
278
295
|
const sorted = fused.toSorted((a, b) => b.finalScore - a.finalScore);
|
|
279
|
-
|
|
296
|
+
const { kept, twins } = collapseNearDuplicates(sorted);
|
|
297
|
+
const penalized = kept.map((h, i) => ({
|
|
298
|
+
...h,
|
|
299
|
+
twinHosts: twins[i] ?? 0,
|
|
300
|
+
finalScore: Math.round(h.finalScore * (1 - TWIN_HOST_PENALTY * Math.min(twins[i] ?? 0, TWIN_HOST_CAP)) * 1e5) / 1e5,
|
|
301
|
+
}));
|
|
302
|
+
return penalized.toSorted((a, b) => b.finalScore - a.finalScore);
|
|
280
303
|
}
|
|
281
304
|
|
|
282
305
|
export function diversifyByHost(hits: FusedHit[], maxPerHost = 2): FusedHit[] {
|
package/src/keys.ts
CHANGED
|
@@ -16,8 +16,6 @@ import {
|
|
|
16
16
|
type ProviderInfo,
|
|
17
17
|
} from './config';
|
|
18
18
|
|
|
19
|
-
const PROVIDER_IDS = PROVIDER_LIST.map((p) => p.id);
|
|
20
|
-
|
|
21
19
|
function providerNames(p: ProviderInfo): string[] {
|
|
22
20
|
return [p.envName, ...p.aliases];
|
|
23
21
|
}
|
|
@@ -70,38 +68,13 @@ export function removeKey(p: ProviderInfo): void {
|
|
|
70
68
|
mutateFile(allNamesRemoved(p));
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
lines.push(
|
|
83
|
-
armed.length ? ` armed: ${armed.map((id) => `${id} ✓(${origins[id] ?? '?'})`).join(', ')}` : ' armed: (none)',
|
|
84
|
-
);
|
|
85
|
-
lines.push(` dormant: ${dormant.length ? dormant.join(', ') : '(none)'}`);
|
|
86
|
-
const chain = cfg.fetchChain
|
|
87
|
-
? cfg.fetchChain.replace(/,/g, ', ')
|
|
88
|
-
: 'jina, tinyfish, firecrawl, tavily, exa, serper, you, kagi';
|
|
89
|
-
lines.push(` fetch: local → ${chain}`);
|
|
90
|
-
const knobs = [];
|
|
91
|
-
if (cfg.searchEngines) knobs.push(`WSEARCH_ENGINES=${cfg.searchEngines}`);
|
|
92
|
-
if (cfg.fetchChain) knobs.push(`WSEARCH_FETCH_CHAIN=${cfg.fetchChain}`);
|
|
93
|
-
if (knobs.length) lines.push(` knobs: ${knobs.join(' · ')}`);
|
|
94
|
-
if (armed.length === 0) {
|
|
95
|
-
lines.push('');
|
|
96
|
-
lines.push('Getting started (30 seconds):');
|
|
97
|
-
lines.push(' 1. get one free key; cheapest paths:');
|
|
98
|
-
lines.push(' · tavily → tavily.com (1,000 free searches/mo)');
|
|
99
|
-
lines.push(' · exa → exa.ai (1,000 free/mo)');
|
|
100
|
-
lines.push(' · firecrawl → firecrawl.dev (1,000 free credits/mo)');
|
|
101
|
-
lines.push(' · tinyfish → tinyfish.ai (free at any balance)');
|
|
102
|
-
lines.push(' · jina → jina.ai (free tier, search + reader)');
|
|
103
|
-
lines.push(' 2. /websearch login, saved to wsearch/env');
|
|
104
|
-
lines.push(' 3. run /websearch again; it should appear under "armed"');
|
|
105
|
-
}
|
|
106
|
-
return lines.join('\n');
|
|
71
|
+
function originLabel(id: string, origin?: string): string {
|
|
72
|
+
if (origin === 'wsearch') return 'wsearch env';
|
|
73
|
+
if (origin === 'env') return `env: ${envNameFor(id)}`;
|
|
74
|
+
return 'shell';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function envNameFor(id: string): string {
|
|
78
|
+
const spec = PROVIDER_LIST.find((p) => p.id === id);
|
|
79
|
+
return spec?.envName ?? id;
|
|
107
80
|
}
|
package/src/login.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import type { ExtensionCommandContext, Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { Input, Key, fuzzyFilter, matchesKey, type Component } from '@earendil-works/pi-tui';
|
|
4
|
+
import {
|
|
5
|
+
keyFilePath,
|
|
6
|
+
keyOrigins,
|
|
7
|
+
keyStatus,
|
|
8
|
+
resetConfigCache,
|
|
9
|
+
PROVIDERS,
|
|
10
|
+
disabledFlagPath,
|
|
11
|
+
type ProviderInfo,
|
|
12
|
+
} from './config';
|
|
13
|
+
import { clearDeadEngines } from './engines';
|
|
14
|
+
import { writeKey, removeKey } from './keys';
|
|
15
|
+
|
|
16
|
+
interface ProviderPick {
|
|
17
|
+
id: string;
|
|
18
|
+
line: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function providerById(id: string | undefined): ProviderInfo | undefined {
|
|
22
|
+
if (!id) return undefined;
|
|
23
|
+
const n = id.trim().toLowerCase();
|
|
24
|
+
return PROVIDERS.find((p) => p.id === n || p.label.toLowerCase().includes(n));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function websearchStatusLine(): string {
|
|
28
|
+
if (existsSync(disabledFlagPath())) return 'websearch is off · /websearch on to re-enable';
|
|
29
|
+
const st = keyStatus();
|
|
30
|
+
const origins = keyOrigins();
|
|
31
|
+
const armed = PROVIDERS.filter((p) => st[p.id]).map((p) => `${p.id} ✓${origins[p.id] ?? '?'}`);
|
|
32
|
+
if (armed.length === 0) return 'websearch is on · no providers · /websearch login';
|
|
33
|
+
return `websearch is on · ${armed.join(', ')}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function providerPick(p: ProviderInfo, armed: boolean, origin?: string): ProviderPick {
|
|
37
|
+
const status = armed ? `✓ ${origin === 'wsearch' ? 'wsearch env' : `env: ${p.envName}`}` : 'not configured';
|
|
38
|
+
return { id: p.id, line: `${p.label} · ${status}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortProviders(list: ProviderInfo[]): ProviderInfo[] {
|
|
42
|
+
return [...list].toSorted((a, b) => a.label.localeCompare(b.label));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class ProviderPicker implements Component {
|
|
46
|
+
private filtered: ProviderPick[];
|
|
47
|
+
private selectedIndex = 0;
|
|
48
|
+
private searchInput = new Input({ placeholder: 'Type to filter' });
|
|
49
|
+
private cached: string[] | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private tui: { requestRender(): void },
|
|
53
|
+
private theme: Theme,
|
|
54
|
+
private all: ProviderPick[],
|
|
55
|
+
private title: string,
|
|
56
|
+
private onPick: (id: string) => void,
|
|
57
|
+
private onClose: () => void,
|
|
58
|
+
) {
|
|
59
|
+
this.filtered = all;
|
|
60
|
+
this.searchInput.onSubmit = () => {
|
|
61
|
+
const picked = this.filtered[this.selectedIndex];
|
|
62
|
+
if (picked) this.onPick(picked.id);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
render(width: number): string[] {
|
|
67
|
+
if (this.cached) return this.cached;
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
const bar = '─'.repeat(Math.max(10, width));
|
|
70
|
+
lines.push(this.theme.fg('accent', bar));
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push(this.theme.fg('accent', this.theme.bold(this.title)));
|
|
73
|
+
lines.push('');
|
|
74
|
+
for (const l of this.searchInput.render(width)) lines.push(l);
|
|
75
|
+
lines.push('');
|
|
76
|
+
const max = 7;
|
|
77
|
+
const n = this.filtered.length;
|
|
78
|
+
const start = n <= max ? 0 : Math.max(0, Math.min(this.selectedIndex - 3, n - max));
|
|
79
|
+
const end = Math.min(start + max, n);
|
|
80
|
+
for (let i = start; i < end; i++) {
|
|
81
|
+
const item = this.filtered[i];
|
|
82
|
+
if (!item) continue;
|
|
83
|
+
const selected = i === this.selectedIndex;
|
|
84
|
+
const sep = item.line.indexOf(' · ');
|
|
85
|
+
const name = sep >= 0 ? item.line.slice(0, sep).trim() : item.line.trim();
|
|
86
|
+
const status = sep >= 0 ? item.line.slice(sep + 3).trim() : '';
|
|
87
|
+
const configured = !status.startsWith('not configured');
|
|
88
|
+
const statusPart = configured
|
|
89
|
+
? this.theme.fg('success', '✓ ') + this.theme.fg('muted', status.replace(/^✓\s*/, ''))
|
|
90
|
+
: this.theme.fg('dim', 'not configured');
|
|
91
|
+
if (selected) {
|
|
92
|
+
lines.push(this.theme.fg('accent', '→ ') + this.theme.fg('accent', this.theme.bold(name)) + ' ' + statusPart);
|
|
93
|
+
} else {
|
|
94
|
+
lines.push(' ' + this.theme.fg('text', name) + ' ' + statusPart);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (n === 0) lines.push(this.theme.fg('muted', ' No matching providers'));
|
|
98
|
+
if (start > 0 || end < n) lines.push(this.theme.fg('muted', ` (${this.selectedIndex + 1}/${n})`));
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push(this.theme.fg('dim', ' ↑↓ navigate · Type to filter · Enter to select · Esc to cancel'));
|
|
101
|
+
lines.push(this.theme.fg('accent', bar));
|
|
102
|
+
this.cached = lines;
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
invalidate(): void {
|
|
107
|
+
this.cached = undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
handleInput(data: string): void {
|
|
111
|
+
this.cached = undefined;
|
|
112
|
+
if (matchesKey(data, Key.up)) {
|
|
113
|
+
if (this.filtered.length) {
|
|
114
|
+
this.selectedIndex = (this.selectedIndex - 1 + this.filtered.length) % this.filtered.length;
|
|
115
|
+
this.tui.requestRender();
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (matchesKey(data, Key.down)) {
|
|
120
|
+
if (this.filtered.length) {
|
|
121
|
+
this.selectedIndex = (this.selectedIndex + 1) % this.filtered.length;
|
|
122
|
+
this.tui.requestRender();
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (matchesKey(data, Key.enter)) {
|
|
127
|
+
const picked = this.filtered[this.selectedIndex];
|
|
128
|
+
if (picked) this.onPick(picked.id);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (matchesKey(data, Key.escape)) {
|
|
132
|
+
this.onClose();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.searchInput.handleInput(data);
|
|
136
|
+
const q = this.searchInput.getValue();
|
|
137
|
+
this.filtered = q ? fuzzyFilter(this.all, q, (r) => r.line) : this.all;
|
|
138
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, this.filtered.length - 1));
|
|
139
|
+
this.tui.requestRender();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function pickProvider(
|
|
144
|
+
ctx: ExtensionCommandContext,
|
|
145
|
+
title: string,
|
|
146
|
+
picks: ProviderPick[],
|
|
147
|
+
): Promise<string | undefined> {
|
|
148
|
+
return ctx.ui.custom<string | undefined>(
|
|
149
|
+
(tui, theme, _kb, done) =>
|
|
150
|
+
new ProviderPicker(
|
|
151
|
+
tui as any,
|
|
152
|
+
theme as any,
|
|
153
|
+
picks,
|
|
154
|
+
title,
|
|
155
|
+
(id) => done(id),
|
|
156
|
+
() => done(undefined),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function handleLogin(ctx: ExtensionCommandContext, name?: string): Promise<void> {
|
|
162
|
+
if (!ctx.hasUI || ctx.mode === 'print') {
|
|
163
|
+
ctx.ui.notify('/websearch login is interactive-only', 'warning');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let provider = providerById(name);
|
|
167
|
+
if (name && !provider) {
|
|
168
|
+
const fuzzy = PROVIDERS.filter((p) => p.id.includes(name.toLowerCase()) || name.toLowerCase().includes(p.id));
|
|
169
|
+
ctx.ui.notify(
|
|
170
|
+
fuzzy.length
|
|
171
|
+
? `unknown provider "${name}": did you mean ${fuzzy.map((p) => p.id).join(', ')}?`
|
|
172
|
+
: `unknown provider "${name}". Run /websearch to see the ${PROVIDERS.length} supported pockets.`,
|
|
173
|
+
'warning',
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!provider) {
|
|
178
|
+
const st = keyStatus();
|
|
179
|
+
const rowOrigins = keyOrigins();
|
|
180
|
+
const picks = sortProviders(PROVIDERS).map((p) => providerPick(p, !!st[p.id], rowOrigins[p.id]));
|
|
181
|
+
const pickedId = await pickProvider(ctx, 'Select provider to configure:', picks);
|
|
182
|
+
provider = pickedId ? providerById(pickedId) : undefined;
|
|
183
|
+
if (!provider) {
|
|
184
|
+
ctx.ui.notify('cancelled', 'info');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const value = await ctx.ui.input(`Paste your ${provider.id} key`, 'key, or !command (Keychain/1Password)');
|
|
189
|
+
if (!value) {
|
|
190
|
+
ctx.ui.notify('cancelled', 'info');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const trimmed = value.trim();
|
|
194
|
+
if (trimmed.length < 8) {
|
|
195
|
+
ctx.ui.notify('value too short to be a key', 'warning');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const isCmd = trimmed.startsWith('!');
|
|
199
|
+
const ok = await ctx.ui.confirm(
|
|
200
|
+
`Write ${provider.id} to ${keyFilePath()}?`,
|
|
201
|
+
isCmd
|
|
202
|
+
? 'Stored as a !command: the secret itself never touches disk.'
|
|
203
|
+
: 'Stored in the dedicated wsearch env file (0600).',
|
|
204
|
+
);
|
|
205
|
+
if (!ok) {
|
|
206
|
+
ctx.ui.notify('cancelled', 'info');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
writeKey(provider, trimmed);
|
|
210
|
+
resetConfigCache();
|
|
211
|
+
clearDeadEngines();
|
|
212
|
+
ctx.ui.notify(`${provider.id} saved ✓`, 'info');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function handleLogout(ctx: ExtensionCommandContext, name?: string): Promise<void> {
|
|
216
|
+
const interactive = (): boolean => !!(ctx.hasUI && ctx.mode !== 'print');
|
|
217
|
+
const origins = keyOrigins();
|
|
218
|
+
const armed = PROVIDERS.filter((p) => origins[p.id]);
|
|
219
|
+
let provider = providerById(name);
|
|
220
|
+
if (name && !provider) {
|
|
221
|
+
ctx.ui.notify(`unknown provider "${name}". Run /websearch to see the supported pockets.`, 'warning');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!provider) {
|
|
225
|
+
if (armed.length === 0) {
|
|
226
|
+
ctx.ui.notify('no providers are configured: nothing to remove.', 'info');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!interactive()) {
|
|
230
|
+
ctx.ui.notify('/websearch logout is interactive-only', 'warning');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const logoutPicks = sortProviders(armed).map((p) => providerPick(p, true, origins[p.id]));
|
|
234
|
+
const pickedId = await pickProvider(ctx, 'Select provider to logout:', logoutPicks);
|
|
235
|
+
provider = pickedId ? providerById(pickedId) : undefined;
|
|
236
|
+
}
|
|
237
|
+
if (!provider) {
|
|
238
|
+
ctx.ui.notify('cancelled', 'info');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (origins[provider.id] !== 'wsearch') {
|
|
242
|
+
ctx.ui.notify(
|
|
243
|
+
`${provider.id} is configured via ${origins[provider.id]}, not the wsearch env file: nothing to remove here.`,
|
|
244
|
+
'info',
|
|
245
|
+
);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!interactive()) {
|
|
249
|
+
ctx.ui.notify('/websearch logout is interactive-only', 'warning');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const ok = await ctx.ui.confirm(`Remove ${provider.id} from ${keyFilePath()}?`, '');
|
|
253
|
+
if (!ok) return;
|
|
254
|
+
removeKey(provider);
|
|
255
|
+
resetConfigCache();
|
|
256
|
+
clearDeadEngines();
|
|
257
|
+
ctx.ui.notify(`${provider.id} removed`, 'info');
|
|
258
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { keyHint, keyText, rawKeyHint } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { type Component } from '@earendil-works/pi-tui';
|
|
4
|
+
import { READ_FIRST_TAG, wrappedLineKind, wrapAll } from './view';
|
|
5
|
+
|
|
6
|
+
const PREVIEW_LINES = 7;
|
|
7
|
+
|
|
8
|
+
export function italicizeIndices(text: string, theme: Theme): string {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/^(indices - 0 - \d+ sections?)$/gm, (m) => theme.italic(m))
|
|
11
|
+
.replace(/^- \*(\d+)\* /gm, (_, n: string) => `- ${theme.italic(n)} `);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function italicizeRanks(text: string, theme: Theme): string {
|
|
15
|
+
return text.replace(/^(\s*)(\d+)\.(?=\s)/gm, (m, pad: string, n: string) => `${pad}${theme.italic(n)}.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_WRAP_COLS = 90;
|
|
21
|
+
const MAX_WRAP_COLS = 240;
|
|
22
|
+
|
|
23
|
+
export function terminalWrapWidth(): number {
|
|
24
|
+
const cols = process.stdout.columns ?? DEFAULT_WRAP_COLS;
|
|
25
|
+
return Math.min(MAX_WRAP_COLS, Math.max(60, cols - 4));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class WrappedSearchText implements Component {
|
|
29
|
+
private raw: string;
|
|
30
|
+
private theme: Theme;
|
|
31
|
+
private expanded: boolean;
|
|
32
|
+
constructor(raw: string, theme: Theme, expanded: boolean) {
|
|
33
|
+
this.raw = raw;
|
|
34
|
+
this.theme = theme;
|
|
35
|
+
this.expanded = expanded;
|
|
36
|
+
}
|
|
37
|
+
invalidate(): void {}
|
|
38
|
+
render(width: number): string[] {
|
|
39
|
+
const wrapped = wrapAll(this.raw, Math.max(40, width));
|
|
40
|
+
const lines = wrapped.split('\n');
|
|
41
|
+
const expanded = this.expanded || lines.length <= PREVIEW_LINES;
|
|
42
|
+
const shown = expanded ? lines : lines.slice(0, PREVIEW_LINES);
|
|
43
|
+
const colored = italicizeRanks(shown.join('\n'), this.theme)
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((l) => (this.expanded ? colorLine(l, this.theme) : this.theme.fg('dim', l)))
|
|
46
|
+
.join('\n');
|
|
47
|
+
const out = replaceTabs(colored).split('\n');
|
|
48
|
+
if (!expanded) {
|
|
49
|
+
const hint = moreLinesHint(shown.length, lines.length, this.theme);
|
|
50
|
+
if (hint) out.push(hint);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function colorLine(l: string, theme: Theme): string {
|
|
57
|
+
const plain = l.replace(ANSI_SGR, '');
|
|
58
|
+
if (
|
|
59
|
+
plain.includes('…[section truncated') ||
|
|
60
|
+
plain.includes('…(continues at outline index') ||
|
|
61
|
+
plain.startsWith('…[clipped by tool]') ||
|
|
62
|
+
plain.startsWith('…[truncated]')
|
|
63
|
+
)
|
|
64
|
+
return theme.fg('warning', l);
|
|
65
|
+
if (plain.startsWith('## ') && plain.endsWith(READ_FIRST_TAG))
|
|
66
|
+
return theme.fg('accent', l.slice(0, l.length - READ_FIRST_TAG.length)) + theme.bold(READ_FIRST_TAG);
|
|
67
|
+
if (/^\s*· recommended first read$/.test(plain)) return theme.bold(l.trimEnd());
|
|
68
|
+
const wrapped = wrappedLineKind(plain);
|
|
69
|
+
if (wrapped === 'index') return theme.fg('dim', l);
|
|
70
|
+
if (wrapped === 'chapter') return theme.fg('accent', l);
|
|
71
|
+
if (plain.startsWith('## ')) return theme.fg('accent', l);
|
|
72
|
+
if (plain.startsWith('- ')) return theme.fg('dim', l);
|
|
73
|
+
if (plain.startsWith('[rendered via')) return theme.fg('muted', l);
|
|
74
|
+
if (/^\s*\d+\. /.test(plain)) return theme.fg('dim', l);
|
|
75
|
+
return l;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function colorizedResult(text: string, theme: Theme): string {
|
|
79
|
+
return text
|
|
80
|
+
.split('\n')
|
|
81
|
+
.map((l) => colorLine(l, theme))
|
|
82
|
+
.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resultText(result: { content: { type: string; text?: string }[] }): string {
|
|
86
|
+
return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function moreLinesHint(shown: number, total: number, theme: Theme): string {
|
|
90
|
+
const remaining = total - shown;
|
|
91
|
+
if (remaining <= 0) return '';
|
|
92
|
+
let hint: string;
|
|
93
|
+
try {
|
|
94
|
+
hint = keyText('app.tools.expand') ? keyHint('app.tools.expand', 'to expand') : rawKeyHint('ctrl+o', 'to expand');
|
|
95
|
+
} catch {
|
|
96
|
+
hint = rawKeyHint('ctrl+o', 'to expand');
|
|
97
|
+
}
|
|
98
|
+
return theme.fg('muted', `... (${remaining} more lines,`) + ' ' + hint + theme.fg('muted', ')');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function previewWithHint(lines: string[], theme: Theme, maxLines = PREVIEW_LINES): string {
|
|
102
|
+
const shown = lines.slice(0, maxLines);
|
|
103
|
+
const text = shown.map((l) => theme.fg('dim', replaceTabs(l))).join('\n');
|
|
104
|
+
const hint = moreLinesHint(shown.length, lines.length, theme);
|
|
105
|
+
return hint ? `${text}\n${hint}` : text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function replaceTabs(text: string): string {
|
|
109
|
+
return text.replace(/\t/g, ' ');
|
|
110
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* evicted on 2026-10-05; do not add a research surface or nested-LLM path.
|
|
4
4
|
*/
|
|
5
5
|
import { Type } from 'typebox';
|
|
6
|
-
import type { ExtensionAPI
|
|
7
|
-
import {
|
|
8
|
-
import { Text, type Component } from '@earendil-works/pi-tui';
|
|
6
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
9
8
|
import { appendFileSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
10
9
|
import { dirname, join } from 'node:path';
|
|
11
10
|
import {
|
|
@@ -21,10 +20,20 @@ import {
|
|
|
21
20
|
import { ABORT_ERROR, DEFAULT_MAX_RESULTS } from './constants';
|
|
22
21
|
import { cacheKey, openCache, CACHE_TTL_HOURS } from './cache';
|
|
23
22
|
import { clearDeadEngines, runEngines, type EngineOutcome } from './engines';
|
|
24
|
-
import { fuse, diversifyByHost, queryTermCoverage } from './fuse';
|
|
23
|
+
import { fuse, diversifyByHost, queryTermCoverage, coverageLow } from './fuse';
|
|
25
24
|
import { normalizeUrl } from './urls';
|
|
26
|
-
import { buildSearchText
|
|
25
|
+
import { buildSearchText } from './view';
|
|
27
26
|
import { scrape } from './scrape';
|
|
27
|
+
import {
|
|
28
|
+
colorizedResult,
|
|
29
|
+
italicizeIndices,
|
|
30
|
+
previewWithHint,
|
|
31
|
+
resultText,
|
|
32
|
+
terminalWrapWidth,
|
|
33
|
+
WrappedSearchText,
|
|
34
|
+
} from './render';
|
|
35
|
+
import { makeFetchResult } from './fetch';
|
|
36
|
+
import { handleLogin, handleLogout, websearchStatusLine } from './login';
|
|
28
37
|
|
|
29
38
|
const SEARCH_PARAMS = Type.Object({
|
|
30
39
|
query: Type.String({
|
|
@@ -54,6 +63,12 @@ const SEARCH_OUTPUT = Type.Object({
|
|
|
54
63
|
title: Type.String(),
|
|
55
64
|
engines: Type.Array(Type.String()),
|
|
56
65
|
score: Type.Number(),
|
|
66
|
+
coverage_low: Type.Optional(
|
|
67
|
+
Type.Boolean({
|
|
68
|
+
description:
|
|
69
|
+
'The hit matched only a few query terms while the result set matched most. Stuffing signature; distrust.',
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
57
72
|
}),
|
|
58
73
|
),
|
|
59
74
|
});
|
|
@@ -184,6 +199,7 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
184
199
|
const visible = candidates.slice(0, params.max_results ?? DEFAULT_MAX_RESULTS);
|
|
185
200
|
const enginesUsed = [...new Set(fused.flatMap((r) => r.engines))];
|
|
186
201
|
const offtopic = visible.length > 0 && queryTermCoverage(visible, params.query) < 0.5;
|
|
202
|
+
const lowCoverage = coverageLow(visible, params.query);
|
|
187
203
|
visible.forEach((r, i) => {
|
|
188
204
|
lastSearch.urlRank.set(normalizeUrl(r.url), {
|
|
189
205
|
rank: i + 1,
|
|
@@ -213,12 +229,13 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
213
229
|
engines_used: enginesUsed,
|
|
214
230
|
engines_errored: errored,
|
|
215
231
|
...(offtopic ? { results_look_offtopic: true } : {}),
|
|
216
|
-
results: visible.map((r) => ({
|
|
232
|
+
results: visible.map((r, i) => ({
|
|
217
233
|
url: r.url,
|
|
218
234
|
title: r.title,
|
|
219
235
|
engines: r.engines,
|
|
220
236
|
ranks: r.engineRanks,
|
|
221
237
|
score: r.finalScore,
|
|
238
|
+
...(lowCoverage[i] ? { coverage_low: true } : {}),
|
|
222
239
|
})),
|
|
223
240
|
},
|
|
224
241
|
};
|
|
@@ -307,360 +324,6 @@ export function registerWebTools(pi: ExtensionAPI) {
|
|
|
307
324
|
});
|
|
308
325
|
}
|
|
309
326
|
|
|
310
|
-
/** Response caps. The composed fetch view is bounded to what a turn needs;
|
|
311
|
-
* structuredContent mirrors the same content trimmed for schema. */
|
|
312
|
-
const MAX_FETCH_TEXT = 16000;
|
|
313
|
-
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
314
|
-
|
|
315
|
-
type FetchDetails = {
|
|
316
|
-
url: string;
|
|
317
|
-
title: string;
|
|
318
|
-
renderer: string;
|
|
319
|
-
outline: string[];
|
|
320
|
-
sections: { index: number; heading: string; text: string }[];
|
|
321
|
-
section_count: number;
|
|
322
|
-
chars?: number;
|
|
323
|
-
sections_requested?: number[];
|
|
324
|
-
sections_missing?: number[];
|
|
325
|
-
sections_truncated?: number;
|
|
326
|
-
sections_clipped?: number[];
|
|
327
|
-
error?: string;
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
interface FetchResult {
|
|
331
|
-
content: { type: 'text'; text: string }[];
|
|
332
|
-
details: FetchDetails;
|
|
333
|
-
structuredContent: JsonValue;
|
|
334
|
-
isError?: boolean;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const PREVIEW_LINES = 10;
|
|
338
|
-
const SEARCH_PREVIEW_LINES = 8;
|
|
339
|
-
|
|
340
|
-
/** Display-only: the model-facing markers (`indices …`, `- *N*`) render as
|
|
341
|
-
* genuine ANSI italics in the TUI; the content text itself is untouched. */
|
|
342
|
-
function italicizeIndices(text: string, theme: Theme): string {
|
|
343
|
-
return text
|
|
344
|
-
.replace(/^(indices - 0 - \d+ sections?)$/gm, (m) => theme.italic(m))
|
|
345
|
-
.replace(/^- \*(\d+)\* /gm, (_, n: string) => `- ${theme.italic(n)} `);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function italicizeRanks(text: string, theme: Theme): string {
|
|
349
|
-
return text.replace(/^(\s*)(\d+)\.(?=\s)/gm, (m, pad: string, n: string) => `${pad}${theme.italic(n)}.`);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
353
|
-
|
|
354
|
-
const DEFAULT_WRAP_COLS = 90;
|
|
355
|
-
const MAX_WRAP_COLS = 240;
|
|
356
|
-
|
|
357
|
-
function terminalWrapWidth(): number {
|
|
358
|
-
const cols = process.stdout.columns ?? DEFAULT_WRAP_COLS;
|
|
359
|
-
return Math.min(MAX_WRAP_COLS, Math.max(60, cols - 4));
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
class WrappedSearchText implements Component {
|
|
363
|
-
private raw: string;
|
|
364
|
-
private theme: Theme;
|
|
365
|
-
private expanded: boolean;
|
|
366
|
-
constructor(raw: string, theme: Theme, expanded: boolean) {
|
|
367
|
-
this.raw = raw;
|
|
368
|
-
this.theme = theme;
|
|
369
|
-
this.expanded = expanded;
|
|
370
|
-
}
|
|
371
|
-
invalidate(): void {}
|
|
372
|
-
render(width: number): string[] {
|
|
373
|
-
const wrapped = wrapAll(this.raw, Math.max(40, width));
|
|
374
|
-
const lines = wrapped.split('\n');
|
|
375
|
-
const expanded = this.expanded || lines.length <= SEARCH_PREVIEW_LINES;
|
|
376
|
-
const shown = expanded ? lines : lines.slice(0, SEARCH_PREVIEW_LINES);
|
|
377
|
-
const colored = italicizeRanks(shown.join('\n'), this.theme)
|
|
378
|
-
.split('\n')
|
|
379
|
-
.map((l) => (this.expanded ? colorLine(l, this.theme) : this.theme.fg('dim', l)))
|
|
380
|
-
.join('\n');
|
|
381
|
-
const out = replaceTabs(colored).split('\n');
|
|
382
|
-
if (!expanded) {
|
|
383
|
-
const hint = moreLinesHint(shown.length, lines.length, this.theme);
|
|
384
|
-
if (hint) out.push(hint);
|
|
385
|
-
}
|
|
386
|
-
return out;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function colorLine(l: string, theme: Theme): string {
|
|
391
|
-
const plain = l.replace(ANSI_SGR, '');
|
|
392
|
-
if (
|
|
393
|
-
plain.includes('…[section truncated') ||
|
|
394
|
-
plain.includes('…(continues at outline index') ||
|
|
395
|
-
plain.startsWith('…[clipped by tool]') ||
|
|
396
|
-
plain.startsWith('…[truncated]')
|
|
397
|
-
)
|
|
398
|
-
return theme.fg('warning', l);
|
|
399
|
-
if (plain.startsWith('## ') && plain.endsWith(READ_FIRST_TAG))
|
|
400
|
-
return theme.fg('accent', l.slice(0, l.length - READ_FIRST_TAG.length)) + theme.bold(READ_FIRST_TAG);
|
|
401
|
-
if (/^\s*· recommended first read$/.test(plain)) return theme.bold(l.trimEnd());
|
|
402
|
-
const wrapped = wrappedLineKind(plain);
|
|
403
|
-
if (wrapped === 'index') return theme.fg('dim', l);
|
|
404
|
-
if (wrapped === 'chapter') return theme.fg('accent', l);
|
|
405
|
-
if (plain.startsWith('## ')) return theme.fg('accent', l);
|
|
406
|
-
if (plain.startsWith('- ')) return theme.fg('dim', l);
|
|
407
|
-
if (plain.startsWith('[rendered via')) return theme.fg('muted', l);
|
|
408
|
-
if (/^\s*\d+\. /.test(plain)) return theme.fg('dim', l);
|
|
409
|
-
return l;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function colorizedResult(text: string, theme: Theme): string {
|
|
413
|
-
return text
|
|
414
|
-
.split('\n')
|
|
415
|
-
.map((l) => colorLine(l, theme))
|
|
416
|
-
.join('\n');
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function resultText(result: { content: { type: string; text?: string }[] }): string {
|
|
420
|
-
return (result.content[0] as { type: string; text: string } | undefined)?.text ?? '';
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function moreLinesHint(shown: number, total: number, theme: Theme): string {
|
|
424
|
-
const remaining = total - shown;
|
|
425
|
-
if (remaining <= 0) return '';
|
|
426
|
-
let hint: string;
|
|
427
|
-
try {
|
|
428
|
-
hint = keyText('app.tools.expand') ? keyHint('app.tools.expand', 'to expand') : rawKeyHint('ctrl+o', 'to expand');
|
|
429
|
-
} catch {
|
|
430
|
-
hint = rawKeyHint('ctrl+o', 'to expand');
|
|
431
|
-
}
|
|
432
|
-
return theme.fg('muted', `… (${remaining} more lines,`) + ' ' + hint + theme.fg('muted', ')');
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function previewWithHint(lines: string[], theme: Theme, maxLines = PREVIEW_LINES): string {
|
|
436
|
-
const shown = lines.slice(0, maxLines);
|
|
437
|
-
const text = shown.map((l) => theme.fg('dim', replaceTabs(l))).join('\n');
|
|
438
|
-
const hint = moreLinesHint(shown.length, lines.length, theme);
|
|
439
|
-
return hint ? `${text}\n${hint}` : text;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function replaceTabs(text: string): string {
|
|
443
|
-
return text.replace(/\t/g, ' ');
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function fetchErrorResult(details: FetchDetails): FetchResult {
|
|
447
|
-
const error = String(details.error ?? 'unknown error');
|
|
448
|
-
return {
|
|
449
|
-
content: [{ type: 'text', text: `WebFetch failed: ${error}` }],
|
|
450
|
-
details,
|
|
451
|
-
structuredContent: { ...details },
|
|
452
|
-
isError: true,
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function buildFetchResult(res: Awaited<ReturnType<typeof scrape>>, sections: number[]): FetchResult {
|
|
457
|
-
const want = [...new Set(sections.map((i) => Math.max(0, Math.min(res.sections.length - 1, i))))];
|
|
458
|
-
const body = res.outline.length
|
|
459
|
-
? `indices - 0 - ${res.outline.length - 1} section${res.outline.length === 1 ? '' : 's'}\n${res.outline.map((h, i) => `- *${i}* ${h}`).join('\n')}\n\n`
|
|
460
|
-
: '';
|
|
461
|
-
const parts: string[] = [];
|
|
462
|
-
want.forEach((idx) => {
|
|
463
|
-
const s = res.sections[idx];
|
|
464
|
-
if (s === undefined) return;
|
|
465
|
-
const head = res.outline[idx] ? `## [${idx}] ${res.outline[idx]}` : `## [${idx}]`;
|
|
466
|
-
parts.push(`${head}\n\n${s}`);
|
|
467
|
-
});
|
|
468
|
-
let text =
|
|
469
|
-
(res.title ? `# ${res.title}\n\n` : '') + body + parts.join('\n\n') + (res.truncated ? '\n…[truncated]' : '');
|
|
470
|
-
text = text.replace(/\n{3,}/g, '\n\n');
|
|
471
|
-
if (res.title) {
|
|
472
|
-
const t = res.title.trim();
|
|
473
|
-
const lines = text.split('\n');
|
|
474
|
-
const headIdx = lines.findIndex((l) => l.startsWith('# '));
|
|
475
|
-
if (headIdx >= 0) {
|
|
476
|
-
for (let i = headIdx + 1; i < Math.min(lines.length, headIdx + 6); i++) {
|
|
477
|
-
const l = lines[i].replace(/^#+\s*/, '').trim();
|
|
478
|
-
if (l === t) {
|
|
479
|
-
lines.splice(i, 1);
|
|
480
|
-
break;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
text = lines.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
485
|
-
}
|
|
486
|
-
if (res.renderer && res.renderer !== 'local') text += `\n\n[rendered via ${res.renderer}]`;
|
|
487
|
-
const clipped = text.length > MAX_FETCH_TEXT ? text.slice(0, MAX_FETCH_TEXT) + '\n…[clipped by tool]' : text;
|
|
488
|
-
const missing = sections.filter((i) => i >= res.sections.length);
|
|
489
|
-
// continuation cuts (res.continued) vs local budget clips (clippedSections)
|
|
490
|
-
const truncatedCount = want.filter((idx) => res.continued?.includes(idx)).length;
|
|
491
|
-
|
|
492
|
-
// same budget as the text view; headings and the '…' marker count against it
|
|
493
|
-
let budget = MAX_FETCH_TEXT;
|
|
494
|
-
const structuredSections: { index: number; heading: string; text: string }[] = [];
|
|
495
|
-
const clippedSections: number[] = [];
|
|
496
|
-
for (const idx of want) {
|
|
497
|
-
if (budget <= 0) {
|
|
498
|
-
clippedSections.push(idx);
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
const s = res.sections[idx];
|
|
502
|
-
if (s === undefined) continue;
|
|
503
|
-
const heading = res.outline[idx] ?? '';
|
|
504
|
-
const fullCost = heading.length + s.length;
|
|
505
|
-
if (fullCost <= budget) {
|
|
506
|
-
structuredSections.push({ index: idx, heading, text: s });
|
|
507
|
-
budget -= fullCost;
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
if (heading.length >= budget) {
|
|
511
|
-
structuredSections.push({ index: idx, heading: heading.slice(0, Math.max(0, budget - 1)) + '…', text: '' });
|
|
512
|
-
} else {
|
|
513
|
-
const textLen = Math.max(0, budget - heading.length - 1);
|
|
514
|
-
structuredSections.push({ index: idx, heading, text: s.slice(0, textLen) + (textLen < s.length ? '…' : '') });
|
|
515
|
-
}
|
|
516
|
-
clippedSections.push(idx);
|
|
517
|
-
budget = 0;
|
|
518
|
-
}
|
|
519
|
-
const details = {
|
|
520
|
-
url: res.url,
|
|
521
|
-
title: res.title,
|
|
522
|
-
renderer: res.renderer,
|
|
523
|
-
outline: res.outline,
|
|
524
|
-
sections: structuredSections,
|
|
525
|
-
section_count: structuredSections.length,
|
|
526
|
-
chars: clipped.length,
|
|
527
|
-
sections_requested: [...new Set(sections)],
|
|
528
|
-
sections_missing: [...new Set(missing)],
|
|
529
|
-
sections_truncated: truncatedCount,
|
|
530
|
-
sections_clipped: clippedSections,
|
|
531
|
-
...(res.publishedAt ? { publishedAt: res.publishedAt } : {}),
|
|
532
|
-
...(res.paywalled ? { paywalled: true } : {}),
|
|
533
|
-
...(res.error ? { error: res.error } : {}),
|
|
534
|
-
};
|
|
535
|
-
if (res.error) return fetchErrorResult(details);
|
|
536
|
-
return { content: [{ type: 'text', text: clipped }], details, structuredContent: { ...details } };
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function makeFetchResult(res: Awaited<ReturnType<typeof scrape>>, params: { sections: number[] }): FetchResult {
|
|
540
|
-
return buildFetchResult(res, params.sections);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
import { statusText, writeKey, removeKey } from './keys';
|
|
544
|
-
|
|
545
|
-
function providerById(id: string | undefined): ProviderInfo | undefined {
|
|
546
|
-
if (!id) return undefined;
|
|
547
|
-
const n = id.trim().toLowerCase();
|
|
548
|
-
return PROVIDERS.find((p) => p.id === n || p.label.toLowerCase().includes(n));
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
async function promptProvider(
|
|
552
|
-
ctx: ExtensionCommandContext,
|
|
553
|
-
title: string,
|
|
554
|
-
options: string[],
|
|
555
|
-
): Promise<ProviderInfo | undefined> {
|
|
556
|
-
const picked = await ctx.ui.select(title, options);
|
|
557
|
-
if (!picked) return undefined;
|
|
558
|
-
const idToken = picked.trim().split(/\s+/)[0];
|
|
559
|
-
return providerById(idToken);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
async function handleLogin(ctx: ExtensionCommandContext, name?: string): Promise<void> {
|
|
563
|
-
// gate before ANY prompt: print mode must fail without side effects
|
|
564
|
-
if (!ctx.hasUI || ctx.mode === 'print') {
|
|
565
|
-
ctx.ui.notify('/websearch login is interactive-only', 'warning');
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
let provider = providerById(name);
|
|
569
|
-
if (name && !provider) {
|
|
570
|
-
const fuzzy = PROVIDERS.filter((p) => p.id.includes(name.toLowerCase()) || name.toLowerCase().includes(p.id));
|
|
571
|
-
ctx.ui.notify(
|
|
572
|
-
fuzzy.length
|
|
573
|
-
? `unknown provider "${name}": did you mean ${fuzzy.map((p) => p.id).join(', ')}?`
|
|
574
|
-
: `unknown provider "${name}". Run /websearch to see the ${PROVIDERS.length} supported pockets.`,
|
|
575
|
-
'warning',
|
|
576
|
-
);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
if (!provider) {
|
|
580
|
-
provider = await promptProvider(
|
|
581
|
-
ctx,
|
|
582
|
-
'login: choose a provider',
|
|
583
|
-
PROVIDERS.map((p) => `${p.id}${p.label !== p.id ? `: ${p.label}` : ''}`),
|
|
584
|
-
);
|
|
585
|
-
if (!provider) {
|
|
586
|
-
ctx.ui.notify('login cancelled', 'info');
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
const value = await ctx.ui.input(`Paste your ${provider.id} key`, 'key, or !command (Keychain/1Password)');
|
|
591
|
-
if (!value) {
|
|
592
|
-
ctx.ui.notify('login cancelled', 'info');
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
const trimmed = value.trim();
|
|
596
|
-
if (trimmed.length < 8) {
|
|
597
|
-
ctx.ui.notify('value too short to be a key', 'warning');
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
const isCmd = trimmed.startsWith('!');
|
|
601
|
-
const ok = await ctx.ui.confirm(
|
|
602
|
-
`Write ${provider.id} to ${keyFilePath()}?`,
|
|
603
|
-
isCmd
|
|
604
|
-
? 'Stored as a !command: the secret itself never touches disk.'
|
|
605
|
-
: 'Stored in the dedicated wsearch env file (0600).',
|
|
606
|
-
);
|
|
607
|
-
if (!ok) {
|
|
608
|
-
ctx.ui.notify('login cancelled', 'info');
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
writeKey(provider, trimmed);
|
|
612
|
-
resetConfigCache();
|
|
613
|
-
clearDeadEngines();
|
|
614
|
-
ctx.ui.notify(`${provider.id} ✓ saved to wsearch/env (${isCmd ? 'command' : 'key'})`, 'info');
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
async function handleLogout(ctx: ExtensionCommandContext, name?: string): Promise<void> {
|
|
618
|
-
const interactive = (): boolean => !!(ctx.hasUI && ctx.mode !== 'print');
|
|
619
|
-
const origins = keyOrigins();
|
|
620
|
-
const armed = PROVIDERS.filter((p) => origins[p.id]);
|
|
621
|
-
let provider = providerById(name);
|
|
622
|
-
if (name && !provider) {
|
|
623
|
-
ctx.ui.notify(`unknown provider "${name}". Run /websearch to see the supported pockets.`, 'warning');
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
if (!provider) {
|
|
627
|
-
if (armed.length === 0) {
|
|
628
|
-
ctx.ui.notify('no providers are configured: nothing to remove.', 'info');
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
if (!interactive()) {
|
|
632
|
-
ctx.ui.notify('/websearch logout is interactive-only', 'warning');
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
provider = await promptProvider(
|
|
636
|
-
ctx,
|
|
637
|
-
'logout: choose a provider (wsearch = removable)',
|
|
638
|
-
armed.map((p) => `${p.id}: ${origins[p.id]}${origins[p.id] === 'wsearch' ? ' (removable)' : ' (set in config)'}`),
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
if (!provider) {
|
|
642
|
-
ctx.ui.notify('logout cancelled', 'info');
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
if (origins[provider.id] !== 'wsearch') {
|
|
646
|
-
ctx.ui.notify(
|
|
647
|
-
`${provider.id} is configured via ${origins[provider.id]}, not the wsearch env file: nothing to remove here.`,
|
|
648
|
-
'info',
|
|
649
|
-
);
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
if (!interactive()) {
|
|
653
|
-
ctx.ui.notify('/websearch logout is interactive-only', 'warning');
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
const ok = await ctx.ui.confirm(`Remove ${provider.id} from ${keyFilePath()}?`, '');
|
|
657
|
-
if (!ok) return;
|
|
658
|
-
removeKey(provider);
|
|
659
|
-
resetConfigCache();
|
|
660
|
-
clearDeadEngines();
|
|
661
|
-
ctx.ui.notify(`${provider.id} removed from wsearch/env`, 'info');
|
|
662
|
-
}
|
|
663
|
-
|
|
664
327
|
const SEARCH_TOOL_NAMES = ['WebSearch', 'WebFetch'] as const;
|
|
665
328
|
|
|
666
329
|
function toggleSearchTools(pi: ExtensionAPI, disabled: boolean): void {
|
|
@@ -705,7 +368,7 @@ export function registerWebCommand(pi: ExtensionAPI) {
|
|
|
705
368
|
}
|
|
706
369
|
switch (action ?? 'status') {
|
|
707
370
|
case 'status': {
|
|
708
|
-
ctx.ui.notify(
|
|
371
|
+
ctx.ui.notify(websearchStatusLine(), 'info');
|
|
709
372
|
return;
|
|
710
373
|
}
|
|
711
374
|
case 'login':
|
|
@@ -720,7 +383,7 @@ export function registerWebCommand(pi: ExtensionAPI) {
|
|
|
720
383
|
return;
|
|
721
384
|
}
|
|
722
385
|
toggleSearchTools(pi, true);
|
|
723
|
-
ctx.ui.notify('
|
|
386
|
+
ctx.ui.notify('websearch is off · /websearch on to re-enable', 'info');
|
|
724
387
|
return;
|
|
725
388
|
}
|
|
726
389
|
case 'on': {
|
|
@@ -729,7 +392,7 @@ export function registerWebCommand(pi: ExtensionAPI) {
|
|
|
729
392
|
return;
|
|
730
393
|
}
|
|
731
394
|
toggleSearchTools(pi, false);
|
|
732
|
-
ctx.ui.notify('
|
|
395
|
+
ctx.ui.notify('websearch is on', 'info');
|
|
733
396
|
return;
|
|
734
397
|
}
|
|
735
398
|
default:
|