pi-fox 1.0.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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/index.ts +1934 -0
- package/package.json +41 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Browser + Web Access Extension
|
|
3
|
+
*
|
|
4
|
+
* Author: vanitea
|
|
5
|
+
*
|
|
6
|
+
* Unified extension providing browser automation (via Playwright) and web access
|
|
7
|
+
* (search, content fetching, code search) in a single package.
|
|
8
|
+
*
|
|
9
|
+
* ── Browser Tools (Playwright, Firefox default) ──
|
|
10
|
+
* browser_navigate, browser_click, browser_type, browser_snapshot,
|
|
11
|
+
* browser_screenshot, browser_evaluate, browser_wait,
|
|
12
|
+
* browser_tab_list, browser_tab_new, browser_tab_close, browser_tab_select,
|
|
13
|
+
* browser_back, browser_forward, browser_reload, browser_close
|
|
14
|
+
*
|
|
15
|
+
* ── Web Tools ──
|
|
16
|
+
* web_search — AI-powered web search (Brave / Perplexity / Tavily / Exa / Gemini)
|
|
17
|
+
* code_search — Code examples, docs, API references
|
|
18
|
+
* fetch_content — Fetch and extract readable content from URLs
|
|
19
|
+
* get_search_content — Retrieve previously fetched/stored content
|
|
20
|
+
*
|
|
21
|
+
* ── Commands ──
|
|
22
|
+
* /browser — Onboarding wizard, provider status, and usage help
|
|
23
|
+
* /search — Search provider management (hub + /search <id> quick switch)
|
|
24
|
+
*
|
|
25
|
+
* Configuration — set ONE or MORE:
|
|
26
|
+
*
|
|
27
|
+
* Priority: settings.json > environment variable
|
|
28
|
+
*
|
|
29
|
+
* Storage options (any method that results in the env var being set works):
|
|
30
|
+
* • Shell: export BRAVE_API_KEY="..." (or setx on Windows)
|
|
31
|
+
* • pi settings: ~/.pi/agent/settings.json → { "browserExt": { "BRAVE_API_KEY": "..." } }
|
|
32
|
+
* • Cloud env: gh secrets, Railway, Render, Fly — all inject env vars
|
|
33
|
+
*
|
|
34
|
+
* Provider keys (free tier available for all):
|
|
35
|
+
* BRAVE_API_KEY — Brave Search (free: 2,000/mo) ★ recommended free option
|
|
36
|
+
* PERPLEXITY_API_KEY — Perplexity AI (paid)
|
|
37
|
+
* TAVILY_API_KEY — Tavily (free: 1,000/mo)
|
|
38
|
+
* EXA_API_KEY — Exa (free: 2,500/mo)
|
|
39
|
+
* GEMINI_API_KEY — Google Gemini (free tier) — prepaid credits depleted
|
|
40
|
+
*
|
|
41
|
+
* First configured key becomes active. Use /search to manage providers at runtime.
|
|
42
|
+
*
|
|
43
|
+
* Usage:
|
|
44
|
+
* pi -e ~/.pi/agent/extensions/pi-fox/index.ts
|
|
45
|
+
* /browser — first-run onboarding wizard
|
|
46
|
+
* /search — search provider management
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
50
|
+
import { Type } from "typebox";
|
|
51
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
import { homedir } from "node:os";
|
|
54
|
+
import TurndownService from "turndown";
|
|
55
|
+
|
|
56
|
+
// Stateless singleton — do not call addRule() here; create a new instance for per-call customization.
|
|
57
|
+
const turndown = new TurndownService({
|
|
58
|
+
headingStyle: "atx",
|
|
59
|
+
codeBlockStyle: "fenced",
|
|
60
|
+
bulletListMarker: "-",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ===========================================================================
|
|
64
|
+
// SECTION 0 — Core Types & Utilities
|
|
65
|
+
// ===========================================================================
|
|
66
|
+
|
|
67
|
+
// ── Type definitions ──
|
|
68
|
+
|
|
69
|
+
type ProviderId = "brave" | "perplexity" | "tavily" | "exa" | "gemini" | (string & {});
|
|
70
|
+
|
|
71
|
+
/** Typed view of settings.json → browserExt block */
|
|
72
|
+
interface ExtConfig {
|
|
73
|
+
BRAVE_API_KEY?: string;
|
|
74
|
+
PERPLEXITY_API_KEY?: string;
|
|
75
|
+
TAVILY_API_KEY?: string;
|
|
76
|
+
EXA_API_KEY?: string;
|
|
77
|
+
GEMINI_API_KEY?: string;
|
|
78
|
+
activeProvider?: ProviderId;
|
|
79
|
+
supervised?: boolean;
|
|
80
|
+
headless?: boolean;
|
|
81
|
+
suppressStartupMessage?: boolean;
|
|
82
|
+
customProviders?: CustomProviderConfig[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Settings snapshot — read once per tool call, passed everywhere. Zero extra disk reads. */
|
|
86
|
+
interface Config {
|
|
87
|
+
raw: Record<string, unknown>;
|
|
88
|
+
ext: ExtConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Explicit success/failure — errors collected and surfaced, never swallowed. */
|
|
92
|
+
type Result<T> = { ok: true; value: T } | { ok: false; error: string; provider?: string };
|
|
93
|
+
|
|
94
|
+
interface SearchResult {
|
|
95
|
+
answer: string;
|
|
96
|
+
results: Array<{ title: string; url: string; snippet: string }>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One object per search provider. Add a new entry to PROVIDER_REGISTRY to add a provider. */
|
|
100
|
+
interface ProviderImpl {
|
|
101
|
+
id: ProviderId;
|
|
102
|
+
label: string;
|
|
103
|
+
envKey: string;
|
|
104
|
+
freeTier: string;
|
|
105
|
+
signupUrl: string;
|
|
106
|
+
hasKey(config: Config): boolean;
|
|
107
|
+
search(query: string, n: number, config: Config): Promise<SearchResult>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Config shape for a user-defined search provider stored in settings.json. */
|
|
111
|
+
interface CustomProviderConfig {
|
|
112
|
+
id: string;
|
|
113
|
+
label: string;
|
|
114
|
+
envKey: string;
|
|
115
|
+
freeTier: string;
|
|
116
|
+
signupUrl: string;
|
|
117
|
+
transform: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface BrowserState {
|
|
121
|
+
browser: import("playwright").Browser | null;
|
|
122
|
+
context: import("playwright").BrowserContext | null;
|
|
123
|
+
page: import("playwright").Page | null;
|
|
124
|
+
engine: string;
|
|
125
|
+
headless: boolean;
|
|
126
|
+
supervised: boolean;
|
|
127
|
+
sessionDir: string;
|
|
128
|
+
viewport: { width: number; height: number };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Settings ──
|
|
132
|
+
|
|
133
|
+
const SETTINGS_FILE = join(homedir(), ".pi", "agent", "settings.json");
|
|
134
|
+
|
|
135
|
+
/** Read settings.json once. Pass the returned Config to all functions that need it. */
|
|
136
|
+
function loadConfig(): Config {
|
|
137
|
+
try {
|
|
138
|
+
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf-8")) as Record<string, unknown>;
|
|
139
|
+
const ext = (raw.browserExt ?? {}) as ExtConfig;
|
|
140
|
+
return { raw, ext };
|
|
141
|
+
} catch {
|
|
142
|
+
return { raw: {}, ext: {} };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Single write path for all settings mutations. Replaces 5 duplicated read-cast-spread-write blocks. */
|
|
147
|
+
function patchExtConfig(patch: Partial<ExtConfig>): void {
|
|
148
|
+
const config = loadConfig();
|
|
149
|
+
const updated: ExtConfig = { ...config.ext, ...patch };
|
|
150
|
+
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
151
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify({ ...config.raw, browserExt: updated }, null, 2));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── HTTP ──
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Async HTTP JSON fetch — replaces all execFileSync("curl") calls.
|
|
158
|
+
* Uses Node 18+ native fetch. Does not block the event loop.
|
|
159
|
+
* Throws on non-2xx with the HTTP status and truncated body for debugging.
|
|
160
|
+
*/
|
|
161
|
+
async function fetchJson<T>(url: string, options?: {
|
|
162
|
+
method?: string;
|
|
163
|
+
headers?: Record<string, string>;
|
|
164
|
+
body?: string;
|
|
165
|
+
timeout?: number;
|
|
166
|
+
}): Promise<T> {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timer = setTimeout(() => controller.abort(), options?.timeout ?? 15000);
|
|
169
|
+
try {
|
|
170
|
+
const res = await fetch(url, {
|
|
171
|
+
method: options?.method,
|
|
172
|
+
headers: options?.headers,
|
|
173
|
+
body: options?.body,
|
|
174
|
+
signal: controller.signal,
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
const body = await res.text().catch(() => "");
|
|
178
|
+
throw new Error(`HTTP ${res.status}: ${body.substring(0, 200)}`);
|
|
179
|
+
}
|
|
180
|
+
return await res.json() as T;
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Wizard test capture ──
|
|
187
|
+
|
|
188
|
+
/** Stores the last response captured by fetchJsonCapturing. Module-level — wizard steps are sequential. */
|
|
189
|
+
let _lastCapturedRaw: unknown = null;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Thin wrapper around fetchJson that records the raw response.
|
|
193
|
+
* Used only during the custom provider wizard test step.
|
|
194
|
+
* Never used in production search paths.
|
|
195
|
+
*/
|
|
196
|
+
async function fetchJsonCapturing<T>(
|
|
197
|
+
url: string,
|
|
198
|
+
options?: Parameters<typeof fetchJson>[1],
|
|
199
|
+
): Promise<T> {
|
|
200
|
+
const result = await fetchJson<T>(url, options);
|
|
201
|
+
_lastCapturedRaw = result;
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getLastCapturedRaw(): unknown {
|
|
206
|
+
const val = _lastCapturedRaw;
|
|
207
|
+
_lastCapturedRaw = null;
|
|
208
|
+
return val;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Tool helpers ──
|
|
212
|
+
|
|
213
|
+
/** Standard error response — replaces 19 duplicated error construction blocks. */
|
|
214
|
+
function toolError(text: string, extras?: Record<string, unknown>) {
|
|
215
|
+
return {
|
|
216
|
+
content: [{ type: "text" as const, text }],
|
|
217
|
+
details: { error: text, ...extras },
|
|
218
|
+
isError: true as const,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Wraps a browser tool's core logic with the supervised screenshot lifecycle.
|
|
224
|
+
* Replaces 5 duplicated _ss/_ssNote/_det blocks.
|
|
225
|
+
* BUG-2 fix: uses \u{1F4F8} (valid TS) not \U0001F4F8 (Python syntax).
|
|
226
|
+
*/
|
|
227
|
+
async function withSupervisedScreenshot(
|
|
228
|
+
state: BrowserState,
|
|
229
|
+
label: string,
|
|
230
|
+
baseDetails: Record<string, unknown>,
|
|
231
|
+
fn: () => Promise<{ text: string; extraDetails?: Record<string, unknown> }>,
|
|
232
|
+
) {
|
|
233
|
+
const { text, extraDetails } = await fn();
|
|
234
|
+
const ss = await takeSupervisedScreenshot(state, label);
|
|
235
|
+
const note = ss ? `\n[\u{1F4F8} ${ss}]` : "";
|
|
236
|
+
const details = { ...baseDetails, ...extraDetails, ...(ss ? { screenshot: ss } : {}) };
|
|
237
|
+
return {
|
|
238
|
+
content: [{ type: "text" as const, text: text + note }],
|
|
239
|
+
details,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ===========================================================================
|
|
244
|
+
// SECTION 1 — Browser State & Session Management
|
|
245
|
+
// ===========================================================================
|
|
246
|
+
|
|
247
|
+
function getEngine(): string {
|
|
248
|
+
return (process.env.PI_BROWSER_ENGINE || "firefox").toLowerCase();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* BUG-3 fix: reads settings.browserExt.headless first (same pattern as getSupervised).
|
|
253
|
+
* The old getHeadless() only read env vars, making the /browser headless toggle permanently inert.
|
|
254
|
+
*/
|
|
255
|
+
function getHeadless(config: Config): boolean {
|
|
256
|
+
if (typeof config.ext.headless === "boolean") return config.ext.headless;
|
|
257
|
+
const val = (process.env.PI_BROWSER_HEADLESS || "true").toLowerCase();
|
|
258
|
+
return val !== "false" && val !== "0";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getSupervised(config: Config): boolean {
|
|
262
|
+
if (typeof config.ext.supervised === "boolean") return config.ext.supervised;
|
|
263
|
+
const val = (process.env.PI_BROWSER_SUPERVISED || "true").toLowerCase();
|
|
264
|
+
return val !== "false" && val !== "0";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function getViewport(config: Config): { width: number; height: number } {
|
|
268
|
+
return {
|
|
269
|
+
width: parseInt(process.env.PI_BROWSER_WIDTH || "1280", 10) || 1280,
|
|
270
|
+
height: parseInt(process.env.PI_BROWSER_HEIGHT || "720", 10) || 720,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* BUG-7 fix: creates the session directory lazily on first screenshot, not at extension load.
|
|
276
|
+
* Called only from takeSupervisedScreenshot. Does nothing if sessionDir already set.
|
|
277
|
+
*/
|
|
278
|
+
function ensureSessionDir(state: BrowserState): string {
|
|
279
|
+
if (!state.sessionDir) {
|
|
280
|
+
const base = join(homedir(), "Pictures", "pi-fox", "sessions");
|
|
281
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").substring(0, 19);
|
|
282
|
+
state.sessionDir = join(base, ts);
|
|
283
|
+
mkdirSync(state.sessionDir, { recursive: true });
|
|
284
|
+
}
|
|
285
|
+
return state.sessionDir;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function takeSupervisedScreenshot(state: BrowserState, label: string): Promise<string | null> {
|
|
289
|
+
if (!state.supervised || !state.page || state.page.isClosed()) return null;
|
|
290
|
+
try {
|
|
291
|
+
const dir = ensureSessionDir(state);
|
|
292
|
+
const safeName = label.replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 40);
|
|
293
|
+
const filename = `${Date.now()}_${safeName}.png`;
|
|
294
|
+
const filepath = join(dir, filename);
|
|
295
|
+
await state.page.screenshot({ path: filepath, type: "png" });
|
|
296
|
+
return filepath;
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Returns existing page if open. Otherwise tears down any stale browser/context
|
|
304
|
+
* (prevents process leaks on page crash) and launches a fresh session.
|
|
305
|
+
*/
|
|
306
|
+
async function getPage(state: BrowserState): Promise<import("playwright").Page> {
|
|
307
|
+
if (state.page && !state.page.isClosed()) return state.page;
|
|
308
|
+
// Tear down stale context/browser before relaunching
|
|
309
|
+
await closeBrowser(state);
|
|
310
|
+
const playwright = await import("playwright");
|
|
311
|
+
const launcher = playwright[state.engine as "firefox" | "chromium" | "webkit"] ?? playwright.firefox;
|
|
312
|
+
state.browser = await launcher.launch({ headless: state.headless });
|
|
313
|
+
state.context = await state.browser.newContext({
|
|
314
|
+
viewport: state.viewport,
|
|
315
|
+
ignoreHTTPSErrors: true,
|
|
316
|
+
});
|
|
317
|
+
state.page = await state.context.newPage();
|
|
318
|
+
return state.page;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** BUG-5 fix: resets sessionDir so the next browser session gets a fresh timestamped directory. */
|
|
322
|
+
async function closeBrowser(state: BrowserState) {
|
|
323
|
+
try { await state.context?.close(); } catch { /* ignore */ }
|
|
324
|
+
try { await state.browser?.close(); } catch { /* ignore */ }
|
|
325
|
+
state.browser = null;
|
|
326
|
+
state.context = null;
|
|
327
|
+
state.page = null;
|
|
328
|
+
state.sessionDir = "";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Browser tool schemas ──
|
|
332
|
+
|
|
333
|
+
const NavigateParams = Type.Object({
|
|
334
|
+
url: Type.String({ description: "URL to navigate to, e.g. https://example.com" }),
|
|
335
|
+
waitUntil: Type.Optional(Type.String({ description: "When to consider navigation done: 'load', 'domcontentloaded', or 'networkidle'. Default: 'load'" })),
|
|
336
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds. Default: 30000" })),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const ClickParams = Type.Object({
|
|
340
|
+
selector: Type.String({ description: "CSS selector or element role to click, e.g. 'button.login', 'text=Submit', 'css=#submit-btn'" }),
|
|
341
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in ms. Default: 10000" })),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const TypeParams = Type.Object({
|
|
345
|
+
selector: Type.String({ description: "CSS selector for the input element, e.g. 'input[name=email]', '#search-box'" }),
|
|
346
|
+
text: Type.String({ description: "Text to type" }),
|
|
347
|
+
delay: Type.Optional(Type.Number({ description: "Delay between keystrokes in ms. Default: 0" })),
|
|
348
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in ms. Default: 10000" })),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const SnapshotParams = Type.Object({
|
|
352
|
+
compact: Type.Optional(Type.Boolean({ description: "Return compact snapshot (default: true)" })),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const ScreenshotParams = Type.Object({
|
|
356
|
+
fullPage: Type.Optional(Type.Boolean({ description: "Capture full page. Default: false" })),
|
|
357
|
+
selector: Type.Optional(Type.String({ description: "CSS selector to screenshot specific element" })),
|
|
358
|
+
format: Type.Optional(Type.String({ description: "Image format: 'png' or 'jpeg'. Default: 'png'" })),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const EvaluateParams = Type.Object({
|
|
362
|
+
script: Type.String({ description: "JavaScript expression to evaluate in the page context" }),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const WaitParams = Type.Object({
|
|
366
|
+
selector: Type.Optional(Type.String({ description: "CSS selector to wait for" })),
|
|
367
|
+
timeout: Type.Optional(Type.Number({ description: "Wait timeout in milliseconds. Default: 10000" })),
|
|
368
|
+
state: Type.Optional(Type.String({ description: "Wait state: 'attached', 'visible', 'hidden', 'detached'. Default: 'visible'" })),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const TabSelectParams = Type.Object({
|
|
372
|
+
index: Type.Number({ description: "Zero-based tab index to switch to" }),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const TabNewParams = Type.Object({
|
|
376
|
+
url: Type.Optional(Type.String({ description: "Optional URL to navigate to in the new tab" })),
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ===========================================================================
|
|
380
|
+
// SECTION 2 — Web Search & Content Fetching
|
|
381
|
+
// ===========================================================================
|
|
382
|
+
|
|
383
|
+
const WEB_CACHE_DIR = join(homedir(), ".pi", "web-cache");
|
|
384
|
+
|
|
385
|
+
function ensureCacheDir() {
|
|
386
|
+
if (!existsSync(WEB_CACHE_DIR)) {
|
|
387
|
+
mkdirSync(WEB_CACHE_DIR, { recursive: true });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function cacheKey(prefix: string, input: string): string {
|
|
392
|
+
// Use a simple hash for cache filenames
|
|
393
|
+
let hash = 0;
|
|
394
|
+
for (let i = 0; i < input.length; i++) {
|
|
395
|
+
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
|
|
396
|
+
}
|
|
397
|
+
return `${prefix}_${Math.abs(hash).toString(36)}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function urlToCacheKey(url: string): string {
|
|
401
|
+
// Create a filesystem-safe key from a URL
|
|
402
|
+
return url.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 100);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function cachePath(key: string): string {
|
|
406
|
+
ensureCacheDir();
|
|
407
|
+
return join(WEB_CACHE_DIR, `${key}.json`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function storeCache(key: string, data: Record<string, unknown>) {
|
|
411
|
+
writeFileSync(cachePath(key), JSON.stringify(data, null, 2));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function loadCache(key: string): Record<string, unknown> | null {
|
|
415
|
+
const p = cachePath(key);
|
|
416
|
+
if (!existsSync(p)) return null;
|
|
417
|
+
try {
|
|
418
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** EFF-4 fix: O(1) direct key lookup — urlToCacheKey already provides the deterministic filename. */
|
|
425
|
+
function findCacheByUrl(url: string): Record<string, unknown> | null {
|
|
426
|
+
return loadCache(`fetch_${urlToCacheKey(url)}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Provider registry ──
|
|
430
|
+
|
|
431
|
+
const NO_PROVIDER_MSG =
|
|
432
|
+
"No search provider configured. web_search and code_search require an API key.\n\n" +
|
|
433
|
+
"Set your key via any of these methods:\n" +
|
|
434
|
+
' Shell: export BRAVE_API_KEY="..." (add to ~/.bashrc for persistence)\n' +
|
|
435
|
+
' Windows: setx BRAVE_API_KEY "..."\n' +
|
|
436
|
+
` Pi config: edit ~/.pi/agent/settings.json → { "browserExt": { "BRAVE_API_KEY": "..." } }\n\n` +
|
|
437
|
+
"Available providers with free tiers:\n" +
|
|
438
|
+
" Brave (2,000/mo) https://brave.com/search/api/\n" +
|
|
439
|
+
" Tavily (1,000/mo) https://app.tavily.com/\n" +
|
|
440
|
+
" Exa (2,500/mo) https://exa.ai/\n" +
|
|
441
|
+
" Gemini (free tier) https://aistudio.google.com/app/apikey\n" +
|
|
442
|
+
" Perplexity (paid) https://www.perplexity.ai/settings/api\n\n" +
|
|
443
|
+
"Run /search for the interactive setup wizard.";
|
|
444
|
+
|
|
445
|
+
const braveProvider: ProviderImpl = {
|
|
446
|
+
id: "brave", label: "Brave Search", envKey: "BRAVE_API_KEY",
|
|
447
|
+
freeTier: "2,000 queries/mo", signupUrl: "https://brave.com/search/api/",
|
|
448
|
+
hasKey(config) { return !!config.ext.BRAVE_API_KEY; },
|
|
449
|
+
async search(query, n, config) {
|
|
450
|
+
const key = config.ext.BRAVE_API_KEY!;
|
|
451
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
452
|
+
url.searchParams.set("q", query);
|
|
453
|
+
url.searchParams.set("count", String(n));
|
|
454
|
+
const data = await fetchJson<{
|
|
455
|
+
web?: { results?: Array<{ title: string; url: string; description?: string }> };
|
|
456
|
+
}>(url.toString(), {
|
|
457
|
+
headers: { "Accept": "application/json", "X-Subscription-Token": key },
|
|
458
|
+
});
|
|
459
|
+
const results = (data.web?.results ?? []).slice(0, n).map(r => ({
|
|
460
|
+
title: r.title, url: r.url, snippet: r.description ?? "",
|
|
461
|
+
}));
|
|
462
|
+
return {
|
|
463
|
+
answer: results.length
|
|
464
|
+
? `Search results for "${query}" (${results.length} results)`
|
|
465
|
+
: `No results found for "${query}"`,
|
|
466
|
+
results,
|
|
467
|
+
};
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const tavilyProvider: ProviderImpl = {
|
|
472
|
+
id: "tavily", label: "Tavily", envKey: "TAVILY_API_KEY",
|
|
473
|
+
freeTier: "1,000 queries/mo", signupUrl: "https://app.tavily.com/",
|
|
474
|
+
hasKey(config) { return !!config.ext.TAVILY_API_KEY; },
|
|
475
|
+
async search(query, n, config) {
|
|
476
|
+
const key = config.ext.TAVILY_API_KEY!;
|
|
477
|
+
const data = await fetchJson<{
|
|
478
|
+
answer?: string;
|
|
479
|
+
results?: Array<{ title: string; url: string; content?: string }>;
|
|
480
|
+
}>("https://api.tavily.com/search", {
|
|
481
|
+
method: "POST",
|
|
482
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
483
|
+
body: JSON.stringify({ query, max_results: n, include_answer: true }),
|
|
484
|
+
});
|
|
485
|
+
const results = (data.results ?? []).map(r => ({
|
|
486
|
+
title: r.title, url: r.url, snippet: (r.content ?? "").substring(0, 300),
|
|
487
|
+
}));
|
|
488
|
+
return { answer: data.answer ?? "", results };
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const exaProvider: ProviderImpl = {
|
|
493
|
+
id: "exa", label: "Exa", envKey: "EXA_API_KEY",
|
|
494
|
+
freeTier: "2,500 queries/mo", signupUrl: "https://exa.ai/",
|
|
495
|
+
hasKey(config) { return !!config.ext.EXA_API_KEY; },
|
|
496
|
+
async search(query, n, config) {
|
|
497
|
+
const key = config.ext.EXA_API_KEY!;
|
|
498
|
+
const data = await fetchJson<{
|
|
499
|
+
results?: Array<{ title?: string; url: string; text?: string }>;
|
|
500
|
+
}>("https://api.exa.ai/search", {
|
|
501
|
+
method: "POST",
|
|
502
|
+
headers: { "Content-Type": "application/json", "X-Api-Key": key },
|
|
503
|
+
body: JSON.stringify({ query, numResults: n, type: "auto", contents: { text: true } }),
|
|
504
|
+
});
|
|
505
|
+
const results = (data.results ?? []).map(r => ({
|
|
506
|
+
title: r.title ?? r.url, url: r.url, snippet: (r.text ?? "").substring(0, 300),
|
|
507
|
+
}));
|
|
508
|
+
return { answer: "", results };
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const geminiProvider: ProviderImpl = {
|
|
513
|
+
id: "gemini", label: "Google Gemini", envKey: "GEMINI_API_KEY",
|
|
514
|
+
freeTier: "free tier", signupUrl: "https://aistudio.google.com/app/apikey",
|
|
515
|
+
hasKey(config) { return !!config.ext.GEMINI_API_KEY; },
|
|
516
|
+
async search(query, _n, config) {
|
|
517
|
+
const key = config.ext.GEMINI_API_KEY!;
|
|
518
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
|
|
519
|
+
const data = await fetchJson<{
|
|
520
|
+
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
|
521
|
+
}>(url, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
headers: { "Content-Type": "application/json" },
|
|
524
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: query }] }] }),
|
|
525
|
+
});
|
|
526
|
+
const answer = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "(no answer)";
|
|
527
|
+
return { answer, results: [] };
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const perplexityProvider: ProviderImpl = {
|
|
532
|
+
id: "perplexity", label: "Perplexity AI", envKey: "PERPLEXITY_API_KEY",
|
|
533
|
+
freeTier: "—", signupUrl: "https://www.perplexity.ai/settings/api",
|
|
534
|
+
hasKey(config) { return !!config.ext.PERPLEXITY_API_KEY; },
|
|
535
|
+
async search(query, n, config) {
|
|
536
|
+
const key = config.ext.PERPLEXITY_API_KEY!;
|
|
537
|
+
const data = await fetchJson<{
|
|
538
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
539
|
+
citations?: string[];
|
|
540
|
+
}>("https://api.perplexity.ai/chat/completions", {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
|
543
|
+
body: JSON.stringify({ model: "sonar", messages: [{ role: "user", content: query }], max_tokens: 1024 }),
|
|
544
|
+
});
|
|
545
|
+
const answer = data.choices?.[0]?.message?.content ?? "(no answer)";
|
|
546
|
+
const results = (data.citations ?? []).slice(0, n).map((url, i) => ({
|
|
547
|
+
title: `Source ${i + 1}`, url, snippet: "",
|
|
548
|
+
}));
|
|
549
|
+
return { answer, results };
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Wraps a CustomProviderConfig from settings.json into a live ProviderImpl.
|
|
555
|
+
* The transform string is a full async search() implementation — it handles both
|
|
556
|
+
* request building and response parsing. Compile and runtime errors are surfaced
|
|
557
|
+
* separately so the user knows whether their syntax or their field mapping is broken.
|
|
558
|
+
*/
|
|
559
|
+
function buildCustomProviderImpl(cfg: CustomProviderConfig): ProviderImpl {
|
|
560
|
+
return {
|
|
561
|
+
id: cfg.id,
|
|
562
|
+
label: cfg.label,
|
|
563
|
+
envKey: cfg.envKey,
|
|
564
|
+
freeTier: cfg.freeTier,
|
|
565
|
+
signupUrl: cfg.signupUrl,
|
|
566
|
+
hasKey(config) {
|
|
567
|
+
return !!(config.ext as Record<string, unknown>)[cfg.envKey] ||
|
|
568
|
+
!!process.env[cfg.envKey];
|
|
569
|
+
},
|
|
570
|
+
async search(query, n, config) {
|
|
571
|
+
const apiKey = String(
|
|
572
|
+
(config.ext as Record<string, unknown>)[cfg.envKey] ||
|
|
573
|
+
process.env[cfg.envKey] || ""
|
|
574
|
+
);
|
|
575
|
+
type TransformFn = (q: string, n: number, k: string, f: typeof fetchJson) => Promise<SearchResult>;
|
|
576
|
+
let fn: TransformFn;
|
|
577
|
+
try {
|
|
578
|
+
fn = new Function(
|
|
579
|
+
"query", "n", "apiKey", "fetchJson",
|
|
580
|
+
`return (${cfg.transform})(query, n, apiKey, fetchJson)`
|
|
581
|
+
) as TransformFn;
|
|
582
|
+
} catch (err) {
|
|
583
|
+
throw new Error(`Transform compile error in "${cfg.label}": ${err instanceof Error ? err.message : String(err)}`);
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
return await fn(query, n, apiKey, fetchJson);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
throw new Error(`Transform runtime error in "${cfg.label}": ${err instanceof Error ? err.message : String(err)}`);
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Built-in providers. Custom providers from settings.json are appended at startup inside browserWebExtension(). */
|
|
595
|
+
let PROVIDER_REGISTRY: ProviderImpl[] = [
|
|
596
|
+
braveProvider, tavilyProvider, exaProvider, geminiProvider, perplexityProvider,
|
|
597
|
+
];
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Returns configured providers and the active one.
|
|
601
|
+
* Single call per tool invocation — no extra disk reads.
|
|
602
|
+
* STRUCT-1 fix: "none" never appears in typed arrays.
|
|
603
|
+
*/
|
|
604
|
+
function getActiveConfig(config: Config): { providers: ProviderImpl[]; active: ProviderImpl | undefined } {
|
|
605
|
+
const providers = PROVIDER_REGISTRY.filter(p => p.hasKey(config));
|
|
606
|
+
const active = providers.find(p => p.id === config.ext.activeProvider) ?? providers[0];
|
|
607
|
+
return { providers, active };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* BUG-1 fix: recency is appended to fullQuery (not bare query) so domain filter is preserved.
|
|
612
|
+
* EFF-1 fix: accepts Config — no extra disk reads inside.
|
|
613
|
+
* Collects provider failures into debugInfo instead of swallowing them.
|
|
614
|
+
*/
|
|
615
|
+
async function performSearch(
|
|
616
|
+
query: string,
|
|
617
|
+
n: number,
|
|
618
|
+
preferred: string | undefined,
|
|
619
|
+
recency: string | undefined,
|
|
620
|
+
domainFilter: string[] | undefined,
|
|
621
|
+
config: Config,
|
|
622
|
+
): Promise<{ answer: string; results: Array<{ title: string; url: string; snippet: string }>; provider: string; debugInfo: string[] }> {
|
|
623
|
+
// Build fullQuery — apply domain filter first, then recency (both on fullQuery, not bare query)
|
|
624
|
+
let fullQuery = query;
|
|
625
|
+
if (domainFilter?.length) {
|
|
626
|
+
const sites = domainFilter.map(d => d.startsWith("-") ? `-site:${d.slice(1)}` : `site:${d}`).join(" ");
|
|
627
|
+
fullQuery = `${fullQuery} ${sites}`;
|
|
628
|
+
}
|
|
629
|
+
if (recency) fullQuery = `${fullQuery} (${recency})`;
|
|
630
|
+
|
|
631
|
+
const { providers, active } = getActiveConfig(config);
|
|
632
|
+
if (!active) return { answer: NO_PROVIDER_MSG, results: [], provider: "none", debugInfo: [] };
|
|
633
|
+
|
|
634
|
+
// Preferred provider first, then remaining configured providers as fallback
|
|
635
|
+
const preferredImpl = preferred && preferred !== "auto"
|
|
636
|
+
? providers.find(p => p.id === preferred)
|
|
637
|
+
: undefined;
|
|
638
|
+
const ordered = preferredImpl
|
|
639
|
+
? [preferredImpl, ...providers.filter(p => p.id !== preferredImpl.id)]
|
|
640
|
+
: [active, ...providers.filter(p => p.id !== active.id)];
|
|
641
|
+
|
|
642
|
+
const debugInfo: string[] = [];
|
|
643
|
+
for (const provider of ordered) {
|
|
644
|
+
try {
|
|
645
|
+
const value = await provider.search(fullQuery, n, config);
|
|
646
|
+
return { ...value, provider: provider.id, debugInfo };
|
|
647
|
+
} catch (err) {
|
|
648
|
+
debugInfo.push(`${provider.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
answer: `All providers failed.\n\nTried:\n${debugInfo.map(d => ` • ${d}`).join("\n")}\n\n${NO_PROVIDER_MSG}`,
|
|
654
|
+
results: [], provider: "none", debugInfo,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Content fetching ──
|
|
659
|
+
|
|
660
|
+
/** Async URL content fetch — uses native fetch, no execFileSync, does not block event loop. */
|
|
661
|
+
async function fetchUrlContent(url: string): Promise<{ title: string; content: string; error?: string }> {
|
|
662
|
+
try {
|
|
663
|
+
const res = await fetch(url, {
|
|
664
|
+
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
|
|
665
|
+
signal: AbortSignal.timeout(15000),
|
|
666
|
+
});
|
|
667
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
668
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
669
|
+
if (!ct.includes("text/")) return { title: url, content: "", error: `Non-text content-type: ${ct}` };
|
|
670
|
+
const html = await res.text();
|
|
671
|
+
|
|
672
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
673
|
+
const title = titleMatch?.[1]?.trim() ?? url;
|
|
674
|
+
|
|
675
|
+
// Strip non-content tags before Turndown conversion
|
|
676
|
+
const cleaned = html
|
|
677
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
678
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
679
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
|
|
680
|
+
|
|
681
|
+
let text: string;
|
|
682
|
+
try {
|
|
683
|
+
text = turndown.turndown(cleaned).trim();
|
|
684
|
+
} catch {
|
|
685
|
+
// Fallback: basic tag strip if Turndown fails on malformed HTML
|
|
686
|
+
text = cleaned
|
|
687
|
+
.replace(/<[^>]+>/g, " ")
|
|
688
|
+
.replace(/ /g, " ")
|
|
689
|
+
.replace(/</g, "<")
|
|
690
|
+
.replace(/>/g, ">")
|
|
691
|
+
.replace(/"/g, '"')
|
|
692
|
+
.replace(/'/g, "'")
|
|
693
|
+
.replace(/&/g, "&")
|
|
694
|
+
.replace(/\s+/g, " ")
|
|
695
|
+
.trim();
|
|
696
|
+
text = "[Note: Markdown conversion failed — plain text fallback]\n\n" + text;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return { title, content: text };
|
|
700
|
+
} catch (err) {
|
|
701
|
+
return { title: url, content: "", error: err instanceof Error ? err.message : String(err) };
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ── Web tool schemas ──
|
|
706
|
+
|
|
707
|
+
const WebSearchParams = Type.Object({
|
|
708
|
+
query: Type.Optional(Type.String({ description: "Single search query. For research, prefer 'queries' with multiple varied angles." })),
|
|
709
|
+
queries: Type.Optional(Type.Array(Type.String(), { description: "Multiple queries for broader coverage. Vary phrasing and scope across 2-4 queries." })),
|
|
710
|
+
numResults: Type.Optional(Type.Number({ description: "Results per query (default: 5, max: 20)" })),
|
|
711
|
+
includeContent: Type.Optional(Type.Boolean({ description: "Fetch full page content for results (async). Default: false" })),
|
|
712
|
+
recencyFilter: Type.Optional(Type.String({ description: "Filter by recency: 'day', 'week', 'month', or 'year'" })),
|
|
713
|
+
domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains (prefix with - to exclude, e.g. '-wikipedia.org')" })),
|
|
714
|
+
provider: Type.Optional(Type.String({ description: "Search provider: 'auto' (default), 'brave', 'perplexity', 'tavily', 'exa', 'gemini'" })),
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const CodeSearchParams = Type.Object({
|
|
718
|
+
query: Type.String({ description: "Programming question, API, library, or debugging topic to search for" }),
|
|
719
|
+
maxResults: Type.Optional(Type.Number({ description: "Max results to return (default: 10)" })),
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const FetchContentParams = Type.Object({
|
|
723
|
+
url: Type.Optional(Type.String({ description: "Single URL to fetch" })),
|
|
724
|
+
urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel)" })),
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const GetSearchContentParams = Type.Object({
|
|
728
|
+
responseId: Type.Optional(Type.String({ description: "Response ID from a previous web_search or fetch_content call" })),
|
|
729
|
+
url: Type.Optional(Type.String({ description: "URL to retrieve cached content for" })),
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// ===========================================================================
|
|
733
|
+
// SECTION 3 — Extension Entry Point
|
|
734
|
+
// ===========================================================================
|
|
735
|
+
|
|
736
|
+
export default function browserWebExtension(pi: ExtensionAPI) {
|
|
737
|
+
// ── Load config once — passed to all tools that need settings ──
|
|
738
|
+
const config = loadConfig();
|
|
739
|
+
|
|
740
|
+
// Append any user-defined providers from settings.json → browserExt.customProviders
|
|
741
|
+
const customImpls = (config.ext.customProviders ?? []).map(buildCustomProviderImpl);
|
|
742
|
+
if (customImpls.length > 0) {
|
|
743
|
+
PROVIDER_REGISTRY = [
|
|
744
|
+
braveProvider, tavilyProvider, exaProvider, geminiProvider, perplexityProvider,
|
|
745
|
+
...customImpls,
|
|
746
|
+
];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── Browser state ──
|
|
750
|
+
const browserState: BrowserState = {
|
|
751
|
+
engine: getEngine(),
|
|
752
|
+
headless: getHeadless(config), // BUG-3 fix: reads settings.headless
|
|
753
|
+
supervised: getSupervised(config),
|
|
754
|
+
sessionDir: "", // BUG-7 fix: lazy — created on first screenshot
|
|
755
|
+
viewport: getViewport(config),
|
|
756
|
+
browser: null,
|
|
757
|
+
context: null,
|
|
758
|
+
page: null,
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// =======================================================================
|
|
762
|
+
// BROWSER TOOLS
|
|
763
|
+
// =======================================================================
|
|
764
|
+
|
|
765
|
+
pi.registerTool({
|
|
766
|
+
name: "browser_navigate",
|
|
767
|
+
label: "Browser Navigate",
|
|
768
|
+
description: "Navigate the browser to a URL. Opens a new browser instance if none is running. Uses Firefox by default.",
|
|
769
|
+
promptSnippet: "Navigate browser to a URL",
|
|
770
|
+
promptGuidelines: ["Use browser_navigate to open a webpage. Always navigate before interacting with page elements."],
|
|
771
|
+
parameters: NavigateParams,
|
|
772
|
+
async execute(_toolCallId, params) {
|
|
773
|
+
try {
|
|
774
|
+
const page = await getPage(browserState);
|
|
775
|
+
const wait = (params.waitUntil as "load" | "domcontentloaded" | "networkidle") ?? "load";
|
|
776
|
+
await page.goto(params.url, { waitUntil: wait, timeout: params.timeout ?? 30000 });
|
|
777
|
+
const title = await page.title();
|
|
778
|
+
return await withSupervisedScreenshot(browserState, "navigate", { url: page.url(), title }, async () => ({
|
|
779
|
+
text: `Navigated to ${params.url}\nTitle: ${title}\nURL: ${page.url()}`,
|
|
780
|
+
}));
|
|
781
|
+
} catch (err) {
|
|
782
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
783
|
+
return toolError(`Navigation failed: ${msg}`, { url: params.url });
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
pi.registerTool({
|
|
789
|
+
name: "browser_click",
|
|
790
|
+
label: "Browser Click",
|
|
791
|
+
description: "Click an element on the page. Supports CSS selectors, text selectors (text=), role selectors, etc.",
|
|
792
|
+
promptSnippet: "Click a page element by selector",
|
|
793
|
+
promptGuidelines: ["Use browser_click after browser_navigate. Use browser_snapshot first to find the right selector."],
|
|
794
|
+
parameters: ClickParams,
|
|
795
|
+
async execute(_toolCallId, params) {
|
|
796
|
+
try {
|
|
797
|
+
const page = await getPage(browserState);
|
|
798
|
+
await page.click(params.selector, { timeout: params.timeout ?? 10000 });
|
|
799
|
+
return await withSupervisedScreenshot(browserState, "click", { selector: params.selector }, async () => ({
|
|
800
|
+
text: `Clicked: ${params.selector}`,
|
|
801
|
+
}));
|
|
802
|
+
} catch (err) {
|
|
803
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
804
|
+
return toolError(`Click failed on "${params.selector}": ${msg}`, { selector: params.selector });
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
pi.registerTool({
|
|
810
|
+
name: "browser_type",
|
|
811
|
+
label: "Browser Type",
|
|
812
|
+
description: "Type text into an input, textarea, or contenteditable element.",
|
|
813
|
+
promptSnippet: "Type text into a form field or input element",
|
|
814
|
+
promptGuidelines: ["Use browser_type to fill form fields."],
|
|
815
|
+
parameters: TypeParams,
|
|
816
|
+
async execute(_toolCallId, params) {
|
|
817
|
+
try {
|
|
818
|
+
const page = await getPage(browserState);
|
|
819
|
+
await page.fill(params.selector, "", { timeout: params.timeout ?? 10000 });
|
|
820
|
+
await page.type(params.selector, params.text, { delay: params.delay ?? 0, timeout: params.timeout ?? 10000 });
|
|
821
|
+
return await withSupervisedScreenshot(browserState, "type", { selector: params.selector, text: params.text }, async () => ({
|
|
822
|
+
text: `Typed into ${params.selector}: "${params.text}"`,
|
|
823
|
+
}));
|
|
824
|
+
} catch (err) {
|
|
825
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
826
|
+
return toolError(`Type failed on "${params.selector}": ${msg}`, { selector: params.selector });
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
pi.registerTool({
|
|
832
|
+
name: "browser_snapshot",
|
|
833
|
+
label: "Browser Snapshot",
|
|
834
|
+
description: "Get the accessibility tree snapshot of the current page. Shows the page structure with roles, names, and states. Use this to understand what's on the page and find selectors.",
|
|
835
|
+
promptSnippet: "Get page accessibility snapshot to see structure and find elements",
|
|
836
|
+
promptGuidelines: ["Use browser_snapshot to understand page structure and find element selectors before browser_click or browser_type."],
|
|
837
|
+
parameters: SnapshotParams,
|
|
838
|
+
async execute(_toolCallId, _params) {
|
|
839
|
+
try {
|
|
840
|
+
const page = await getPage(browserState);
|
|
841
|
+
let raw: string;
|
|
842
|
+
try {
|
|
843
|
+
raw = await page.locator("body").ariaSnapshot() ?? "(empty page)";
|
|
844
|
+
} catch {
|
|
845
|
+
raw = "(could not get page snapshot)";
|
|
846
|
+
}
|
|
847
|
+
return await withSupervisedScreenshot(browserState, "snapshot", { length: raw.length, url: page.url() }, async () => ({
|
|
848
|
+
text: raw.substring(0, 8000) + (raw.length > 8000 ? "\n... (truncated)" : ""),
|
|
849
|
+
}));
|
|
850
|
+
} catch (err) {
|
|
851
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
852
|
+
return toolError(`Snapshot failed: ${msg}`);
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
pi.registerTool({
|
|
858
|
+
name: "browser_screenshot",
|
|
859
|
+
label: "Browser Screenshot",
|
|
860
|
+
description: "Take a screenshot of the current page. Returns a base64-encoded PNG/JPEG image that the model can see.",
|
|
861
|
+
promptSnippet: "Take a screenshot of the current page",
|
|
862
|
+
promptGuidelines: ["Use browser_screenshot to visually inspect the current page state."],
|
|
863
|
+
parameters: ScreenshotParams,
|
|
864
|
+
async execute(_toolCallId, params) {
|
|
865
|
+
try {
|
|
866
|
+
const page = await getPage(browserState);
|
|
867
|
+
const format = (params.format as "png" | "jpeg") ?? "png";
|
|
868
|
+
// BUG-4 fix: use locator when selector provided — element-scoped screenshot
|
|
869
|
+
const target = params.selector
|
|
870
|
+
? page.locator(params.selector as string)
|
|
871
|
+
: page;
|
|
872
|
+
// fullPage only applies to full-page screenshots; Locator.screenshot() doesn't support it
|
|
873
|
+
const buffer = params.selector
|
|
874
|
+
? await target.screenshot({ type: format })
|
|
875
|
+
: await target.screenshot({ fullPage: params.fullPage ?? false, type: format });
|
|
876
|
+
const base64 = buffer.toString("base64");
|
|
877
|
+
return {
|
|
878
|
+
content: [
|
|
879
|
+
{ type: "text" as const, text: `Screenshot taken (${format}, ${buffer.length} bytes)${params.selector ? ` [selector: ${params.selector}]` : ""}` },
|
|
880
|
+
{ type: "image" as const, source: { type: "base64" as const, mediaType: `image/${format}`, data: base64 } },
|
|
881
|
+
],
|
|
882
|
+
details: { format, size: buffer.length, url: page.url(), selector: (params.selector as string) ?? null },
|
|
883
|
+
};
|
|
884
|
+
} catch (err) {
|
|
885
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
886
|
+
return toolError(`Screenshot failed: ${msg}`, { selector: params.selector as string | undefined });
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
pi.registerTool({
|
|
892
|
+
name: "browser_evaluate",
|
|
893
|
+
label: "Browser Evaluate",
|
|
894
|
+
description: "Run JavaScript code in the page context and return the result. Use to extract data, manipulate the DOM, or query values.",
|
|
895
|
+
promptSnippet: "Run JavaScript in the current page",
|
|
896
|
+
promptGuidelines: ["Use browser_evaluate to extract data from the page, check state, or perform custom DOM operations."],
|
|
897
|
+
parameters: EvaluateParams,
|
|
898
|
+
async execute(_toolCallId, params) {
|
|
899
|
+
try {
|
|
900
|
+
const page = await getPage(browserState);
|
|
901
|
+
const result = await page.evaluate(params.script);
|
|
902
|
+
const output = typeof result === "object" ? JSON.stringify(result, null, 2) : String(result);
|
|
903
|
+
return await withSupervisedScreenshot(browserState, "evaluate", { resultType: typeof result }, async () => ({
|
|
904
|
+
text: output.substring(0, 8000) + (output.length > 8000 ? "\n... (truncated)" : ""),
|
|
905
|
+
}));
|
|
906
|
+
} catch (err) {
|
|
907
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
908
|
+
return toolError(`Evaluate failed: ${msg}`);
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
pi.registerTool({
|
|
914
|
+
name: "browser_wait",
|
|
915
|
+
label: "Browser Wait",
|
|
916
|
+
description: "Wait for an element to appear/disappear or wait for a timeout. Useful after clicks that cause page transitions.",
|
|
917
|
+
promptSnippet: "Wait for an element or a timeout before proceeding",
|
|
918
|
+
promptGuidelines: ["Use browser_wait after actions that cause page changes (clicks, form submissions)."],
|
|
919
|
+
parameters: WaitParams,
|
|
920
|
+
async execute(_toolCallId, params) {
|
|
921
|
+
try {
|
|
922
|
+
const page = await getPage(browserState);
|
|
923
|
+
if (params.selector) {
|
|
924
|
+
const waitState = (params.state as "attached" | "visible" | "hidden" | "detached") || "visible";
|
|
925
|
+
await page.waitForSelector(params.selector, { state: waitState, timeout: params.timeout || 10000 });
|
|
926
|
+
return { content: [{ type: "text" as const, text: `Element "${params.selector}" reached state "${waitState}"` }], details: { selector: params.selector, state: waitState } };
|
|
927
|
+
} else if (params.timeout) {
|
|
928
|
+
await page.waitForTimeout(params.timeout);
|
|
929
|
+
return { content: [{ type: "text" as const, text: `Waited ${params.timeout}ms` }], details: { timeout: params.timeout } };
|
|
930
|
+
}
|
|
931
|
+
return { content: [{ type: "text" as const, text: "Nothing to wait for" }], details: {} };
|
|
932
|
+
} catch (err) {
|
|
933
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
934
|
+
return { content: [{ type: "text" as const, text: `Wait failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
pi.registerTool({
|
|
940
|
+
name: "browser_tab_list",
|
|
941
|
+
label: "Browser Tab List",
|
|
942
|
+
description: "List all open tabs/pages with their index, URL, and title.",
|
|
943
|
+
promptSnippet: "List all open browser tabs",
|
|
944
|
+
promptGuidelines: ["Use browser_tab_list to see all open tabs when working with multiple pages."],
|
|
945
|
+
parameters: Type.Object({}),
|
|
946
|
+
async execute() {
|
|
947
|
+
try {
|
|
948
|
+
if (!browserState.context) return { content: [{ type: "text" as const, text: "No browser session open" }], details: {} };
|
|
949
|
+
const pages = browserState.context.pages();
|
|
950
|
+
const tabs = await Promise.all(pages.map(async (p, i) => ({
|
|
951
|
+
index: i, url: p.url(), title: await p.title().catch(() => "(loading)"),
|
|
952
|
+
})));
|
|
953
|
+
const lines = tabs.map(t => `[${t.index}] ${t.title}\n ${t.url}`);
|
|
954
|
+
return { content: [{ type: "text" as const, text: tabs.length ? lines.join("\n") : "No tabs open" }], details: { tabCount: tabs.length, tabs } };
|
|
955
|
+
} catch (err) {
|
|
956
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
957
|
+
return { content: [{ type: "text" as const, text: `Tab list failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
958
|
+
}
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
pi.registerTool({
|
|
963
|
+
name: "browser_tab_new",
|
|
964
|
+
label: "Browser Tab New",
|
|
965
|
+
description: "Open a new tab and switch to it.",
|
|
966
|
+
promptSnippet: "Open a new browser tab",
|
|
967
|
+
promptGuidelines: [],
|
|
968
|
+
parameters: TabNewParams,
|
|
969
|
+
async execute(_toolCallId, params) {
|
|
970
|
+
try {
|
|
971
|
+
await getPage(browserState);
|
|
972
|
+
const newPage = await browserState.context!.newPage();
|
|
973
|
+
if (params.url) await newPage.goto(params.url, { waitUntil: "load", timeout: 30000 });
|
|
974
|
+
browserState.page = newPage;
|
|
975
|
+
const idx = browserState.context!.pages().length - 1;
|
|
976
|
+
return { content: [{ type: "text" as const, text: `Opened new tab [${idx}]${params.url ? `: ${params.url}` : ""}` }], details: { index: idx, url: params.url } };
|
|
977
|
+
} catch (err) {
|
|
978
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
979
|
+
return { content: [{ type: "text" as const, text: `New tab failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
pi.registerTool({
|
|
985
|
+
name: "browser_tab_close",
|
|
986
|
+
label: "Browser Tab Close",
|
|
987
|
+
description: "Close the current tab. Switches to another tab if available.",
|
|
988
|
+
promptSnippet: "Close the current browser tab",
|
|
989
|
+
promptGuidelines: [],
|
|
990
|
+
parameters: Type.Object({}),
|
|
991
|
+
async execute() {
|
|
992
|
+
try {
|
|
993
|
+
const pages = browserState.context?.pages() ?? [];
|
|
994
|
+
if (pages.length <= 1) {
|
|
995
|
+
await closeBrowser(browserState);
|
|
996
|
+
return { content: [{ type: "text" as const, text: "Closed the last tab. Browser session ended." }], details: {} };
|
|
997
|
+
}
|
|
998
|
+
if (!browserState.page) return toolError("No active page to close");
|
|
999
|
+
const currentIdx = pages.indexOf(browserState.page);
|
|
1000
|
+
await browserState.page.close();
|
|
1001
|
+
// Re-fetch pages after close — stale snapshot would reference the closed page
|
|
1002
|
+
const remaining = browserState.context!.pages();
|
|
1003
|
+
const newIdx = Math.max(0, Math.min(currentIdx - 1, remaining.length - 1));
|
|
1004
|
+
browserState.page = remaining[newIdx] ?? remaining[0];
|
|
1005
|
+
return {
|
|
1006
|
+
content: [{ type: "text" as const, text: `Closed tab. Switched to tab [${newIdx}]: ${browserState.page?.url()}` }],
|
|
1007
|
+
details: { newIndex: newIdx },
|
|
1008
|
+
};
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1011
|
+
return toolError(`Close tab failed: ${msg}`);
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
pi.registerTool({
|
|
1017
|
+
name: "browser_tab_select",
|
|
1018
|
+
label: "Browser Tab Select",
|
|
1019
|
+
description: "Switch to a specific tab by its index.",
|
|
1020
|
+
promptSnippet: "Switch to a specific browser tab by index",
|
|
1021
|
+
promptGuidelines: [],
|
|
1022
|
+
parameters: TabSelectParams,
|
|
1023
|
+
async execute(_toolCallId, params) {
|
|
1024
|
+
try {
|
|
1025
|
+
const pages = browserState.context?.pages() || [];
|
|
1026
|
+
if (params.index < 0 || params.index >= pages.length) {
|
|
1027
|
+
return { content: [{ type: "text" as const, text: `Tab index ${params.index} out of range. ${pages.length} tabs open (0-${pages.length - 1})` }], details: { error: "out of range" }, isError: true };
|
|
1028
|
+
}
|
|
1029
|
+
browserState.page = pages[params.index];
|
|
1030
|
+
return { content: [{ type: "text" as const, text: `Switched to tab [${params.index}]: ${(await browserState.page?.title()) || "(loading)"} - ${browserState.page?.url()}` }], details: { index: params.index } };
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1033
|
+
return { content: [{ type: "text" as const, text: `Tab select failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
pi.registerTool({
|
|
1039
|
+
name: "browser_back",
|
|
1040
|
+
label: "Browser Back",
|
|
1041
|
+
description: "Navigate back in browser history.",
|
|
1042
|
+
promptSnippet: "Go back in browser history",
|
|
1043
|
+
promptGuidelines: [],
|
|
1044
|
+
parameters: Type.Object({}),
|
|
1045
|
+
async execute() {
|
|
1046
|
+
try {
|
|
1047
|
+
const page = await getPage(browserState);
|
|
1048
|
+
await page.goBack({ waitUntil: "load", timeout: 15000 });
|
|
1049
|
+
return { content: [{ type: "text" as const, text: `Went back → ${page.url()}` }], details: { url: page.url() } };
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1052
|
+
return { content: [{ type: "text" as const, text: `Back failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
pi.registerTool({
|
|
1058
|
+
name: "browser_forward",
|
|
1059
|
+
label: "Browser Forward",
|
|
1060
|
+
description: "Navigate forward in browser history.",
|
|
1061
|
+
promptSnippet: "Go forward in browser history",
|
|
1062
|
+
promptGuidelines: [],
|
|
1063
|
+
parameters: Type.Object({}),
|
|
1064
|
+
async execute() {
|
|
1065
|
+
try {
|
|
1066
|
+
const page = await getPage(browserState);
|
|
1067
|
+
await page.goForward({ waitUntil: "load", timeout: 15000 });
|
|
1068
|
+
return { content: [{ type: "text" as const, text: `Went forward → ${page.url()}` }], details: { url: page.url() } };
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1071
|
+
return { content: [{ type: "text" as const, text: `Forward failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
pi.registerTool({
|
|
1077
|
+
name: "browser_reload",
|
|
1078
|
+
label: "Browser Reload",
|
|
1079
|
+
description: "Reload the current page.",
|
|
1080
|
+
promptSnippet: "Reload the current page",
|
|
1081
|
+
promptGuidelines: [],
|
|
1082
|
+
parameters: Type.Object({}),
|
|
1083
|
+
async execute() {
|
|
1084
|
+
try {
|
|
1085
|
+
const page = await getPage(browserState);
|
|
1086
|
+
await page.reload({ waitUntil: "load", timeout: 30000 });
|
|
1087
|
+
return { content: [{ type: "text" as const, text: `Reloaded: ${page.url()}` }], details: { url: page.url() } };
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1090
|
+
return { content: [{ type: "text" as const, text: `Reload failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
pi.registerTool({
|
|
1096
|
+
name: "browser_close",
|
|
1097
|
+
label: "Browser Close",
|
|
1098
|
+
description: "Close the entire browser session and all tabs.",
|
|
1099
|
+
promptSnippet: "Close the browser",
|
|
1100
|
+
promptGuidelines: [],
|
|
1101
|
+
parameters: Type.Object({}),
|
|
1102
|
+
async execute() {
|
|
1103
|
+
await closeBrowser(browserState);
|
|
1104
|
+
return { content: [{ type: "text" as const, text: "Browser closed." }], details: {} };
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// =======================================================================
|
|
1109
|
+
// WEB SEARCH TOOLS
|
|
1110
|
+
// =======================================================================
|
|
1111
|
+
|
|
1112
|
+
pi.registerTool({
|
|
1113
|
+
name: "web_search",
|
|
1114
|
+
label: "Web Search",
|
|
1115
|
+
description:
|
|
1116
|
+
"Search the web using Brave, Perplexity, Tavily, Exa, or Gemini. Returns an AI-synthesized answer with source citations. " +
|
|
1117
|
+
"For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query. " +
|
|
1118
|
+
"Provider auto-selects from configured API keys. " +
|
|
1119
|
+
"Run /search for the setup wizard, or set a key via env var or settings.json.",
|
|
1120
|
+
promptSnippet: "Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query.",
|
|
1121
|
+
parameters: WebSearchParams,
|
|
1122
|
+
async execute(_toolCallId, params) {
|
|
1123
|
+
const cfg = loadConfig();
|
|
1124
|
+
const queries = params.queries?.length ? params.queries : (params.query ? [params.query] : []);
|
|
1125
|
+
if (!queries.length) {
|
|
1126
|
+
return toolError("No query provided. Use 'query' or 'queries' parameter.");
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const numResults = (params.numResults as number) ?? 5;
|
|
1130
|
+
|
|
1131
|
+
// EFF-3 fix: performSearch is now async (native fetch), so Promise.all is truly parallel
|
|
1132
|
+
const allResults = await Promise.all(
|
|
1133
|
+
queries.map(q => performSearch(
|
|
1134
|
+
q, numResults,
|
|
1135
|
+
params.provider as string | undefined,
|
|
1136
|
+
params.recencyFilter as string | undefined,
|
|
1137
|
+
params.domainFilter as string[] | undefined,
|
|
1138
|
+
cfg,
|
|
1139
|
+
))
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
// Format output
|
|
1143
|
+
let output = "";
|
|
1144
|
+
for (let i = 0; i < allResults.length; i++) {
|
|
1145
|
+
const r = allResults[i];
|
|
1146
|
+
if (allResults.length > 1) output += `## Query: "${queries[i]}" [${r.provider}]\n\n`;
|
|
1147
|
+
if (r.answer) output += `${r.answer}\n\n`;
|
|
1148
|
+
if (r.results.length) {
|
|
1149
|
+
output += "---\n**Sources:**\n";
|
|
1150
|
+
for (let j = 0; j < r.results.length; j++) {
|
|
1151
|
+
const s = r.results[j];
|
|
1152
|
+
output += `${j + 1}. ${s.title}\n ${s.url}\n`;
|
|
1153
|
+
if (s.snippet) output += ` ${s.snippet}\n`;
|
|
1154
|
+
}
|
|
1155
|
+
output += "\n";
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Cache results
|
|
1160
|
+
const responseId = cacheKey("search", queries.join("|"));
|
|
1161
|
+
storeCache(responseId, {
|
|
1162
|
+
type: "search",
|
|
1163
|
+
queries: allResults.map((r, i) => ({ query: queries[i], ...r })),
|
|
1164
|
+
timestamp: Date.now(),
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// Background content fetch — genuinely parallel now that fetchUrlContent is async
|
|
1168
|
+
const topUrls = allResults.flatMap(r => r.results.slice(0, 3).map(s => s.url));
|
|
1169
|
+
if ((params.includeContent as boolean) && topUrls.length) {
|
|
1170
|
+
output += `---\nCaching content for ${topUrls.length} URLs in background...\n`;
|
|
1171
|
+
Promise.all(topUrls.map(u => fetchUrlContent(u))).then(contents => {
|
|
1172
|
+
const fetchId = cacheKey("fetch", topUrls.join("|"));
|
|
1173
|
+
storeCache(fetchId, { type: "fetch", urls: contents, timestamp: Date.now() });
|
|
1174
|
+
}).catch(() => { /* background errors ignored */ });
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
content: [{ type: "text" as const, text: output.trim() }],
|
|
1179
|
+
details: {
|
|
1180
|
+
queries,
|
|
1181
|
+
queryCount: queries.length,
|
|
1182
|
+
provider: allResults[0]?.provider ?? "none",
|
|
1183
|
+
responseId,
|
|
1184
|
+
totalResults: allResults.reduce((sum, r) => sum + r.results.length, 0),
|
|
1185
|
+
debugInfo: allResults[0]?.debugInfo,
|
|
1186
|
+
},
|
|
1187
|
+
};
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
pi.registerTool({
|
|
1192
|
+
name: "code_search",
|
|
1193
|
+
label: "Code Search",
|
|
1194
|
+
description:
|
|
1195
|
+
"Search for code examples, documentation, and API references. " +
|
|
1196
|
+
"Uses Exa's code search when EXA_API_KEY is set, otherwise falls back to web_search. " +
|
|
1197
|
+
"Returns relevant code snippets and docs from GitHub, Stack Overflow, and official documentation. " +
|
|
1198
|
+
"Use for any programming question — API usage, library examples, debugging help.",
|
|
1199
|
+
promptSnippet: "Use for programming/API/library questions to retrieve concrete examples and docs.",
|
|
1200
|
+
parameters: CodeSearchParams,
|
|
1201
|
+
async execute(_toolCallId, params) {
|
|
1202
|
+
const cfg = loadConfig();
|
|
1203
|
+
const maxResults = (params.maxResults as number) ?? 10;
|
|
1204
|
+
if (!params.query) {
|
|
1205
|
+
return toolError("No query provided.");
|
|
1206
|
+
}
|
|
1207
|
+
const { providers } = getActiveConfig(cfg);
|
|
1208
|
+
const hasExa = providers.some(p => p.id === "exa");
|
|
1209
|
+
|
|
1210
|
+
if (hasExa) {
|
|
1211
|
+
try {
|
|
1212
|
+
const r = await exaProvider.search(params.query as string, maxResults, cfg);
|
|
1213
|
+
let output = "";
|
|
1214
|
+
if (r.answer) output += `${r.answer}\n\n`;
|
|
1215
|
+
if (r.results.length) output += "**Code Sources:**\n";
|
|
1216
|
+
for (let i = 0; i < r.results.length; i++) {
|
|
1217
|
+
const s = r.results[i];
|
|
1218
|
+
output += `${i + 1}. ${s.title}\n ${s.url}\n`;
|
|
1219
|
+
if (s.snippet) output += ` \`\`\`\n ${s.snippet.substring(0, 500)}\n \`\`\`\n`;
|
|
1220
|
+
}
|
|
1221
|
+
const responseId = cacheKey("codesearch", params.query as string);
|
|
1222
|
+
storeCache(responseId, { type: "codesearch", query: params.query, results: r.results, timestamp: Date.now() });
|
|
1223
|
+
return {
|
|
1224
|
+
content: [{ type: "text" as const, text: output }],
|
|
1225
|
+
details: { query: params.query, resultCount: r.results.length, provider: "exa", responseId },
|
|
1226
|
+
};
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1229
|
+
return toolError(`Code search failed: ${msg}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Fallback: web_search with code-focused query
|
|
1234
|
+
const codeQuery = `${params.query} code examples documentation GitHub Stack Overflow official docs`;
|
|
1235
|
+
const r = await performSearch(codeQuery, maxResults, undefined, undefined, undefined, cfg);
|
|
1236
|
+
let output = r.answer ? `${r.answer}\n\n` : "";
|
|
1237
|
+
if (r.results.length) output += "**Sources:**\n";
|
|
1238
|
+
for (let i = 0; i < r.results.length; i++) {
|
|
1239
|
+
const s = r.results[i];
|
|
1240
|
+
output += `${i + 1}. ${s.title}\n ${s.url}\n`;
|
|
1241
|
+
if (s.snippet) output += ` ${s.snippet}\n`;
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
content: [{ type: "text" as const, text: output }],
|
|
1245
|
+
details: { query: params.query, resultCount: r.results.length, provider: r.provider, fallback: true },
|
|
1246
|
+
};
|
|
1247
|
+
},
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
pi.registerTool({
|
|
1251
|
+
name: "fetch_content",
|
|
1252
|
+
label: "Fetch Content",
|
|
1253
|
+
description:
|
|
1254
|
+
"Fetch URL(s) and extract readable content as markdown/text from HTML pages. " +
|
|
1255
|
+
"Content is cached locally and can be retrieved with get_search_content.",
|
|
1256
|
+
promptSnippet: "Use to extract readable content from URL(s).",
|
|
1257
|
+
parameters: FetchContentParams,
|
|
1258
|
+
async execute(_toolCallId, params) {
|
|
1259
|
+
const urls = params.urls?.length ? params.urls : (params.url ? [params.url] : []);
|
|
1260
|
+
if (!urls.length) {
|
|
1261
|
+
return { content: [{ type: "text" as const, text: "Error: No URL provided." }], details: { error: "No URL provided" }, isError: true };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const results = await Promise.all(urls.map(u => fetchUrlContent(u)));
|
|
1265
|
+
// Store with a deterministic URL-based key so get_search_content can find it
|
|
1266
|
+
for (let i = 0; i < results.length; i++) {
|
|
1267
|
+
const urlKey = urlToCacheKey(urls[i]);
|
|
1268
|
+
storeCache(`fetch_${urlKey}`, { type: "fetch", urls: [results[i]], url: urls[i], timestamp: Date.now() });
|
|
1269
|
+
}
|
|
1270
|
+
// Also store multi-URL batch under a combined key
|
|
1271
|
+
const combinedKey = cacheKey("fetch", urls.join("|"));
|
|
1272
|
+
storeCache(combinedKey, { type: "fetch", urls: results, timestamp: Date.now() });
|
|
1273
|
+
|
|
1274
|
+
if (urls.length === 1) {
|
|
1275
|
+
const r = results[0];
|
|
1276
|
+
if (r.error) {
|
|
1277
|
+
return { content: [{ type: "text" as const, text: `Error: ${r.error}` }], details: { error: r.error, url: urls[0] }, isError: true };
|
|
1278
|
+
}
|
|
1279
|
+
const truncated = r.content.length > 30000;
|
|
1280
|
+
const output = truncated ? r.content.substring(0, 30000) + "\n\n[Content truncated...]" : r.content;
|
|
1281
|
+
const urlKey = `fetch_${urlToCacheKey(urls[0])}`;
|
|
1282
|
+
return {
|
|
1283
|
+
content: [{ type: "text" as const, text: `# ${r.title}\n\n${output}\n\n[Cached as: ${urlKey}]` }],
|
|
1284
|
+
details: { url: urls[0], title: r.title, chars: r.content.length, truncated, responseId: urlKey },
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Multiple URLs
|
|
1289
|
+
const responseId = combinedKey;
|
|
1290
|
+
let output = `# Fetched ${urls.length} URLs\n\n`;
|
|
1291
|
+
for (let i = 0; i < results.length; i++) {
|
|
1292
|
+
const r = results[i];
|
|
1293
|
+
output += `## [${i}] ${r.title}\nURL: ${urls[i]}\n`;
|
|
1294
|
+
if (r.error) {
|
|
1295
|
+
output += `Error: ${r.error}\n\n`;
|
|
1296
|
+
} else {
|
|
1297
|
+
const truncated = r.content.length > 10000;
|
|
1298
|
+
output += truncated ? r.content.substring(0, 10000) + "\n[truncated]\n\n" : r.content + "\n\n";
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return {
|
|
1303
|
+
content: [{ type: "text" as const, text: output }],
|
|
1304
|
+
details: { urls, urlCount: urls.length, successful: results.filter(r => !r.error).length, responseId },
|
|
1305
|
+
};
|
|
1306
|
+
},
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
pi.registerTool({
|
|
1310
|
+
name: "get_search_content",
|
|
1311
|
+
label: "Get Search Content",
|
|
1312
|
+
description: "Retrieve full content from a previous web_search, code_search, or fetch_content call using its responseId or URL.",
|
|
1313
|
+
promptSnippet: "Use after web_search/fetch_content when full stored content is needed via responseId or URL.",
|
|
1314
|
+
parameters: GetSearchContentParams,
|
|
1315
|
+
async execute(_toolCallId, params) {
|
|
1316
|
+
let data: Record<string, unknown> | null = null;
|
|
1317
|
+
|
|
1318
|
+
if (params.responseId) {
|
|
1319
|
+
data = loadCache(params.responseId);
|
|
1320
|
+
}
|
|
1321
|
+
if (!data && params.url) {
|
|
1322
|
+
const urlKey = `fetch_${urlToCacheKey(params.url)}`;
|
|
1323
|
+
data = loadCache(urlKey);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (!data) {
|
|
1327
|
+
return {
|
|
1328
|
+
content: [{ type: "text" as const, text: "No cached content found. Run web_search or fetch_content first, then pass the URL to get_search_content." }],
|
|
1329
|
+
details: { error: "Not found" },
|
|
1330
|
+
isError: true,
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (data.type === "search") {
|
|
1335
|
+
const queries = data.queries as Array<{ query: string; answer: string; results: Array<{ title: string; url: string; snippet: string }> }>;
|
|
1336
|
+
let output = "";
|
|
1337
|
+
for (const q of queries) {
|
|
1338
|
+
output += `## Query: "${q.query}"\n\n`;
|
|
1339
|
+
if (q.answer) output += `${q.answer}\n\n`;
|
|
1340
|
+
for (const r of q.results) {
|
|
1341
|
+
output += `### ${r.title}\n${r.url}\n\n`;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return { content: [{ type: "text" as const, text: output }], details: { type: "search", queryCount: queries.length } };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (data.type === "fetch") {
|
|
1348
|
+
const urls = data.urls as Array<{ title: string; content: string; error?: string }>;
|
|
1349
|
+
let output = "";
|
|
1350
|
+
for (const u of urls) {
|
|
1351
|
+
output += `# ${u.title}\n\n`;
|
|
1352
|
+
if (u.error) output += `Error: ${u.error}\n`;
|
|
1353
|
+
else output += u.content;
|
|
1354
|
+
output += "\n\n";
|
|
1355
|
+
}
|
|
1356
|
+
return { content: [{ type: "text" as const, text: output.substring(0, 50000) }], details: { type: "fetch", urlCount: urls.length } };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (data.type === "codesearch") {
|
|
1360
|
+
const results = data.results as Array<{ title: string; url: string; snippet: string }>;
|
|
1361
|
+
let output = "";
|
|
1362
|
+
for (let i = 0; i < results.length; i++) {
|
|
1363
|
+
const r = results[i];
|
|
1364
|
+
output += `${i + 1}. ${r.title}\n ${r.url}\n`;
|
|
1365
|
+
if (r.snippet) output += ` ${r.snippet}\n`;
|
|
1366
|
+
output += "\n";
|
|
1367
|
+
}
|
|
1368
|
+
return { content: [{ type: "text" as const, text: output }], details: { type: "codesearch", resultCount: results.length } };
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
return { content: [{ type: "text" as const, text: "Unknown cache type." }], details: { error: "Unknown type" }, isError: true };
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
// =======================================================================
|
|
1376
|
+
// COMMANDS
|
|
1377
|
+
// =======================================================================
|
|
1378
|
+
|
|
1379
|
+
async function runProviderSwitch(
|
|
1380
|
+
ctx: ExtensionContext,
|
|
1381
|
+
providers: ProviderImpl[],
|
|
1382
|
+
): Promise<void> {
|
|
1383
|
+
const newProvider = await ctx.ui.select(
|
|
1384
|
+
"Switch active provider to:",
|
|
1385
|
+
providers.map(p => ({
|
|
1386
|
+
value: p.id,
|
|
1387
|
+
label: p.label,
|
|
1388
|
+
description: p.freeTier !== "—" ? `Free: ${p.freeTier}` : "Paid",
|
|
1389
|
+
})),
|
|
1390
|
+
);
|
|
1391
|
+
if (!newProvider || newProvider === "cancel") return;
|
|
1392
|
+
|
|
1393
|
+
// Load current config for comparison
|
|
1394
|
+
const currentCfg = loadConfig();
|
|
1395
|
+
if (newProvider === currentCfg.ext.activeProvider) {
|
|
1396
|
+
ctx.ui.notify("Already using that provider.", "info");
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
patchExtConfig({ activeProvider: newProvider as ProviderId });
|
|
1401
|
+
ctx.ui.notify(
|
|
1402
|
+
`Active provider switched to ${PROVIDER_REGISTRY.find(p => p.id === newProvider)?.label}. Run /reload to apply.`,
|
|
1403
|
+
"info",
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async function runProviderAdd(
|
|
1408
|
+
ctx: ExtensionContext,
|
|
1409
|
+
providers: ProviderImpl[],
|
|
1410
|
+
): Promise<void> {
|
|
1411
|
+
const unconfigured = PROVIDER_REGISTRY.filter(p => !providers.some(c => c.id === p.id));
|
|
1412
|
+
if (!unconfigured.length) {
|
|
1413
|
+
ctx.ui.notify("All providers are already configured.", "info");
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const provider = await ctx.ui.select(
|
|
1417
|
+
"Add provider:",
|
|
1418
|
+
unconfigured.map(p => ({
|
|
1419
|
+
value: p.id,
|
|
1420
|
+
label: p.label,
|
|
1421
|
+
description: p.freeTier !== "—" ? `Free: ${p.freeTier}` : "Paid",
|
|
1422
|
+
})),
|
|
1423
|
+
);
|
|
1424
|
+
if (provider) {
|
|
1425
|
+
const def = PROVIDER_REGISTRY.find(p => p.id === provider)!;
|
|
1426
|
+
ctx.ui.notify(`To add ${def.label}:`, "info");
|
|
1427
|
+
ctx.ui.notify(`1. Get a key: ${def.signupUrl}`, "info");
|
|
1428
|
+
ctx.ui.notify(` Shell: export ${def.envKey}="your-key-here"`, "info");
|
|
1429
|
+
ctx.ui.notify(` Pi settings: ~/.pi/agent/settings.json → { "browserExt": { "${def.envKey}": "..." } }`, "info");
|
|
1430
|
+
ctx.ui.notify(`2. Run /reload to activate.`, "info");
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function runProviderRemove(
|
|
1435
|
+
ctx: ExtensionContext,
|
|
1436
|
+
cfg: Config,
|
|
1437
|
+
providers: ProviderImpl[],
|
|
1438
|
+
): Promise<void> {
|
|
1439
|
+
const toRemove = await ctx.ui.select(
|
|
1440
|
+
"Remove provider:",
|
|
1441
|
+
providers.map(p => ({ value: p.id, label: p.label })),
|
|
1442
|
+
);
|
|
1443
|
+
if (toRemove) {
|
|
1444
|
+
const def = PROVIDER_REGISTRY.find(p => p.id === toRemove)!;
|
|
1445
|
+
const remaining = providers.filter(p => p.id !== toRemove);
|
|
1446
|
+
const patch: Partial<ExtConfig> = { [def.envKey]: undefined } as Partial<ExtConfig>;
|
|
1447
|
+
if (cfg.ext.activeProvider === toRemove) {
|
|
1448
|
+
patch.activeProvider = remaining[0]?.id as ProviderId | undefined;
|
|
1449
|
+
}
|
|
1450
|
+
patchExtConfig(patch);
|
|
1451
|
+
ctx.ui.notify(`${def.label} removed. Run /reload to apply.`, "info");
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
interface ProviderMeta {
|
|
1456
|
+
id: string;
|
|
1457
|
+
label: string;
|
|
1458
|
+
envKey: string;
|
|
1459
|
+
freeTier: string;
|
|
1460
|
+
signupUrl: string;
|
|
1461
|
+
apiKey: string;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function runCollectProviderMeta(ctx: Parameters<typeof runProviderSwitch>[0]): Promise<ProviderMeta | null> {
|
|
1465
|
+
ctx.ui.notify("── Step 1 of 3: Provider details ──", "info");
|
|
1466
|
+
ctx.ui.notify(
|
|
1467
|
+
"You will be asked to select from options for each field.\n" +
|
|
1468
|
+
"For free-text values (name, URL, key), pick the matching option and enter your value when prompted.",
|
|
1469
|
+
"info",
|
|
1470
|
+
);
|
|
1471
|
+
|
|
1472
|
+
const label = await ctx.ui.select("Provider display name — pick the closest match or 'Other' to type:", [
|
|
1473
|
+
{ value: "SerpAPI", label: "SerpAPI", description: "Google results via SerpAPI" },
|
|
1474
|
+
{ value: "Bing Web Search", label: "Bing Web Search", description: "Microsoft Bing" },
|
|
1475
|
+
{ value: "You.com", label: "You.com", description: "You.com search" },
|
|
1476
|
+
{ value: "Kagi", label: "Kagi", description: "Kagi search (paid)" },
|
|
1477
|
+
{ value: "custom", label: "Other — I'll type it", description: "Enter a custom name" },
|
|
1478
|
+
]);
|
|
1479
|
+
if (!label) return null;
|
|
1480
|
+
|
|
1481
|
+
const id = (label === "custom" ? "custom" : label.toLowerCase().replace(/[^a-z0-9]/g, "-"));
|
|
1482
|
+
const envKey = (label === "custom" ? "CUSTOM_SEARCH_KEY" : label.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_KEY");
|
|
1483
|
+
|
|
1484
|
+
ctx.ui.notify(`Provider ID will be: ${id}`, "info");
|
|
1485
|
+
ctx.ui.notify(`Env key name will be: ${envKey}`, "info");
|
|
1486
|
+
ctx.ui.notify(
|
|
1487
|
+
`Store your API key in settings.json now:\n` +
|
|
1488
|
+
` Edit ~/.pi/agent/settings.json → add under "browserExt":\n` +
|
|
1489
|
+
` "${envKey}": "your-key-here"`,
|
|
1490
|
+
"info",
|
|
1491
|
+
);
|
|
1492
|
+
|
|
1493
|
+
const keyConfirm = await ctx.ui.select("Have you added your API key to settings.json?", [
|
|
1494
|
+
{ value: "yes", label: "Yes — key is saved", description: "Continue to transform step" },
|
|
1495
|
+
{ value: "no", label: "No — cancel", description: "Exit wizard" },
|
|
1496
|
+
]);
|
|
1497
|
+
if (keyConfirm !== "yes") return null;
|
|
1498
|
+
|
|
1499
|
+
const signupUrl = await ctx.ui.select("Signup / docs URL:", [
|
|
1500
|
+
{ value: "https://serpapi.com/", label: "SerpAPI — https://serpapi.com/" },
|
|
1501
|
+
{ value: "https://www.microsoft.com/en-us/bing/apis/bing-web-search-api", label: "Bing — microsoft.com" },
|
|
1502
|
+
{ value: "https://you.com/", label: "You.com — https://you.com/" },
|
|
1503
|
+
{ value: "https://kagi.com/", label: "Kagi — https://kagi.com/" },
|
|
1504
|
+
{ value: "unknown", label: "Other / unknown" },
|
|
1505
|
+
]);
|
|
1506
|
+
|
|
1507
|
+
const freeTier = await ctx.ui.select("Free tier:", [
|
|
1508
|
+
{ value: "100/mo", label: "100 queries/mo" },
|
|
1509
|
+
{ value: "1,000/mo", label: "1,000 queries/mo" },
|
|
1510
|
+
{ value: "paid", label: "Paid only" },
|
|
1511
|
+
{ value: "free", label: "Free" },
|
|
1512
|
+
]);
|
|
1513
|
+
|
|
1514
|
+
return {
|
|
1515
|
+
id,
|
|
1516
|
+
label: label === "custom" ? id : label,
|
|
1517
|
+
envKey,
|
|
1518
|
+
freeTier: freeTier ?? "—",
|
|
1519
|
+
signupUrl: signupUrl ?? "—",
|
|
1520
|
+
apiKey: "",
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const TRANSFORM_DRAFT_PATH = join(homedir(), ".pi", "web-cache", "custom-provider-draft.js");
|
|
1525
|
+
|
|
1526
|
+
async function runCollectTransform(
|
|
1527
|
+
ctx: Parameters<typeof runProviderSwitch>[0],
|
|
1528
|
+
meta: ProviderMeta,
|
|
1529
|
+
): Promise<string | null> {
|
|
1530
|
+
ctx.ui.notify("── Step 2 of 3: Write the transform ──", "info");
|
|
1531
|
+
|
|
1532
|
+
const template =
|
|
1533
|
+
`// Transform for ${meta.label}\n` +
|
|
1534
|
+
`// Signature: async function(query, n, apiKey, fetchJson)\n` +
|
|
1535
|
+
`// Return: { answer: string, results: Array<{ title, url, snippet }> }\n` +
|
|
1536
|
+
`//\n` +
|
|
1537
|
+
`// 'fetchJson' is provided — use it for all HTTP calls (handles timeout + errors).\n` +
|
|
1538
|
+
`// Example fetchJson call:\n` +
|
|
1539
|
+
`// const data = await fetchJson("https://api.example.com/search?q=" + encodeURIComponent(query), {\n` +
|
|
1540
|
+
`// headers: { "Authorization": "Bearer " + apiKey }\n` +
|
|
1541
|
+
`// });\n\n` +
|
|
1542
|
+
`async function(query, n, apiKey, fetchJson) {\n` +
|
|
1543
|
+
` const data = await fetchJson("YOUR_ENDPOINT_URL?q=" + encodeURIComponent(query), {\n` +
|
|
1544
|
+
` headers: { "Authorization": "Bearer " + apiKey }\n` +
|
|
1545
|
+
` });\n` +
|
|
1546
|
+
` return {\n` +
|
|
1547
|
+
` answer: data.YOUR_ANSWER_FIELD || "",\n` +
|
|
1548
|
+
` results: (data.YOUR_RESULTS_ARRAY || []).slice(0, n).map(r => ({\n` +
|
|
1549
|
+
` title: r.YOUR_TITLE_FIELD,\n` +
|
|
1550
|
+
` url: r.YOUR_URL_FIELD,\n` +
|
|
1551
|
+
` snippet: r.YOUR_SNIPPET_FIELD || ""\n` +
|
|
1552
|
+
` }))\n` +
|
|
1553
|
+
` };\n` +
|
|
1554
|
+
`}\n`;
|
|
1555
|
+
|
|
1556
|
+
mkdirSync(join(homedir(), ".pi", "web-cache"), { recursive: true });
|
|
1557
|
+
writeFileSync(TRANSFORM_DRAFT_PATH, template, "utf-8");
|
|
1558
|
+
|
|
1559
|
+
ctx.ui.notify(`Transform template written to:\n ${TRANSFORM_DRAFT_PATH}`, "info");
|
|
1560
|
+
ctx.ui.notify("Edit it with any editor, or ask your pi agent:", "info");
|
|
1561
|
+
ctx.ui.notify(
|
|
1562
|
+
"─────────────────────────────────────────\n" +
|
|
1563
|
+
"Need help writing the transform? Ask your pi agent:\n\n" +
|
|
1564
|
+
` "I'm adding a custom search provider called ${meta.label}.\n` +
|
|
1565
|
+
` Here is the API documentation / endpoint: [paste docs or endpoint URL]\n\n` +
|
|
1566
|
+
` Write a transform function with this exact signature:\n` +
|
|
1567
|
+
` async function(query, n, apiKey, fetchJson)\n\n` +
|
|
1568
|
+
` Where fetchJson(url, options) handles the HTTP call.\n` +
|
|
1569
|
+
` It must return:\n` +
|
|
1570
|
+
` { answer: string, results: Array<{ title: string, url: string, snippet: string }> }\n\n` +
|
|
1571
|
+
` Save the completed function to:\n` +
|
|
1572
|
+
` ${TRANSFORM_DRAFT_PATH}"\n` +
|
|
1573
|
+
"─────────────────────────────────────────",
|
|
1574
|
+
"info",
|
|
1575
|
+
);
|
|
1576
|
+
|
|
1577
|
+
const action = await ctx.ui.select("When your transform is ready:", [
|
|
1578
|
+
{ value: "done", label: "Done — I've edited the file", description: "Proceed to test" },
|
|
1579
|
+
{ value: "cancel", label: "Cancel", description: "Exit wizard without saving" },
|
|
1580
|
+
]);
|
|
1581
|
+
|
|
1582
|
+
if (action !== "done") return null;
|
|
1583
|
+
|
|
1584
|
+
try {
|
|
1585
|
+
return readFileSync(TRANSFORM_DRAFT_PATH, "utf-8").trim();
|
|
1586
|
+
} catch {
|
|
1587
|
+
ctx.ui.notify(`Could not read transform file at ${TRANSFORM_DRAFT_PATH}`, "warning");
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
async function runTestTransform(
|
|
1593
|
+
ctx: Parameters<typeof runProviderSwitch>[0],
|
|
1594
|
+
meta: ProviderMeta,
|
|
1595
|
+
transformSrc: string,
|
|
1596
|
+
): Promise<boolean> {
|
|
1597
|
+
ctx.ui.notify("── Step 3 of 3: Test ──", "info");
|
|
1598
|
+
ctx.ui.notify("Compiling transform...", "info");
|
|
1599
|
+
|
|
1600
|
+
type TransformFn = (q: string, n: number, k: string, f: typeof fetchJson) => Promise<SearchResult>;
|
|
1601
|
+
let fn: TransformFn;
|
|
1602
|
+
try {
|
|
1603
|
+
fn = new Function(
|
|
1604
|
+
"query", "n", "apiKey", "fetchJson",
|
|
1605
|
+
`return (${transformSrc})(query, n, apiKey, fetchJson)`
|
|
1606
|
+
) as TransformFn;
|
|
1607
|
+
} catch (err) {
|
|
1608
|
+
ctx.ui.notify(
|
|
1609
|
+
`✗ Compile error — fix the syntax in your transform file:\n ${err instanceof Error ? err.message : String(err)}`,
|
|
1610
|
+
"warning",
|
|
1611
|
+
);
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
ctx.ui.notify("Syntax OK. Running live test search for \"test\"...", "info");
|
|
1616
|
+
|
|
1617
|
+
const cfg = loadConfig();
|
|
1618
|
+
const apiKey = String(
|
|
1619
|
+
(cfg.ext as Record<string, unknown>)[meta.envKey] ||
|
|
1620
|
+
process.env[meta.envKey] || ""
|
|
1621
|
+
);
|
|
1622
|
+
|
|
1623
|
+
if (!apiKey) {
|
|
1624
|
+
ctx.ui.notify(
|
|
1625
|
+
`✗ No API key found for ${meta.envKey}.\n` +
|
|
1626
|
+
` Add it to ~/.pi/agent/settings.json → browserExt → "${meta.envKey}"`,
|
|
1627
|
+
"warning",
|
|
1628
|
+
);
|
|
1629
|
+
return false;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
let result: SearchResult;
|
|
1633
|
+
try {
|
|
1634
|
+
result = await fn("test", 3, apiKey, fetchJsonCapturing as typeof fetchJson);
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
ctx.ui.notify(
|
|
1637
|
+
`✗ Runtime error:\n ${err instanceof Error ? err.message : String(err)}`,
|
|
1638
|
+
"warning",
|
|
1639
|
+
);
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const raw = getLastCapturedRaw();
|
|
1644
|
+
ctx.ui.notify("RAW API RESPONSE:", "info");
|
|
1645
|
+
ctx.ui.notify(JSON.stringify(raw, null, 2).substring(0, 2000), "info");
|
|
1646
|
+
|
|
1647
|
+
ctx.ui.notify("\nMAPPED OUTPUT:", "info");
|
|
1648
|
+
ctx.ui.notify(`answer: "${result.answer}"`, "info");
|
|
1649
|
+
if (result.results.length === 0) {
|
|
1650
|
+
ctx.ui.notify("✗ results: [] — empty! Check your results array field name.", "warning");
|
|
1651
|
+
return false;
|
|
1652
|
+
}
|
|
1653
|
+
result.results.forEach((r, i) => {
|
|
1654
|
+
ctx.ui.notify(
|
|
1655
|
+
`${i + 1}. ${r.title || "⚠ MISSING TITLE"}\n ${r.url || "⚠ MISSING URL"}\n ${r.snippet || "(no snippet)"}`,
|
|
1656
|
+
"info",
|
|
1657
|
+
);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
const missingTitle = result.results.some(r => !r.title);
|
|
1661
|
+
const missingUrl = result.results.some(r => !r.url);
|
|
1662
|
+
if (missingTitle || missingUrl) {
|
|
1663
|
+
ctx.ui.notify(
|
|
1664
|
+
`✗ Some results are missing ${[missingTitle && "title", missingUrl && "url"].filter(Boolean).join(" and ")}.\n` +
|
|
1665
|
+
` Check your field mappings in the transform.`,
|
|
1666
|
+
"warning",
|
|
1667
|
+
);
|
|
1668
|
+
return false;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
ctx.ui.notify(`✓ ${result.results.length} results mapped successfully.`, "info");
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function saveCustomProvider(meta: ProviderMeta, transformSrc: string): void {
|
|
1676
|
+
const cfg = loadConfig();
|
|
1677
|
+
const existing = cfg.ext.customProviders ?? [];
|
|
1678
|
+
const newProvider: CustomProviderConfig = {
|
|
1679
|
+
id: meta.id,
|
|
1680
|
+
label: meta.label,
|
|
1681
|
+
envKey: meta.envKey,
|
|
1682
|
+
freeTier: meta.freeTier,
|
|
1683
|
+
signupUrl: meta.signupUrl,
|
|
1684
|
+
transform: transformSrc,
|
|
1685
|
+
};
|
|
1686
|
+
// Replace if ID already exists, otherwise append
|
|
1687
|
+
const updated = existing.some(p => p.id === meta.id)
|
|
1688
|
+
? existing.map(p => p.id === meta.id ? newProvider : p)
|
|
1689
|
+
: [...existing, newProvider];
|
|
1690
|
+
patchExtConfig({ customProviders: updated });
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async function runCustomProviderWizard(
|
|
1694
|
+
ctx: Parameters<typeof runProviderSwitch>[0],
|
|
1695
|
+
): Promise<void> {
|
|
1696
|
+
ctx.ui.notify(
|
|
1697
|
+
"Custom Provider Wizard\n" +
|
|
1698
|
+
"─────────────────────\n" +
|
|
1699
|
+
"Add any search API without editing source code.\n" +
|
|
1700
|
+
"You will need: an API key, and a JS transform function.\n" +
|
|
1701
|
+
"The wizard will guide you through 3 steps.",
|
|
1702
|
+
"info",
|
|
1703
|
+
);
|
|
1704
|
+
|
|
1705
|
+
const meta = await runCollectProviderMeta(ctx);
|
|
1706
|
+
if (!meta) {
|
|
1707
|
+
ctx.ui.notify("Wizard cancelled.", "info");
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const transformSrc = await runCollectTransform(ctx, meta);
|
|
1712
|
+
if (!transformSrc) {
|
|
1713
|
+
ctx.ui.notify("Wizard cancelled.", "info");
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const passed = await runTestTransform(ctx, meta, transformSrc);
|
|
1718
|
+
if (!passed) {
|
|
1719
|
+
const retry = await ctx.ui.select("Test failed. What would you like to do?", [
|
|
1720
|
+
{ value: "retry", label: "Edit transform and retry", description: `Re-edit ${TRANSFORM_DRAFT_PATH} then try again` },
|
|
1721
|
+
{ value: "save", label: "Save anyway", description: "Save the untested transform — it may not work" },
|
|
1722
|
+
{ value: "cancel", label: "Cancel", description: "Exit without saving" },
|
|
1723
|
+
]);
|
|
1724
|
+
if (retry === "retry") {
|
|
1725
|
+
const retryTransform = await runCollectTransform(ctx, meta);
|
|
1726
|
+
if (!retryTransform) return;
|
|
1727
|
+
const retryPassed = await runTestTransform(ctx, meta, retryTransform);
|
|
1728
|
+
if (retryPassed) {
|
|
1729
|
+
saveCustomProvider(meta, retryTransform);
|
|
1730
|
+
} else {
|
|
1731
|
+
ctx.ui.notify("Still failing. Saving anyway — run /search → Remove to undo.", "warning");
|
|
1732
|
+
saveCustomProvider(meta, retryTransform);
|
|
1733
|
+
}
|
|
1734
|
+
} else if (retry === "save") {
|
|
1735
|
+
saveCustomProvider(meta, transformSrc);
|
|
1736
|
+
} else {
|
|
1737
|
+
ctx.ui.notify("Cancelled — nothing saved.", "info");
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
} else {
|
|
1741
|
+
saveCustomProvider(meta, transformSrc);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
ctx.ui.notify(
|
|
1745
|
+
`✓ ${meta.label} saved to settings.json.\n` +
|
|
1746
|
+
`Run /reload to activate it. It will appear in /search and the provider fallback chain.`,
|
|
1747
|
+
"info",
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
pi.registerCommand("browser", {
|
|
1752
|
+
description: "Browser status and settings (headless, supervised)",
|
|
1753
|
+
handler: async (_args, ctx) => {
|
|
1754
|
+
const cfg = loadConfig();
|
|
1755
|
+
const { active } = getActiveConfig(cfg);
|
|
1756
|
+
|
|
1757
|
+
// ── Status block ──
|
|
1758
|
+
const running = browserState.browser !== null && browserState.page !== null && !browserState.page.isClosed();
|
|
1759
|
+
const url = running ? browserState.page!.url() : "N/A";
|
|
1760
|
+
const title = running ? await browserState.page!.title().catch(() => "N/A") : "N/A";
|
|
1761
|
+
|
|
1762
|
+
const status = [
|
|
1763
|
+
`Browser Engine: ${browserState.engine}`,
|
|
1764
|
+
`Headless: ${browserState.headless}`,
|
|
1765
|
+
`Supervised: ${browserState.supervised}`,
|
|
1766
|
+
`Session active: ${running}`,
|
|
1767
|
+
running ? `Current page: ${title}\nURL: ${url}` : "",
|
|
1768
|
+
`Search: ${active?.id ?? "none configured — run /search"}`,
|
|
1769
|
+
browserState.supervised && browserState.sessionDir ? `Screenshot dir: ${browserState.sessionDir}` : "",
|
|
1770
|
+
].filter(Boolean).join("\n");
|
|
1771
|
+
ctx.ui.notify(status, "info");
|
|
1772
|
+
|
|
1773
|
+
// ── Settings menu ──
|
|
1774
|
+
const choice = await ctx.ui.select(
|
|
1775
|
+
"Browser settings:",
|
|
1776
|
+
[
|
|
1777
|
+
{ value: "headless", label: `Headless: ${browserState.headless} → ${!browserState.headless}`, description: "Toggle visible/invisible browser. Run /reload to apply." },
|
|
1778
|
+
{ value: "supervised", label: `Supervised: ${browserState.supervised} → ${!browserState.supervised}`, description: "Auto-screenshot after each browser action. Run /reload to apply." },
|
|
1779
|
+
{ value: "done", label: "Done", description: "Exit" },
|
|
1780
|
+
],
|
|
1781
|
+
);
|
|
1782
|
+
|
|
1783
|
+
switch (choice) {
|
|
1784
|
+
case "headless": {
|
|
1785
|
+
patchExtConfig({ headless: !browserState.headless });
|
|
1786
|
+
ctx.ui.notify(`Headless set to ${!browserState.headless}. Run /reload to apply.`, "info");
|
|
1787
|
+
break;
|
|
1788
|
+
}
|
|
1789
|
+
case "supervised": {
|
|
1790
|
+
patchExtConfig({ supervised: !browserState.supervised });
|
|
1791
|
+
ctx.ui.notify(`Supervised mode ${!browserState.supervised ? "ON" : "OFF"}. Run /reload to apply.`, "info");
|
|
1792
|
+
break;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
},
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
pi.registerCommand("search", {
|
|
1799
|
+
description: "Search provider management — status, switch, add, remove. Use /search <provider-id> to switch instantly.",
|
|
1800
|
+
handler: async (args, ctx) => {
|
|
1801
|
+
const cfg = loadConfig();
|
|
1802
|
+
const { providers, active } = getActiveConfig(cfg);
|
|
1803
|
+
|
|
1804
|
+
// ── Direct switch: /search <provider-id> ──
|
|
1805
|
+
const arg = (args as string).trim().toLowerCase();
|
|
1806
|
+
if (arg) {
|
|
1807
|
+
const target = providers.find(p => p.id === arg);
|
|
1808
|
+
if (target) {
|
|
1809
|
+
patchExtConfig({ activeProvider: target.id as ProviderId });
|
|
1810
|
+
ctx.ui.notify(`Switched to ${target.label}. Run /reload to activate.`, "info");
|
|
1811
|
+
} else {
|
|
1812
|
+
ctx.ui.notify(`Search provider '${arg}' is not configured.`, "warning");
|
|
1813
|
+
ctx.ui.notify(
|
|
1814
|
+
providers.length > 0
|
|
1815
|
+
? `Your configured providers: ${providers.map(p => p.id).join(", ")}`
|
|
1816
|
+
: "No providers configured yet.",
|
|
1817
|
+
"info",
|
|
1818
|
+
);
|
|
1819
|
+
ctx.ui.notify(
|
|
1820
|
+
"Need to add one? Run /search → Add a provider, or ask me: 'add Exa as a search provider'",
|
|
1821
|
+
"info",
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// ── Onboarding: no providers configured ──
|
|
1828
|
+
if (providers.length === 0) {
|
|
1829
|
+
ctx.ui.notify("⚠ No search providers configured. web_search and code_search are disabled.", "warning");
|
|
1830
|
+
|
|
1831
|
+
const choice = await ctx.ui.select(
|
|
1832
|
+
"Would you like to configure a search provider?",
|
|
1833
|
+
[
|
|
1834
|
+
{ value: "brave", label: "Brave Search — FREE (2,000 queries/mo)", description: "Best free option. Sign up at brave.com/search/api/" },
|
|
1835
|
+
{ value: "tavily", label: "Tavily — FREE (1,000 queries/mo)", description: "Designed for AI agents. Sign up at app.tavily.com" },
|
|
1836
|
+
{ value: "exa", label: "Exa — FREE tier (2,500 queries/mo)", description: "Sign up at exa.ai" },
|
|
1837
|
+
{ value: "gemini", label: "Google Gemini — free tier", description: "Get key at aistudio.google.com/app/apikey" },
|
|
1838
|
+
{ value: "perplexity", label: "Perplexity AI — paid", description: "Get key at perplexity.ai/settings/api" },
|
|
1839
|
+
{ value: "skip", label: "Skip — configure later", description: "You can run /search again anytime" },
|
|
1840
|
+
],
|
|
1841
|
+
);
|
|
1842
|
+
|
|
1843
|
+
if (choice && choice !== "skip") {
|
|
1844
|
+
const def = PROVIDER_REGISTRY.find(p => p.id === choice);
|
|
1845
|
+
if (def) {
|
|
1846
|
+
ctx.ui.notify(`To use ${def.label}:`, "info");
|
|
1847
|
+
ctx.ui.notify(`1. Get a free key: ${def.signupUrl}`, "info");
|
|
1848
|
+
ctx.ui.notify(`2. Set it via one of these methods:`, "info");
|
|
1849
|
+
ctx.ui.notify(` Shell (current session): export ${def.envKey}="your-key-here"`, "info");
|
|
1850
|
+
ctx.ui.notify(` Shell (persistent): Add the above to ~/.bashrc`, "info");
|
|
1851
|
+
ctx.ui.notify(` Windows (persistent): setx ${def.envKey} "your-key-here"`, "info");
|
|
1852
|
+
ctx.ui.notify(` Pi settings (persistent): Edit ~/.pi/agent/settings.json:`, "info");
|
|
1853
|
+
ctx.ui.notify(` { "browserExt": { "${def.envKey}": "your-key-here", "activeProvider": "${def.id}" } }`, "info");
|
|
1854
|
+
ctx.ui.notify(`3. Restart pi or run /reload for the key to take effect.`, "info");
|
|
1855
|
+
}
|
|
1856
|
+
} else {
|
|
1857
|
+
ctx.ui.notify("Skipped. Run /search anytime to configure a provider.", "info");
|
|
1858
|
+
}
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// ── Hub: providers configured ──
|
|
1863
|
+
ctx.ui.notify(
|
|
1864
|
+
`Active provider: ${active?.label ?? "none"}\nConfigured providers: ${providers.map(p => p.id).join(", ")}`,
|
|
1865
|
+
"info",
|
|
1866
|
+
);
|
|
1867
|
+
|
|
1868
|
+
const choice = await ctx.ui.select(
|
|
1869
|
+
`${active ? `Active: ${active.label}` : "No active provider"} · ${providers.length} configured · Options:`,
|
|
1870
|
+
[
|
|
1871
|
+
{ value: "switch", label: "Switch active provider", description: "Change which provider is used for web_search" },
|
|
1872
|
+
{ value: "add", label: "Add a provider", description: "Configure an additional search provider" },
|
|
1873
|
+
{ value: "add-custom", label: "Add custom provider", description: "Wizard: add any search API without editing source" },
|
|
1874
|
+
{ value: "remove", label: "Remove a provider key", description: "Clear a provider's key from settings.json" },
|
|
1875
|
+
{ value: "done", label: "Done", description: "Exit" },
|
|
1876
|
+
],
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
switch (choice) {
|
|
1880
|
+
case "switch": await runProviderSwitch(ctx, providers); break;
|
|
1881
|
+
case "add": await runProviderAdd(ctx, providers); break;
|
|
1882
|
+
case "add-custom": await runCustomProviderWizard(ctx); break;
|
|
1883
|
+
case "remove": await runProviderRemove(ctx, cfg, providers); break;
|
|
1884
|
+
}
|
|
1885
|
+
},
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
// =======================================================================
|
|
1889
|
+
// LIFECYCLE
|
|
1890
|
+
// =======================================================================
|
|
1891
|
+
|
|
1892
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1893
|
+
const { active } = getActiveConfig(config);
|
|
1894
|
+
const suppress = config.ext.suppressStartupMessage === true;
|
|
1895
|
+
|
|
1896
|
+
if (!suppress) {
|
|
1897
|
+
const supervisedOn = browserState.supervised;
|
|
1898
|
+
const screenshotPath = join(homedir(), "Pictures", "pi-fox", "sessions");
|
|
1899
|
+
|
|
1900
|
+
if (supervisedOn) {
|
|
1901
|
+
ctx.ui.notify(
|
|
1902
|
+
`[pi-fox] Supervised mode: ON\n` +
|
|
1903
|
+
` Screenshots saved to: ${screenshotPath}\n` +
|
|
1904
|
+
` To toggle: set browserExt.supervised = true/false in ~/.pi/agent/settings.json\n` +
|
|
1905
|
+
` — or just ask me to turn it on or off.\n` +
|
|
1906
|
+
` To hide this message: set browserExt.suppressStartupMessage = true\n` +
|
|
1907
|
+
` — or just ask me to hide it.`,
|
|
1908
|
+
"info",
|
|
1909
|
+
);
|
|
1910
|
+
} else {
|
|
1911
|
+
ctx.ui.notify(
|
|
1912
|
+
`[pi-fox] Supervised mode: OFF\n` +
|
|
1913
|
+
` To enable automatic screenshots: set browserExt.supervised = true\n` +
|
|
1914
|
+
` — or just ask me to turn it on.\n` +
|
|
1915
|
+
` To hide this message: set browserExt.suppressStartupMessage = true\n` +
|
|
1916
|
+
` — or just ask me to hide it.`,
|
|
1917
|
+
"info",
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
if (!active) {
|
|
1923
|
+
ctx.ui.notify(
|
|
1924
|
+
"⚠ No search key configured. web_search and code_search are disabled.\n" +
|
|
1925
|
+
"Run /search for the setup wizard (Brave Search = free, 2,000 queries/mo).",
|
|
1926
|
+
"warning",
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
pi.on("session_shutdown", async () => {
|
|
1932
|
+
await closeBrowser(browserState);
|
|
1933
|
+
});
|
|
1934
|
+
}
|