@phi-code-admin/phi-code 0.74.2 → 0.74.3

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.
Files changed (2) hide show
  1. package/extensions/phi/web-search.ts +432 -186
  2. package/package.json +106 -106
@@ -1,20 +1,13 @@
1
1
  /**
2
- * Web Search Extension - Internet search capabilities for Phi Code
2
+ * Web Search & Fetch Extension for Phi Code
3
3
  *
4
- * Provides web search functionality with fallback options:
5
- * 1. Brave Search API (if BRAVE_API_KEY environment variable is set)
6
- * 2. DuckDuckGo HTML scraping fallback
4
+ * Tools:
5
+ * - web_search: Google scraping (primary) DuckDuckGo (fallback) Brave (if API key set)
6
+ * - fetch_url: Read any URL and extract clean text (node-fetch + @mozilla/readability + jsdom)
7
+ * - /search command for quick searches
7
8
  *
8
- * Features:
9
- * - web_search tool for LLM use
10
- * - Configurable result count
11
- * - Clean result formatting with titles, URLs, and descriptions
12
- * - Automatic fallback when API is unavailable
13
- *
14
- * Usage:
15
- * 1. Copy to packages/coding-agent/extensions/phi/web-search.ts
16
- * 2. Optionally set BRAVE_API_KEY environment variable
17
- * 3. Use web_search tool in conversations
9
+ * Zero API keys required. Works out of the box.
10
+ * Optional: set BRAVE_API_KEY for Brave Search as extra fallback.
18
11
  */
19
12
 
20
13
  import { Type } from "@sinclair/typebox";
@@ -24,173 +17,398 @@ interface SearchResult {
24
17
  title: string;
25
18
  url: string;
26
19
  description: string;
20
+ source?: string;
21
+ }
22
+
23
+ interface SearchResponse {
24
+ results: SearchResult[];
25
+ provider: string;
26
+ fallbackUsed: boolean;
27
+ triedProviders: string[];
28
+ }
29
+
30
+ // ─── Rotating User-Agents ───
31
+
32
+ const USER_AGENTS = [
33
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
34
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
35
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
36
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15",
37
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
38
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0",
39
+ ];
40
+
41
+ function randomUA(): string {
42
+ return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
43
+ }
44
+
45
+ // ─── HTML helpers (zero dependencies) ───
46
+
47
+ function decodeEntities(text: string): string {
48
+ return text
49
+ .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
50
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'")
51
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
52
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
53
+ }
54
+
55
+ function stripTags(html: string): string {
56
+ return decodeEntities(html.replace(/<[^>]*>/g, "")).trim();
27
57
  }
28
58
 
29
59
  export default function webSearchExtension(pi: ExtensionAPI) {
30
60
  const BRAVE_API_KEY = process.env.BRAVE_API_KEY;
31
61
  const BRAVE_API_URL = "https://api.search.brave.com/res/v1/web/search";
62
+ const HTTP_TIMEOUT = parseInt(process.env.HTTP_TIMEOUT || "15000", 10);
32
63
 
33
- // Rate limiting: max 1 request per second (Brave free tier limit)
64
+ // Rate limiting
34
65
  let lastRequestTime = 0;
35
- const MIN_INTERVAL_MS = 1100;
66
+ const MIN_INTERVAL_MS = 1500;
36
67
 
37
68
  async function rateLimitWait(): Promise<void> {
38
69
  const now = Date.now();
39
70
  const elapsed = now - lastRequestTime;
40
71
  if (elapsed < MIN_INTERVAL_MS) {
41
- await new Promise(resolve => setTimeout(resolve, MIN_INTERVAL_MS - elapsed));
72
+ await new Promise((r) => setTimeout(r, MIN_INTERVAL_MS - elapsed));
42
73
  }
43
74
  lastRequestTime = Date.now();
44
75
  }
45
76
 
46
- /**
47
- * Search using Brave Search API
48
- */
49
- async function searchBrave(query: string, count: number = 5): Promise<SearchResult[]> {
50
- if (!BRAVE_API_KEY) {
51
- throw new Error("BRAVE_API_KEY environment variable not set");
77
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
78
+ // Provider 1: Google Scraping (primary — works on local machines)
79
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
80
+
81
+ async function searchGoogle(query: string, count: number): Promise<SearchResult[]> {
82
+ await rateLimitWait();
83
+
84
+ const params = new URLSearchParams({
85
+ q: query,
86
+ num: Math.min(count + 2, 12).toString(),
87
+ hl: "en",
88
+ gl: "us",
89
+ });
90
+
91
+ const response = await fetch(`https://www.google.com/search?${params}`, {
92
+ headers: {
93
+ "User-Agent": randomUA(),
94
+ "Accept": "text/html,application/xhtml+xml",
95
+ "Accept-Language": "en-US,en;q=0.9",
96
+ "Cookie": "CONSENT=PENDING+987",
97
+ },
98
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
99
+ });
100
+
101
+ if (!response.ok) throw new Error(`Google HTTP ${response.status}`);
102
+
103
+ const html = await response.text();
104
+
105
+ if (html.includes("detected unusual traffic") || html.includes("sorry/index") || html.includes("g-recaptcha")) {
106
+ throw new Error("Google CAPTCHA detected");
52
107
  }
53
108
 
109
+ const results: SearchResult[] = [];
110
+
111
+ // Strategy 1: <div class="g"> blocks with <h3> and <a href>
112
+ const gBlockRegex = /<div class="g"[^>]*>(.*?)<\/div>\s*<\/div>\s*<\/div>/gs;
113
+ let gMatch;
114
+ while ((gMatch = gBlockRegex.exec(html)) !== null && results.length < count) {
115
+ const block = gMatch[1];
116
+ const linkMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>/);
117
+ const titleMatch = block.match(/<h3[^>]*>(.*?)<\/h3>/s);
118
+ const snippetMatch = block.match(/<div[^>]*class="[^"]*VwiC3b[^"]*"[^>]*>(.*?)<\/div>/s)
119
+ || block.match(/<span[^>]*class="[^"]*st[^"]*"[^>]*>(.*?)<\/span>/s);
120
+
121
+ if (linkMatch && titleMatch) {
122
+ const url = linkMatch[1];
123
+ if (!url.includes("google.com")) {
124
+ results.push({
125
+ title: stripTags(titleMatch[1]),
126
+ url,
127
+ description: snippetMatch ? stripTags(snippetMatch[1]) : "",
128
+ source: "google",
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ // Strategy 2: find <h3> + nearest <a href>
135
+ if (results.length === 0) {
136
+ const h3Regex = /<h3[^>]*>(.*?)<\/h3>/gs;
137
+ let h3Match;
138
+ while ((h3Match = h3Regex.exec(html)) !== null && results.length < count) {
139
+ const pos = h3Match.index;
140
+ const surrounding = html.substring(Math.max(0, pos - 500), pos + h3Match[0].length + 200);
141
+ const linkMatch = surrounding.match(/<a[^>]*href="(https?:\/\/(?!www\.google)[^"]+)"[^>]*>/);
142
+ const titleText = stripTags(h3Match[1]);
143
+ if (linkMatch && titleText) {
144
+ const afterH3 = html.substring(pos + h3Match[0].length, pos + h3Match[0].length + 500);
145
+ const snippetMatch = afterH3.match(/<(?:div|span)[^>]*>(.*?)<\/(?:div|span)>/s);
146
+ results.push({
147
+ title: titleText,
148
+ url: linkMatch[1],
149
+ description: snippetMatch ? stripTags(snippetMatch[1]).substring(0, 200) : "",
150
+ source: "google",
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ // Strategy 3: extract any external links
157
+ if (results.length === 0) {
158
+ const extRegex = /href="(https?:\/\/(?!www\.google|accounts\.google|support\.google|maps\.google|policies\.google)[^"]+)"/g;
159
+ const seen = new Set<string>();
160
+ let extMatch;
161
+ while ((extMatch = extRegex.exec(html)) !== null && seen.size < count) {
162
+ if (!seen.has(extMatch[1])) {
163
+ seen.add(extMatch[1]);
164
+ results.push({
165
+ title: extMatch[1].replace(/https?:\/\/(www\.)?/, "").split("/")[0],
166
+ url: extMatch[1],
167
+ description: "",
168
+ source: "google",
169
+ });
170
+ }
171
+ }
172
+ }
173
+
174
+ if (results.length === 0) {
175
+ throw new Error("Google returned no parseable results (JS-heavy page or blocked)");
176
+ }
177
+
178
+ return results.slice(0, count);
179
+ }
180
+
181
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
182
+ // Provider 2: DuckDuckGo HTML scraping (fallback)
183
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
184
+
185
+ async function searchDuckDuckGo(query: string, count: number): Promise<SearchResult[]> {
186
+ await rateLimitWait();
187
+
188
+ const response = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
189
+ headers: {
190
+ "User-Agent": randomUA(),
191
+ "Accept": "text/html",
192
+ "Accept-Language": "en-US,en;q=0.5",
193
+ },
194
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
195
+ });
196
+
197
+ if (!response.ok) throw new Error(`DuckDuckGo HTTP ${response.status}`);
198
+
199
+ const html = await response.text();
200
+
201
+ if (html.includes("complete the following challenge") || html.includes("bots use DuckDuckGo")) {
202
+ throw new Error("DuckDuckGo CAPTCHA detected");
203
+ }
204
+
205
+ const results: SearchResult[] = [];
206
+
207
+ // Parse result links
208
+ const linkRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gs;
209
+ const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>(.*?)<\/a>/gs;
210
+
211
+ const links: Array<{ url: string; title: string }> = [];
212
+ let m;
213
+ while ((m = linkRegex.exec(html)) !== null && links.length < count) {
214
+ let url = m[1];
215
+ const title = stripTags(m[2]);
216
+
217
+ // DDG wraps URLs through redirect
218
+ if (url.includes("uddg=")) {
219
+ try { url = decodeURIComponent(url.split("uddg=")[1].split("&")[0]); } catch { continue; }
220
+ }
221
+
222
+ if (url.startsWith("http") && title) {
223
+ links.push({ url, title });
224
+ }
225
+ }
226
+
227
+ const snippets: string[] = [];
228
+ while ((m = snippetRegex.exec(html)) !== null) {
229
+ snippets.push(stripTags(m[1]));
230
+ }
231
+
232
+ for (let i = 0; i < links.length; i++) {
233
+ results.push({
234
+ title: links[i].title,
235
+ url: links[i].url,
236
+ description: snippets[i] || "",
237
+ source: "duckduckgo",
238
+ });
239
+ }
240
+
241
+ if (results.length === 0) throw new Error("DuckDuckGo returned no results");
242
+
243
+ return results;
244
+ }
245
+
246
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
247
+ // Provider 3: Brave Search API (fallback, needs BRAVE_API_KEY)
248
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
249
+
250
+ async function searchBrave(query: string, count: number): Promise<SearchResult[]> {
251
+ if (!BRAVE_API_KEY) throw new Error("BRAVE_API_KEY not set");
252
+
253
+ await rateLimitWait();
254
+
54
255
  const params = new URLSearchParams({
55
256
  q: query,
56
257
  count: count.toString(),
57
258
  offset: "0",
58
- mkt: "en-US",
59
259
  safesearch: "moderate",
60
- freshness: "pw", // Past week preference
61
260
  text_decorations: "false",
62
- spellcheck: "true"
261
+ spellcheck: "true",
63
262
  });
64
263
 
65
- await rateLimitWait();
66
264
  const response = await fetch(`${BRAVE_API_URL}?${params}`, {
67
- method: "GET",
68
265
  headers: {
69
266
  "Accept": "application/json",
70
267
  "Accept-Encoding": "gzip",
71
- "X-Subscription-Token": BRAVE_API_KEY
72
- }
268
+ "X-Subscription-Token": BRAVE_API_KEY,
269
+ },
270
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
73
271
  });
74
272
 
75
- if (!response.ok) {
76
- throw new Error(`Brave API error: ${response.status} ${response.statusText}`);
77
- }
273
+ if (!response.ok) throw new Error(`Brave API HTTP ${response.status}`);
78
274
 
79
- const data = await response.json();
80
-
81
- if (!data.web?.results) {
82
- return [];
83
- }
275
+ const data = await response.json() as any;
276
+ if (!data.web?.results) return [];
84
277
 
85
- return data.web.results.map((result: any): SearchResult => ({
86
- title: result.title || "No title",
87
- url: result.url || "",
88
- description: result.description || "No description available"
278
+ return data.web.results.map((r: any): SearchResult => ({
279
+ title: r.title || "No title",
280
+ url: r.url || "",
281
+ description: r.description || "",
282
+ source: "brave",
89
283
  }));
90
284
  }
91
285
 
92
- /**
93
- * Search using DuckDuckGo HTML fallback
94
- */
95
- async function searchDuckDuckGo(query: string, count: number = 5): Promise<SearchResult[]> {
96
- try {
97
- const encodedQuery = encodeURIComponent(query);
98
- const url = `https://html.duckduckgo.com/html/?q=${encodedQuery}`;
286
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
287
+ // Search orchestrator with cascading fallback
288
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
99
289
 
100
- const response = await fetch(url, {
101
- method: "GET",
102
- headers: {
103
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
104
- }
105
- });
290
+ async function performSearch(query: string, count: number = 5): Promise<SearchResponse> {
291
+ const triedProviders: string[] = [];
106
292
 
107
- if (!response.ok) {
108
- throw new Error(`DuckDuckGo error: ${response.status} ${response.statusText}`);
109
- }
293
+ type Provider = { name: string; fn: (q: string, c: number) => Promise<SearchResult[]> };
294
+ const providers: Provider[] = [
295
+ { name: "google", fn: searchGoogle },
296
+ { name: "duckduckgo", fn: searchDuckDuckGo },
297
+ ];
298
+ if (BRAVE_API_KEY) {
299
+ providers.push({ name: "brave", fn: searchBrave });
300
+ }
110
301
 
111
- const html = await response.text();
112
-
113
- // Parse HTML to extract search results
114
- const results: SearchResult[] = [];
115
-
116
- // Basic regex parsing for DuckDuckGo results
117
- // This is fragile but works for basic cases
118
- const resultPattern = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g;
119
- const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([^<]*)<\/a>/g;
120
-
121
- let match;
122
- const urls: string[] = [];
123
- const titles: string[] = [];
124
-
125
- // Extract titles and URLs
126
- while ((match = resultPattern.exec(html)) !== null && titles.length < count) {
127
- const url = match[1];
128
- const title = match[2];
129
-
130
- if (url && title && !url.startsWith('/')) {
131
- urls.push(url);
132
- titles.push(title.trim());
302
+ for (const provider of providers) {
303
+ triedProviders.push(provider.name);
304
+ try {
305
+ const results = await provider.fn(query, count);
306
+ if (results.length > 0) {
307
+ return {
308
+ results,
309
+ provider: provider.name,
310
+ fallbackUsed: triedProviders.length > 1,
311
+ triedProviders,
312
+ };
133
313
  }
314
+ } catch (error: any) {
315
+ console.warn(`[web-search] ${provider.name} failed: ${error?.message || error}`);
134
316
  }
317
+ }
135
318
 
136
- // Extract snippets (descriptions)
137
- const descriptions: string[] = [];
138
- while ((match = snippetPattern.exec(html)) !== null && descriptions.length < titles.length) {
139
- descriptions.push(match[1].trim());
140
- }
319
+ return { results: [], provider: "none", fallbackUsed: true, triedProviders };
320
+ }
141
321
 
142
- // Combine results
143
- for (let i = 0; i < Math.min(titles.length, count); i++) {
144
- results.push({
145
- title: titles[i] || "No title",
146
- url: urls[i] || "",
147
- description: descriptions[i] || "No description available"
148
- });
149
- }
322
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
323
+ // fetch_url: Read web pages (readability + jsdom if available, raw fallback)
324
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
150
325
 
151
- return results;
326
+ // Lazy-loaded optional deps (installed by user if they want better extraction)
327
+ let _Readability: any = null;
328
+ let _JSDOM: any = null;
329
+ let _readabilityChecked = false;
152
330
 
153
- } catch (error) {
154
- console.warn("DuckDuckGo fallback failed:", error);
155
- return [];
331
+ async function tryLoadReadability(): Promise<boolean> {
332
+ if (_readabilityChecked) return !!_Readability;
333
+ _readabilityChecked = true;
334
+ try {
335
+ const readabilityMod = await import("@mozilla/readability");
336
+ _Readability = readabilityMod.Readability;
337
+ const jsdomMod = await import("jsdom");
338
+ _JSDOM = jsdomMod.JSDOM;
339
+ return true;
340
+ } catch {
341
+ return false;
156
342
  }
157
343
  }
158
344
 
159
- /**
160
- * Perform web search with automatic fallback
161
- */
162
- async function performSearch(query: string, count: number = 5): Promise<SearchResult[]> {
163
- // Try Brave first if API key is available
164
- if (BRAVE_API_KEY) {
345
+ async function fetchUrl(url: string, maxLength: number = 8000): Promise<string> {
346
+ const response = await fetch(url, {
347
+ headers: {
348
+ "User-Agent": randomUA(),
349
+ "Accept": "text/html,application/xhtml+xml,text/plain,application/json",
350
+ },
351
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
352
+ });
353
+
354
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
355
+
356
+ const contentType = response.headers.get("content-type") || "";
357
+ const text = await response.text();
358
+
359
+ // Plain text / JSON — return as-is
360
+ if (contentType.includes("text/plain") || contentType.includes("application/json")) {
361
+ return text.substring(0, maxLength);
362
+ }
363
+
364
+ // Try Readability + JSDOM (best quality extraction)
365
+ const hasReadability = await tryLoadReadability();
366
+ if (hasReadability && _JSDOM && _Readability) {
165
367
  try {
166
- const results = await searchBrave(query, count);
167
- if (results.length > 0) {
168
- return results;
368
+ const dom = new _JSDOM(text, { url });
369
+ const reader = new _Readability(dom.window.document);
370
+ const article = reader.parse();
371
+ if (article?.textContent) {
372
+ return article.textContent.substring(0, maxLength);
169
373
  }
170
- } catch (error) {
171
- console.warn("Brave Search failed, falling back to DuckDuckGo:", error);
374
+ } catch {
375
+ // Fall through to basic extraction
172
376
  }
173
377
  }
174
378
 
175
- // Fallback to DuckDuckGo
176
- return await searchDuckDuckGo(query, count);
379
+ // Basic HTML → text extraction (no dependencies)
380
+ let readable = text
381
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
382
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
383
+ .replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "")
384
+ .replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "")
385
+ .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
386
+ .replace(/<br\s*\/?>/gi, "\n")
387
+ .replace(/<\/p>/gi, "\n\n")
388
+ .replace(/<\/h[1-6]>/gi, "\n\n")
389
+ .replace(/<\/li>/gi, "\n")
390
+ .replace(/<[^>]+>/g, " ")
391
+ .replace(/[ \t]+/g, " ")
392
+ .replace(/\n{3,}/g, "\n\n")
393
+ .trim();
394
+
395
+ return decodeEntities(readable).substring(0, maxLength);
177
396
  }
178
397
 
179
- /**
180
- * Web search tool
181
- */
398
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
399
+ // Tool: web_search
400
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
401
+
182
402
  pi.registerTool({
183
403
  name: "web_search",
184
404
  label: "Web Search",
185
- description: "Search the web for information using Brave Search API or DuckDuckGo fallback",
405
+ description: "Search the web. Uses Google (primary), DuckDuckGo (fallback), Brave (if API key set). No API keys required.",
186
406
  parameters: Type.Object({
187
- query: Type.String({
188
- description: "Search query to find information about"
189
- }),
190
- count: Type.Optional(Type.Number({
191
- description: "Number of results to return (1-10, default: 5)",
407
+ query: Type.String({ description: "Search query" }),
408
+ count: Type.Optional(Type.Number({
409
+ description: "Number of results (1-10, default: 5)",
192
410
  minimum: 1,
193
- maximum: 10
411
+ maximum: 10,
194
412
  })),
195
413
  }),
196
414
 
@@ -198,108 +416,136 @@ export default function webSearchExtension(pi: ExtensionAPI) {
198
416
  const { query, count = 5 } = params as { query: string; count?: number };
199
417
 
200
418
  try {
201
- const results = await performSearch(query, count);
419
+ const response = await performSearch(query, count);
202
420
 
203
- if (results.length === 0) {
421
+ if (response.results.length === 0) {
204
422
  return {
205
- content: [{
206
- type: "text",
207
- text: `No search results found for "${query}". Try rephrasing your search or checking your internet connection.`
423
+ content: [{
424
+ type: "text",
425
+ text: `No search results found for "${query}". Try rephrasing your search.\n\nProviders tried: ${response.triedProviders.join(", ")}`,
208
426
  }],
209
- details: { found: false, query, resultCount: 0 }
427
+ details: { found: false, query, triedProviders: response.triedProviders },
210
428
  };
211
429
  }
212
430
 
213
- // Format results
214
431
  let resultText = `**Web Search Results for "${query}":**\n\n`;
215
-
216
- results.forEach((result, index) => {
217
- resultText += `**${index + 1}. ${result.title}**\n`;
432
+ response.results.forEach((result, i) => {
433
+ resultText += `**${i + 1}. ${result.title}**\n`;
218
434
  resultText += `🔗 ${result.url}\n`;
219
- resultText += `📄 ${result.description}\n\n`;
435
+ if (result.description) resultText += `📄 ${result.description}\n`;
436
+ resultText += "\n";
220
437
  });
221
-
222
- // Add search method info
223
- const method = BRAVE_API_KEY ? "Brave Search API" : "DuckDuckGo";
224
- resultText += `\n*Results provided by ${method}*`;
438
+ resultText += `\n*Results provided by ${response.provider}*`;
439
+ if (response.fallbackUsed) {
440
+ resultText += ` *(fallback from: ${response.triedProviders.filter(p => p !== response.provider).join(", ")})*`;
441
+ }
225
442
 
226
443
  return {
227
444
  content: [{ type: "text", text: resultText }],
228
- details: {
229
- found: true,
230
- query,
231
- resultCount: results.length,
232
- method,
233
- results: results.map(r => ({ title: r.title, url: r.url }))
234
- }
445
+ details: {
446
+ found: true, query,
447
+ resultCount: response.results.length,
448
+ provider: response.provider,
449
+ fallbackUsed: response.fallbackUsed,
450
+ triedProviders: response.triedProviders,
451
+ results: response.results.map((r) => ({ title: r.title, url: r.url })),
452
+ },
235
453
  };
454
+ } catch (error) {
455
+ return {
456
+ content: [{ type: "text", text: `Web search failed: ${error}` }],
457
+ details: { error: String(error), found: false, query },
458
+ };
459
+ }
460
+ },
461
+ });
462
+
463
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
464
+ // Tool: fetch_url
465
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
466
+
467
+ pi.registerTool({
468
+ name: "fetch_url",
469
+ label: "Fetch URL",
470
+ description: "Fetch a URL and extract readable text content. Uses @mozilla/readability + jsdom if installed, otherwise basic HTML extraction.",
471
+ parameters: Type.Object({
472
+ url: Type.String({ description: "URL to fetch" }),
473
+ max_length: Type.Optional(Type.Number({
474
+ description: "Max characters to return (default: 8000)",
475
+ minimum: 500,
476
+ maximum: 50000,
477
+ })),
478
+ }),
479
+
480
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
481
+ const { url, max_length = 8000 } = params as { url: string; max_length?: number };
482
+
483
+ try {
484
+ const content = await fetchUrl(url, max_length);
485
+
486
+ if (!content || content.length < 10) {
487
+ return {
488
+ content: [{ type: "text", text: `Could not extract content from ${url}. The page may require JavaScript.` }],
489
+ details: { success: false, url },
490
+ };
491
+ }
236
492
 
493
+ const truncated = content.length >= max_length;
494
+ return {
495
+ content: [{ type: "text", text: `**Content from ${url}:**\n\n${content}${truncated ? "\n\n*(truncated)*" : ""}` }],
496
+ details: { success: true, url, length: content.length, truncated },
497
+ };
237
498
  } catch (error) {
238
499
  return {
239
- content: [{
240
- type: "text",
241
- text: `Web search failed: ${error}. Please check your internet connection and try again.`
242
- }],
243
- details: { error: String(error), found: false, query }
500
+ content: [{ type: "text", text: `Failed to fetch ${url}: ${error}` }],
501
+ details: { success: false, url, error: String(error) },
244
502
  };
245
503
  }
246
504
  },
247
505
  });
248
506
 
249
- /**
250
- * /search command - Quick web search from chat
251
- */
507
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
508
+ // Command: /search
509
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
510
+
252
511
  pi.registerCommand("search", {
253
- description: "Perform a web search (usage: /search <query>)",
512
+ description: "Quick web search (usage: /search <query>)",
254
513
  handler: async (args, ctx) => {
255
514
  const query = args.trim();
256
-
257
- if (!query) {
258
- ctx.ui.notify("Usage: /search <search query>", "warning");
259
- return;
260
- }
515
+ if (!query) { ctx.ui.notify("Usage: /search <query>", "warning"); return; }
261
516
 
262
517
  try {
263
- ctx.ui.notify(`🔍 Searching for: "${query}"...`, "info");
264
-
265
- const results = await performSearch(query, 3); // Fewer results for command
518
+ ctx.ui.notify(`🔍 Searching: "${query}"...`, "info");
519
+ const response = await performSearch(query, 3);
266
520
 
267
- if (results.length === 0) {
268
- ctx.ui.notify("No results found. Try different keywords.", "warning");
521
+ if (response.results.length === 0) {
522
+ ctx.ui.notify("No results found.", "warning");
269
523
  return;
270
524
  }
271
525
 
272
- let message = `🔍 **Search Results for "${query}":**\n\n`;
273
-
274
- results.forEach((result, index) => {
275
- message += `**${index + 1}. ${result.title}**\n`;
276
- message += `${result.url}\n`;
277
- message += `${result.description.slice(0, 100)}...\n\n`;
526
+ let msg = `🔍 **"${query}":**\n\n`;
527
+ response.results.forEach((r, i) => {
528
+ msg += `**${i + 1}. ${r.title}**\n${r.url}\n`;
529
+ if (r.description) msg += `${r.description.slice(0, 100)}...\n`;
530
+ msg += "\n";
278
531
  });
279
-
280
- const method = BRAVE_API_KEY ? "Brave Search" : "DuckDuckGo";
281
- message += `*Powered by ${method}*`;
282
-
283
- ctx.ui.notify(message, "info");
284
-
532
+ msg += `*via ${response.provider}*`;
533
+ ctx.ui.notify(msg, "info");
285
534
  } catch (error) {
286
535
  ctx.ui.notify(`Search failed: ${error}`, "error");
287
536
  }
288
537
  },
289
538
  });
290
539
 
291
- /**
292
- * Show search configuration on session start
293
- */
540
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
541
+ // Session start
542
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
543
+
294
544
  pi.on("session_start", async (_event, ctx) => {
295
- if (BRAVE_API_KEY) {
296
- if (BRAVE_API_KEY.length < 10) {
297
- ctx.ui.notify("⚠️ BRAVE_API_KEY looks invalid (too short). Using DuckDuckGo fallback.", "warning");
298
- } else {
299
- ctx.ui.notify("🌐 Web search enabled (Brave Search API)", "info");
300
- }
301
- } else {
302
- ctx.ui.notify("🌐 Web search enabled (DuckDuckGo fallback — set BRAVE_API_KEY for better results)", "info");
303
- }
545
+ const chain = ["Google", "DuckDuckGo"];
546
+ if (BRAVE_API_KEY) chain.push("Brave");
547
+ const hasReadability = await tryLoadReadability();
548
+ const fetchMode = hasReadability ? "readability+jsdom" : "basic HTML extraction";
549
+ ctx.ui.notify(`🌐 Web search (${chain.join(" ")}) · fetch_url (${fetchMode})`, "info");
304
550
  });
305
- }
551
+ }
package/package.json CHANGED
@@ -1,107 +1,107 @@
1
1
  {
2
- "name": "@phi-code-admin/phi-code",
3
- "version": "0.74.2",
4
- "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
- "type": "module",
6
- "piConfig": {
7
- "name": "phi",
8
- "configDir": ".phi"
9
- },
10
- "bin": {
11
- "phi": "dist/cli.js"
12
- },
13
- "main": "./dist/index.js",
14
- "types": "./dist/index.d.ts",
15
- "exports": {
16
- ".": {
17
- "types": "./dist/index.d.ts",
18
- "import": "./dist/index.js"
19
- },
20
- "./hooks": {
21
- "types": "./dist/core/hooks/index.d.ts",
22
- "import": "./dist/core/hooks/index.js"
23
- }
24
- },
25
- "files": [
26
- "dist",
27
- "skills",
28
- "agents",
29
- "extensions",
30
- "README.md",
31
- "LICENSE",
32
- "CHANGELOG.md",
33
- "scripts"
34
- ],
35
- "scripts": {
36
- "clean": "shx rm -rf dist",
37
- "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
38
- "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets",
39
- "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
40
- "copy-assets": "./scripts/copy-assets.sh",
41
- "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
42
- "test": "vitest --run",
43
- "prepublishOnly": "npm run clean && npm run build",
44
- "postinstall": "node scripts/postinstall.cjs"
45
- },
46
- "dependencies": {
47
- "@mariozechner/jiti": "^2.6.2",
48
- "@silvia-odwyer/photon-node": "^0.3.4",
49
- "chalk": "^5.5.0",
50
- "cli-highlight": "^2.1.11",
51
- "diff": "^8.0.2",
52
- "extract-zip": "^2.0.1",
53
- "file-type": "^21.1.1",
54
- "glob": "^13.0.1",
55
- "hosted-git-info": "^9.0.2",
56
- "ignore": "^7.0.5",
57
- "marked": "^15.0.12",
58
- "minimatch": "^10.2.3",
59
- "phi-code-agent": "^0.56.3",
60
- "phi-code-ai": "^0.56.3",
61
- "phi-code-tui": "^0.56.3",
62
- "proper-lockfile": "^4.1.2",
63
- "sigma-agents": "^0.1.6",
64
- "sigma-memory": "0.2.1",
65
- "sigma-skills": "^0.1.2",
66
- "strip-ansi": "^7.1.0",
67
- "undici": "^7.19.1",
68
- "yaml": "^2.8.2"
69
- },
70
- "overrides": {
71
- "rimraf": "6.1.2",
72
- "gaxios": {
73
- "rimraf": "6.1.2"
74
- }
75
- },
76
- "optionalDependencies": {
77
- "@mariozechner/clipboard": "^0.3.2"
78
- },
79
- "devDependencies": {
80
- "@types/diff": "^7.0.2",
81
- "@types/hosted-git-info": "^3.0.5",
82
- "@types/ms": "^2.1.0",
83
- "@types/node": "^24.3.0",
84
- "@types/proper-lockfile": "^4.1.4",
85
- "shx": "^0.4.0",
86
- "typescript": "^5.7.3",
87
- "vitest": "^3.2.4"
88
- },
89
- "keywords": [
90
- "ai",
91
- "coding-agent",
92
- "cli",
93
- "typescript",
94
- "sub-agents",
95
- "memory"
96
- ],
97
- "author": "Mario Zechner",
98
- "license": "MIT",
99
- "repository": {
100
- "type": "git",
101
- "url": "https://github.com/uglyswap/phi-code.git"
102
- },
103
- "homepage": "https://github.com/uglyswap/phi-code",
104
- "engines": {
105
- "node": ">=20.6.0"
106
- }
107
- }
2
+ "name": "@phi-code-admin/phi-code",
3
+ "version": "0.74.3",
4
+ "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
+ "type": "module",
6
+ "piConfig": {
7
+ "name": "phi",
8
+ "configDir": ".phi"
9
+ },
10
+ "bin": {
11
+ "phi": "dist/cli.js"
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./hooks": {
21
+ "types": "./dist/core/hooks/index.d.ts",
22
+ "import": "./dist/core/hooks/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "skills",
28
+ "agents",
29
+ "extensions",
30
+ "README.md",
31
+ "LICENSE",
32
+ "CHANGELOG.md",
33
+ "scripts"
34
+ ],
35
+ "scripts": {
36
+ "clean": "shx rm -rf dist",
37
+ "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
38
+ "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets",
39
+ "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
40
+ "copy-assets": "./scripts/copy-assets.sh",
41
+ "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
42
+ "test": "vitest --run",
43
+ "prepublishOnly": "npm run clean && npm run build",
44
+ "postinstall": "node scripts/postinstall.cjs"
45
+ },
46
+ "dependencies": {
47
+ "@mariozechner/jiti": "^2.6.2",
48
+ "@silvia-odwyer/photon-node": "^0.3.4",
49
+ "chalk": "^5.5.0",
50
+ "cli-highlight": "^2.1.11",
51
+ "diff": "^8.0.2",
52
+ "extract-zip": "^2.0.1",
53
+ "file-type": "^21.1.1",
54
+ "glob": "^13.0.1",
55
+ "hosted-git-info": "^9.0.2",
56
+ "ignore": "^7.0.5",
57
+ "marked": "^15.0.12",
58
+ "minimatch": "^10.2.3",
59
+ "phi-code-agent": "^0.56.3",
60
+ "phi-code-ai": "^0.56.3",
61
+ "phi-code-tui": "^0.56.3",
62
+ "proper-lockfile": "^4.1.2",
63
+ "sigma-agents": "^0.1.6",
64
+ "sigma-memory": "0.2.1",
65
+ "sigma-skills": "^0.1.2",
66
+ "strip-ansi": "^7.1.0",
67
+ "undici": "^7.19.1",
68
+ "yaml": "^2.8.2"
69
+ },
70
+ "overrides": {
71
+ "rimraf": "6.1.2",
72
+ "gaxios": {
73
+ "rimraf": "6.1.2"
74
+ }
75
+ },
76
+ "optionalDependencies": {
77
+ "@mariozechner/clipboard": "^0.3.2"
78
+ },
79
+ "devDependencies": {
80
+ "@types/diff": "^7.0.2",
81
+ "@types/hosted-git-info": "^3.0.5",
82
+ "@types/ms": "^2.1.0",
83
+ "@types/node": "^24.3.0",
84
+ "@types/proper-lockfile": "^4.1.4",
85
+ "shx": "^0.4.0",
86
+ "typescript": "^5.7.3",
87
+ "vitest": "^3.2.4"
88
+ },
89
+ "keywords": [
90
+ "ai",
91
+ "coding-agent",
92
+ "cli",
93
+ "typescript",
94
+ "sub-agents",
95
+ "memory"
96
+ ],
97
+ "author": "Mario Zechner",
98
+ "license": "MIT",
99
+ "repository": {
100
+ "type": "git",
101
+ "url": "https://github.com/uglyswap/phi-code.git"
102
+ },
103
+ "homepage": "https://github.com/uglyswap/phi-code",
104
+ "engines": {
105
+ "node": ">=20.6.0"
106
+ }
107
+ }