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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-wsearch",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Pi extension: WebSearch + WebFetch",
5
5
  "license": "MIT",
6
6
  "type": "module",
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: FusedHit, b: FusedHit): boolean {
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
- return collapseNearDuplicates(sorted);
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
- export function statusText(): string {
74
- const st = keyStatus();
75
- const origins = keyOrigins();
76
- const armed = PROVIDER_IDS.filter((id) => st[id]);
77
- const dormant = PROVIDER_IDS.filter((id) => !st[id]);
78
- const cfg = getConfig();
79
- const lines: string[] = [];
80
- lines.push(`web tools: ${existsSync(disabledFlagPath()) ? 'OFF' : 'ON'}`);
81
- lines.push(`web search: ${PROVIDER_IDS.length} pockets`);
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, ExtensionCommandContext, Theme } from '@earendil-works/pi-coding-agent';
7
- import { keyHint, keyText, rawKeyHint } from '@earendil-works/pi-coding-agent';
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, READ_FIRST_TAG, wrappedLineKind, wrapAll } from './view';
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(statusText(), 'info');
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('WebSearch + WebFetch OFF — /websearch on re-enables', 'info');
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('WebSearch + WebFetch ON', 'info');
395
+ ctx.ui.notify('websearch is on', 'info');
733
396
  return;
734
397
  }
735
398
  default: