@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.
- package/extensions/phi/web-search.ts +432 -186
- package/package.json +106 -106
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Web Search
|
|
2
|
+
* Web Search & Fetch Extension for Phi Code
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
50
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/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
|
|
64
|
+
// Rate limiting
|
|
34
65
|
let lastRequestTime = 0;
|
|
35
|
-
const MIN_INTERVAL_MS =
|
|
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(
|
|
72
|
+
await new Promise((r) => setTimeout(r, MIN_INTERVAL_MS - elapsed));
|
|
42
73
|
}
|
|
43
74
|
lastRequestTime = Date.now();
|
|
44
75
|
}
|
|
45
76
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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((
|
|
86
|
-
title:
|
|
87
|
-
url:
|
|
88
|
-
description:
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
171
|
-
|
|
374
|
+
} catch {
|
|
375
|
+
// Fall through to basic extraction
|
|
172
376
|
}
|
|
173
377
|
}
|
|
174
378
|
|
|
175
|
-
//
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
435
|
+
if (result.description) resultText += `📄 ${result.description}\n`;
|
|
436
|
+
resultText += "\n";
|
|
220
437
|
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
507
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
508
|
+
// Command: /search
|
|
509
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
510
|
+
|
|
252
511
|
pi.registerCommand("search", {
|
|
253
|
-
description: "
|
|
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
|
|
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.
|
|
521
|
+
if (response.results.length === 0) {
|
|
522
|
+
ctx.ui.notify("No results found.", "warning");
|
|
269
523
|
return;
|
|
270
524
|
}
|
|
271
525
|
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
540
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
541
|
+
// Session start
|
|
542
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
543
|
+
|
|
294
544
|
pi.on("session_start", async (_event, ctx) => {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|