droid-patch 0.1.2 → 0.2.1

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/dist/cli.js CHANGED
@@ -1,12 +1,665 @@
1
1
  #!/usr/bin/env node
2
- import { createAlias, listAliases, patchDroid, removeAlias } from "./alias-DcCF7R2B.js";
2
+ import { createAlias, createAliasForWrapper, listAliases, patchDroid, removeAlias } from "./alias-C9LRaTwF.js";
3
3
  import bin from "tiny-bin";
4
4
  import { styleText } from "node:util";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
9
10
 
11
+ //#region src/websearch-patch.ts
12
+ /**
13
+ * Generate search proxy server code (runs in background)
14
+ * Since BUN_CONFIG_PRELOAD doesn't work with compiled binaries,
15
+ * use a local proxy server to intercept search requests instead
16
+ *
17
+ * Features:
18
+ * - Auto-shutdown on idle (default 5 minutes without requests)
19
+ * - Timeout configurable via DROID_PROXY_IDLE_TIMEOUT env var (seconds)
20
+ * - Set to 0 to disable timeout
21
+ */
22
+ function generateSearchProxyServer() {
23
+ return `#!/usr/bin/env node
24
+ // Droid WebSearch Proxy Server
25
+ // Auto-generated by droid-patch --websearch
26
+
27
+ const http = require('http');
28
+ const https = require('https');
29
+ const { execSync } = require('child_process');
30
+ const fs = require('fs');
31
+
32
+ const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
33
+ const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '23119');
34
+
35
+ // Idle timeout in seconds, default 5 minutes, set to 0 to disable
36
+ const IDLE_TIMEOUT = parseInt(process.env.DROID_PROXY_IDLE_TIMEOUT || '300');
37
+ let lastActivityTime = Date.now();
38
+ let idleCheckTimer = null;
39
+
40
+ function log(...args) {
41
+ if (DEBUG) console.error('[websearch]', ...args);
42
+ }
43
+
44
+ // Update activity time
45
+ function updateActivity() {
46
+ lastActivityTime = Date.now();
47
+ }
48
+
49
+ // Check if any droid process is running (droid instances using the proxy)
50
+ function isDroidRunning() {
51
+ try {
52
+ const { execSync } = require('child_process');
53
+ // Use ps to check if droid.patched binary is running
54
+ // Exclude scripts and grep itself, only match actual droid binary processes
55
+ const result = execSync(
56
+ 'ps aux | grep -E "[d]roid\\\\.patched" | grep -v grep | wc -l',
57
+ { encoding: 'utf-8', timeout: 1000 }
58
+ ).trim();
59
+ return parseInt(result) > 0;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ // Check idle time and possibly exit
66
+ function checkIdleAndExit() {
67
+ if (IDLE_TIMEOUT <= 0) return; // Timeout disabled
68
+
69
+ // If droid process is running, refresh activity time (like heartbeat)
70
+ if (isDroidRunning()) {
71
+ log('Droid process detected, keeping proxy alive');
72
+ updateActivity();
73
+ return;
74
+ }
75
+
76
+ const idleMs = Date.now() - lastActivityTime;
77
+ const timeoutMs = IDLE_TIMEOUT * 1000;
78
+
79
+ if (idleMs >= timeoutMs) {
80
+ log(\`Idle for \${Math.round(idleMs / 1000)}s and no droid running, shutting down...\`);
81
+ cleanup();
82
+ process.exit(0);
83
+ }
84
+ }
85
+
86
+ // Cleanup resources
87
+ function cleanup() {
88
+ if (idleCheckTimer) {
89
+ clearInterval(idleCheckTimer);
90
+ idleCheckTimer = null;
91
+ }
92
+ // Delete PID file
93
+ try {
94
+ fs.unlinkSync('/tmp/droid-search-proxy.pid');
95
+ } catch {}
96
+ }
97
+
98
+ // === Search Implementation ===
99
+
100
+ // Smithery Exa MCP - highest priority, requires SMITHERY_API_KEY and SMITHERY_PROFILE
101
+ async function searchSmitheryExa(query, numResults) {
102
+ const apiKey = process.env.SMITHERY_API_KEY;
103
+ const profile = process.env.SMITHERY_PROFILE;
104
+ if (!apiKey || !profile) return null;
105
+
106
+ try {
107
+ // Construct URL with authentication
108
+ const serverUrl = \`https://server.smithery.ai/exa/mcp?api_key=\${encodeURIComponent(apiKey)}&profile=\${encodeURIComponent(profile)}\`;
109
+ log('Smithery Exa request');
110
+
111
+ // Use MCP protocol to call the search tool via HTTP POST
112
+ const requestBody = JSON.stringify({
113
+ jsonrpc: '2.0',
114
+ id: 1,
115
+ method: 'tools/call',
116
+ params: {
117
+ name: 'web_search_exa',
118
+ arguments: {
119
+ query: query,
120
+ numResults: numResults
121
+ }
122
+ }
123
+ });
124
+
125
+ const curlCmd = \`curl -s -X POST "\${serverUrl}" -H "Content-Type: application/json" -d '\${requestBody.replace(/'/g, "'\\\\\\\\''")}'\`;
126
+ const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 30000 });
127
+ const response = JSON.parse(jsonStr);
128
+
129
+ // Parse MCP response
130
+ if (response.result && response.result.content) {
131
+ // MCP returns content as array of text blocks
132
+ const textContent = response.result.content.find(c => c.type === 'text');
133
+ if (textContent && textContent.text) {
134
+ try {
135
+ const searchResults = JSON.parse(textContent.text);
136
+ if (Array.isArray(searchResults) && searchResults.length > 0) {
137
+ return searchResults.slice(0, numResults).map(item => ({
138
+ title: item.title || '',
139
+ url: item.url || '',
140
+ content: item.text || item.snippet || item.highlights?.join(' ') || '',
141
+ publishedDate: item.publishedDate || null,
142
+ author: item.author || null,
143
+ score: item.score || null
144
+ }));
145
+ }
146
+ } catch (parseErr) {
147
+ log('Smithery response parsing failed');
148
+ }
149
+ }
150
+ }
151
+
152
+ if (response.error) {
153
+ log('Smithery Exa error:', response.error.message || response.error);
154
+ return null;
155
+ }
156
+ } catch (e) {
157
+ log('Smithery Exa failed:', e.message);
158
+ return null;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ async function searchGooglePSE(query, numResults) {
164
+ const apiKey = process.env.GOOGLE_PSE_API_KEY;
165
+ const cx = process.env.GOOGLE_PSE_CX;
166
+ if (!apiKey || !cx) return null;
167
+
168
+ try {
169
+ const url = \`https://www.googleapis.com/customsearch/v1?key=\${apiKey}&cx=\${cx}&q=\${encodeURIComponent(query)}&num=\${Math.min(numResults, 10)}\`;
170
+ log('Google PSE request:', url.replace(apiKey, '***'));
171
+
172
+ const curlCmd = \`curl -s "\${url}"\`;
173
+ const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
174
+ const data = JSON.parse(jsonStr);
175
+
176
+ if (data.error) {
177
+ log('Google PSE error:', data.error.message);
178
+ return null;
179
+ }
180
+ return (data.items || []).map(item => ({
181
+ title: item.title,
182
+ url: item.link,
183
+ content: item.snippet || '',
184
+ publishedDate: null,
185
+ author: null,
186
+ score: null
187
+ }));
188
+ } catch (e) {
189
+ log('Google PSE failed:', e.message);
190
+ return null;
191
+ }
192
+ }
193
+
194
+ // SearXNG - self-hosted meta search engine
195
+ async function searchSearXNG(query, numResults) {
196
+ const searxngUrl = process.env.SEARXNG_URL;
197
+ if (!searxngUrl) return null;
198
+
199
+ try {
200
+ const url = \`\${searxngUrl}/search?q=\${encodeURIComponent(query)}&format=json&engines=google,bing,duckduckgo\`;
201
+ log('SearXNG request:', url);
202
+
203
+ const curlCmd = \`curl -s "\${url}" -H "Accept: application/json"\`;
204
+ const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
205
+ const data = JSON.parse(jsonStr);
206
+
207
+ if (data.results && data.results.length > 0) {
208
+ return data.results.slice(0, numResults).map(item => ({
209
+ title: item.title,
210
+ url: item.url,
211
+ content: item.content || '',
212
+ publishedDate: null,
213
+ author: null,
214
+ score: null
215
+ }));
216
+ }
217
+ } catch (e) {
218
+ log('SearXNG failed:', e.message);
219
+ }
220
+ return null;
221
+ }
222
+
223
+ // Serper API - free tier available (2500 queries/month)
224
+ async function searchSerper(query, numResults) {
225
+ const apiKey = process.env.SERPER_API_KEY;
226
+ if (!apiKey) return null;
227
+
228
+ try {
229
+ const curlCmd = \`curl -s "https://google.serper.dev/search" -H "X-API-KEY: \${apiKey}" -H "Content-Type: application/json" -d '{"q":"\${query.replace(/"/g, '\\\\"')}","num":\${numResults}}'\`;
230
+ log('Serper request');
231
+
232
+ const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
233
+ const data = JSON.parse(jsonStr);
234
+
235
+ if (data.organic && data.organic.length > 0) {
236
+ return data.organic.slice(0, numResults).map(item => ({
237
+ title: item.title,
238
+ url: item.link,
239
+ content: item.snippet || '',
240
+ publishedDate: null,
241
+ author: null,
242
+ score: null
243
+ }));
244
+ }
245
+ } catch (e) {
246
+ log('Serper failed:', e.message);
247
+ }
248
+ return null;
249
+ }
250
+
251
+ // Brave Search API - free tier available
252
+ async function searchBrave(query, numResults) {
253
+ const apiKey = process.env.BRAVE_API_KEY;
254
+ if (!apiKey) return null;
255
+
256
+ try {
257
+ const url = \`https://api.search.brave.com/res/v1/web/search?q=\${encodeURIComponent(query)}&count=\${numResults}\`;
258
+ const curlCmd = \`curl -s "\${url}" -H "Accept: application/json" -H "X-Subscription-Token: \${apiKey}"\`;
259
+ log('Brave request');
260
+
261
+ const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
262
+ const data = JSON.parse(jsonStr);
263
+
264
+ if (data.web && data.web.results && data.web.results.length > 0) {
265
+ return data.web.results.slice(0, numResults).map(item => ({
266
+ title: item.title,
267
+ url: item.url,
268
+ content: item.description || '',
269
+ publishedDate: null,
270
+ author: null,
271
+ score: null
272
+ }));
273
+ }
274
+ } catch (e) {
275
+ log('Brave failed:', e.message);
276
+ }
277
+ return null;
278
+ }
279
+
280
+ // DuckDuckGo - limited reliability due to bot detection
281
+ async function searchDuckDuckGo(query, numResults) {
282
+ // DuckDuckGo Instant Answer API (limited results but more reliable)
283
+ try {
284
+ const apiUrl = \`https://api.duckduckgo.com/?q=\${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1\`;
285
+ const curlCmd = \`curl -s "\${apiUrl}" -H "User-Agent: Mozilla/5.0"\`;
286
+ const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
287
+ const data = JSON.parse(jsonStr);
288
+
289
+ const results = [];
290
+
291
+ if (data.Abstract && data.AbstractURL) {
292
+ results.push({
293
+ title: data.Heading || query,
294
+ url: data.AbstractURL,
295
+ content: data.Abstract,
296
+ publishedDate: null,
297
+ author: null,
298
+ score: null
299
+ });
300
+ }
301
+
302
+ for (const topic of (data.RelatedTopics || [])) {
303
+ if (results.length >= numResults) break;
304
+ if (topic.Text && topic.FirstURL) {
305
+ results.push({
306
+ title: topic.Text.substring(0, 100),
307
+ url: topic.FirstURL,
308
+ content: topic.Text,
309
+ publishedDate: null,
310
+ author: null,
311
+ score: null
312
+ });
313
+ }
314
+ if (topic.Topics) {
315
+ for (const st of topic.Topics) {
316
+ if (results.length >= numResults) break;
317
+ if (st.Text && st.FirstURL) {
318
+ results.push({
319
+ title: st.Text.substring(0, 100),
320
+ url: st.FirstURL,
321
+ content: st.Text,
322
+ publishedDate: null,
323
+ author: null,
324
+ score: null
325
+ });
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ if (results.length > 0) {
332
+ log('DDG API:', results.length, 'results');
333
+ return results;
334
+ }
335
+ } catch (e) {
336
+ log('DDG API failed:', e.message);
337
+ }
338
+
339
+ return [];
340
+ }
341
+
342
+ function parseDDGLiteHTML(html, maxResults) {
343
+ const results = [];
344
+ const linkRegex = /<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>([^<]+)<\\/a>/gi;
345
+ const snippetRegex = /<td[^>]*class="result-snippet"[^>]*>([^<]*)<\\/td>/gi;
346
+
347
+ const links = [];
348
+ let match;
349
+
350
+ while ((match = linkRegex.exec(html)) !== null && links.length < maxResults) {
351
+ let url = match[1];
352
+ if (url.includes('duckduckgo.com') && !url.includes('uddg=')) continue;
353
+ if (url.includes('uddg=')) {
354
+ const uddgMatch = url.match(/uddg=([^&]+)/);
355
+ if (uddgMatch) url = decodeURIComponent(uddgMatch[1]);
356
+ }
357
+ links.push({
358
+ url: url,
359
+ title: decodeHTMLEntities(match[2].trim())
360
+ });
361
+ }
362
+
363
+ const snippets = [];
364
+ while ((match = snippetRegex.exec(html)) !== null && snippets.length < maxResults) {
365
+ snippets.push(decodeHTMLEntities(match[1].trim()));
366
+ }
367
+
368
+ for (let i = 0; i < links.length && results.length < maxResults; i++) {
369
+ results.push({
370
+ title: links[i].title,
371
+ url: links[i].url,
372
+ content: snippets[i] || '',
373
+ publishedDate: null,
374
+ author: null,
375
+ score: null
376
+ });
377
+ }
378
+
379
+ return results;
380
+ }
381
+
382
+ function decodeHTMLEntities(str) {
383
+ return str
384
+ .replace(/&amp;/g, '&')
385
+ .replace(/&lt;/g, '<')
386
+ .replace(/&gt;/g, '>')
387
+ .replace(/&quot;/g, '"')
388
+ .replace(/&#39;/g, "'")
389
+ .replace(/&nbsp;/g, ' ');
390
+ }
391
+
392
+ async function search(query, numResults = 10) {
393
+ // Priority order:
394
+ // 1. Smithery Exa MCP (best quality if configured)
395
+ // 2. Google PSE (most reliable if configured)
396
+ // 3. Serper (free tier: 2500/month)
397
+ // 4. Brave Search (free tier available)
398
+ // 5. SearXNG (self-hosted)
399
+ // 6. DuckDuckGo (limited due to bot detection)
400
+
401
+ // 1. Smithery Exa MCP (highest priority)
402
+ const smitheryResults = await searchSmitheryExa(query, numResults);
403
+ if (smitheryResults && smitheryResults.length > 0) {
404
+ log('Using Smithery Exa');
405
+ return { results: smitheryResults, source: 'smithery-exa' };
406
+ }
407
+
408
+ // 2. Google PSE
409
+ const googleResults = await searchGooglePSE(query, numResults);
410
+ if (googleResults && googleResults.length > 0) {
411
+ log('Using Google PSE');
412
+ return { results: googleResults, source: 'google-pse' };
413
+ }
414
+
415
+ // 3. Serper
416
+ const serperResults = await searchSerper(query, numResults);
417
+ if (serperResults && serperResults.length > 0) {
418
+ log('Using Serper');
419
+ return { results: serperResults, source: 'serper' };
420
+ }
421
+
422
+ // 4. Brave Search
423
+ const braveResults = await searchBrave(query, numResults);
424
+ if (braveResults && braveResults.length > 0) {
425
+ log('Using Brave Search');
426
+ return { results: braveResults, source: 'brave' };
427
+ }
428
+
429
+ // 5. SearXNG
430
+ const searxngResults = await searchSearXNG(query, numResults);
431
+ if (searxngResults && searxngResults.length > 0) {
432
+ log('Using SearXNG');
433
+ return { results: searxngResults, source: 'searxng' };
434
+ }
435
+
436
+ // 6. DuckDuckGo (last resort, limited results)
437
+ log('Using DuckDuckGo (fallback)');
438
+ const ddgResults = await searchDuckDuckGo(query, numResults);
439
+ return { results: ddgResults, source: 'duckduckgo' };
440
+ }
441
+
442
+ // === HTTP Proxy Server ===
443
+
444
+ const FACTORY_API = 'https://api.factory.ai';
445
+
446
+ const server = http.createServer(async (req, res) => {
447
+ const url = new URL(req.url, \`http://\${req.headers.host}\`);
448
+
449
+ // Health check - don't update activity time to avoid self-ping preventing timeout
450
+ if (url.pathname === '/health') {
451
+ const idleSeconds = Math.round((Date.now() - lastActivityTime) / 1000);
452
+ const droidRunning = isDroidRunning();
453
+ res.writeHead(200, { 'Content-Type': 'application/json' });
454
+ res.end(JSON.stringify({
455
+ status: 'ok',
456
+ port: PORT,
457
+ idleTimeout: IDLE_TIMEOUT,
458
+ idleSeconds: idleSeconds,
459
+ droidRunning: droidRunning,
460
+ // If droid is running, won't shutdown; otherwise calculate based on idle time
461
+ willShutdownIn: IDLE_TIMEOUT > 0 && !droidRunning ? Math.max(0, IDLE_TIMEOUT - idleSeconds) : null
462
+ }));
463
+ return;
464
+ }
465
+
466
+ // Update activity time (only non-health-check requests refresh it)
467
+ updateActivity();
468
+
469
+ // Search endpoint - intercept
470
+ if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
471
+ let body = '';
472
+ req.on('data', c => body += c);
473
+ req.on('end', async () => {
474
+ try {
475
+ const { query, numResults } = JSON.parse(body);
476
+ log('Search query:', query);
477
+ const { results, source } = await search(query, numResults || 10);
478
+ log('Results:', results.length, 'from', source);
479
+ res.writeHead(200, { 'Content-Type': 'application/json' });
480
+ res.end(JSON.stringify({ results }));
481
+ } catch (e) {
482
+ log('Search error:', e.message);
483
+ res.writeHead(500, { 'Content-Type': 'application/json' });
484
+ res.end(JSON.stringify({ error: String(e), results: [] }));
485
+ }
486
+ });
487
+ return;
488
+ }
489
+
490
+ // Proxy all other requests to Factory API
491
+ log('Proxy:', req.method, url.pathname);
492
+
493
+ const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
494
+ const proxyReq = https.request(proxyUrl, {
495
+ method: req.method,
496
+ headers: { ...req.headers, host: proxyUrl.host }
497
+ }, proxyRes => {
498
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
499
+ proxyRes.pipe(res);
500
+ });
501
+
502
+ proxyReq.on('error', e => {
503
+ log('Proxy error:', e.message);
504
+ res.writeHead(502, { 'Content-Type': 'application/json' });
505
+ res.end(JSON.stringify({ error: 'Proxy failed: ' + e.message }));
506
+ });
507
+
508
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
509
+ req.pipe(proxyReq);
510
+ } else {
511
+ proxyReq.end();
512
+ }
513
+ });
514
+
515
+ // If port is 0, system will automatically assign an available port
516
+ server.listen(PORT, '127.0.0.1', () => {
517
+ const actualPort = server.address().port;
518
+ const hasGoogle = process.env.GOOGLE_PSE_API_KEY && process.env.GOOGLE_PSE_CX;
519
+
520
+ // Write port file for parent process to read
521
+ const portFile = process.env.SEARCH_PROXY_PORT_FILE;
522
+ if (portFile) {
523
+ fs.writeFileSync(portFile, String(actualPort));
524
+ }
525
+
526
+ const hasSmithery = process.env.SMITHERY_API_KEY && process.env.SMITHERY_PROFILE;
527
+ log('Search proxy started on http://127.0.0.1:' + actualPort);
528
+ log('Smithery Exa:', hasSmithery ? 'configured (priority 1)' : 'not set');
529
+ log('Google PSE:', hasGoogle ? 'configured' : 'not set');
530
+ log('Serper:', process.env.SERPER_API_KEY ? 'configured' : 'not set');
531
+ log('Brave:', process.env.BRAVE_API_KEY ? 'configured' : 'not set');
532
+ log('SearXNG:', process.env.SEARXNG_URL || 'not set');
533
+
534
+ // Start idle check timer
535
+ // Check interval = min(timeout/2, 30s) to ensure timely timeout detection
536
+ if (IDLE_TIMEOUT > 0) {
537
+ const checkInterval = Math.min(IDLE_TIMEOUT * 500, 30000); // milliseconds
538
+ log(\`Idle timeout: \${IDLE_TIMEOUT}s (will auto-shutdown when idle)\`);
539
+ idleCheckTimer = setInterval(checkIdleAndExit, checkInterval);
540
+ } else {
541
+ log('Idle timeout: disabled (will run forever)');
542
+ }
543
+ });
544
+
545
+ process.on('SIGTERM', () => { cleanup(); server.close(); process.exit(0); });
546
+ process.on('SIGINT', () => { cleanup(); server.close(); process.exit(0); });
547
+ `;
548
+ }
549
+ /**
550
+ * Generate unified Wrapper script
551
+ * Uses shared proxy server mode:
552
+ * - All droid instances share the same proxy process
553
+ * - Proxy starts automatically if not running
554
+ * - Proxy runs as background daemon, doesn't exit with droid
555
+ */
556
+ function generateUnifiedWrapper(droidPath, proxyScriptPath) {
557
+ return `#!/bin/bash
558
+ # Droid with WebSearch
559
+ # Auto-generated by droid-patch --websearch
560
+
561
+ PROXY_SCRIPT="${proxyScriptPath}"
562
+ DROID_BIN="${droidPath}"
563
+ PORT=23119
564
+ PID_FILE="/tmp/droid-search-proxy.pid"
565
+ LOG_FILE="/tmp/droid-search-proxy.log"
566
+
567
+ # Check if proxy is running
568
+ is_proxy_running() {
569
+ if [ -f "$PID_FILE" ]; then
570
+ local pid
571
+ pid=$(cat "$PID_FILE")
572
+ if kill -0 "$pid" 2>/dev/null; then
573
+ # Process exists, check if port responds
574
+ if curl -s "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then
575
+ return 0
576
+ fi
577
+ fi
578
+ # PID file exists but process doesn't exist or port not responding, cleanup
579
+ rm -f "$PID_FILE"
580
+ fi
581
+ return 1
582
+ }
583
+
584
+ # Start shared proxy
585
+ start_shared_proxy() {
586
+ # First check if port is occupied by another program
587
+ if lsof -i:"$PORT" > /dev/null 2>&1; then
588
+ # Port is occupied, check if it's our proxy
589
+ if curl -s "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then
590
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy already running on port $PORT" >&2
591
+ return 0
592
+ else
593
+ echo "[websearch] Port $PORT is occupied by another process" >&2
594
+ return 1
595
+ fi
596
+ fi
597
+
598
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Starting shared proxy on port $PORT..." >&2
599
+
600
+ # Start proxy as background daemon
601
+ SEARCH_PROXY_PORT="$PORT" nohup node "$PROXY_SCRIPT" >> "$LOG_FILE" 2>&1 &
602
+ echo $! > "$PID_FILE"
603
+
604
+ # Wait for proxy to start
605
+ for i in {1..30}; do
606
+ if curl -s "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then
607
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy ready on port $PORT (PID: $(cat $PID_FILE))" >&2
608
+ return 0
609
+ fi
610
+ sleep 0.1
611
+ done
612
+
613
+ echo "[websearch] Failed to start proxy" >&2
614
+ rm -f "$PID_FILE"
615
+ return 1
616
+ }
617
+
618
+ # Ensure proxy is running
619
+ ensure_proxy() {
620
+ if is_proxy_running; then
621
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Using existing proxy on port $PORT" >&2
622
+ return 0
623
+ fi
624
+ start_shared_proxy
625
+ }
626
+
627
+ # Start/reuse proxy
628
+ if ! ensure_proxy; then
629
+ echo "[websearch] Running without search proxy" >&2
630
+ exec "$DROID_BIN" "$@"
631
+ fi
632
+
633
+ # Run droid, set API to point to local proxy
634
+ export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:$PORT"
635
+ exec "$DROID_BIN" "$@"
636
+ `;
637
+ }
638
+ /**
639
+ * Create unified WebSearch files
640
+ *
641
+ * Approach: Proxy server mode
642
+ * - wrapper script starts local proxy server
643
+ * - proxy server intercepts search requests, passes through other requests
644
+ * - uses FACTORY_API_BASE_URL_OVERRIDE env var to point to proxy
645
+ * - alias works directly, no extra steps needed
646
+ */
647
+ async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName) {
648
+ if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
649
+ const proxyScriptPath = join(outputDir, `${aliasName}-proxy.js`);
650
+ const wrapperScriptPath = join(outputDir, aliasName);
651
+ await writeFile(proxyScriptPath, generateSearchProxyServer());
652
+ console.log(`[*] Created proxy script: ${proxyScriptPath}`);
653
+ await writeFile(wrapperScriptPath, generateUnifiedWrapper(droidPath, proxyScriptPath));
654
+ await chmod(wrapperScriptPath, 493);
655
+ console.log(`[*] Created wrapper: ${wrapperScriptPath}`);
656
+ return {
657
+ wrapperScript: wrapperScriptPath,
658
+ preloadScript: proxyScriptPath
659
+ };
660
+ }
661
+
662
+ //#endregion
10
663
  //#region src/cli.ts
11
664
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
665
  function getVersion() {
@@ -29,26 +682,70 @@ function findDefaultDroidPath() {
29
682
  for (const p of paths) if (existsSync(p)) return p;
30
683
  return join(home, ".droid/bin/droid");
31
684
  }
32
- bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
685
+ bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace Factory API base URL (https://api.factory.ai) with custom URL").option("--websearch", "Enable local WebSearch via fetch hook (Google PSE + DuckDuckGo fallback)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
33
686
  const alias = args?.[0];
34
687
  const isCustom = options["is-custom"];
35
688
  const skipLogin = options["skip-login"];
689
+ const apiBase = options["api-base"];
690
+ const webSearch = options["websearch"];
36
691
  const dryRun = options["dry-run"];
37
692
  const path = options.path || findDefaultDroidPath();
38
693
  const outputDir = options.output;
39
694
  const backup = options.backup !== false;
40
695
  const verbose = options.verbose;
41
696
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
42
- if (!isCustom && !skipLogin) {
697
+ if (webSearch && !isCustom && !skipLogin && !apiBase) {
698
+ if (!alias) {
699
+ console.log(styleText("red", "Error: Alias name required for --websearch"));
700
+ console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
701
+ process.exit(1);
702
+ }
703
+ console.log(styleText("cyan", "═".repeat(60)));
704
+ console.log(styleText(["cyan", "bold"], " Droid WebSearch Setup"));
705
+ console.log(styleText("cyan", "═".repeat(60)));
706
+ console.log();
707
+ const websearchDir = join(homedir(), ".droid-patch", "websearch");
708
+ const { wrapperScript } = await createWebSearchUnifiedFiles(websearchDir, path, alias);
709
+ await createAliasForWrapper(wrapperScript, alias, verbose);
710
+ console.log();
711
+ console.log(styleText("green", "═".repeat(60)));
712
+ console.log(styleText(["green", "bold"], " WebSearch Ready!"));
713
+ console.log(styleText("green", "═".repeat(60)));
714
+ console.log();
715
+ console.log("Run directly:");
716
+ console.log(styleText("yellow", ` ${alias}`));
717
+ console.log();
718
+ console.log(styleText("cyan", "Auto-shutdown:"));
719
+ console.log(styleText("gray", " Proxy auto-shuts down after 5 min idle (no manual cleanup needed)"));
720
+ console.log(styleText("gray", " To disable: export DROID_PROXY_IDLE_TIMEOUT=0"));
721
+ console.log();
722
+ console.log("Search providers (in priority order):");
723
+ console.log(styleText("yellow", " 1. Smithery Exa (best quality):"));
724
+ console.log(styleText("gray", " export SMITHERY_API_KEY=your_api_key"));
725
+ console.log(styleText("gray", " export SMITHERY_PROFILE=your_profile"));
726
+ console.log(styleText("gray", " 2. Google PSE:"));
727
+ console.log(styleText("gray", " export GOOGLE_PSE_API_KEY=your_api_key"));
728
+ console.log(styleText("gray", " export GOOGLE_PSE_CX=your_search_engine_id"));
729
+ console.log(styleText("gray", " 3-6. Serper, Brave, SearXNG, DuckDuckGo (fallbacks)"));
730
+ console.log();
731
+ console.log("Debug mode:");
732
+ console.log(styleText("gray", " export DROID_SEARCH_DEBUG=1"));
733
+ return;
734
+ }
735
+ if (!isCustom && !skipLogin && !apiBase && !webSearch) {
43
736
  console.log(styleText("yellow", "No patch flags specified. Available patches:"));
44
737
  console.log(styleText("gray", " --is-custom Patch isCustom for custom models"));
45
738
  console.log(styleText("gray", " --skip-login Bypass login by injecting a fake API key"));
739
+ console.log(styleText("gray", " --api-base Replace Factory API URL with custom server"));
740
+ console.log(styleText("gray", " --websearch Enable local WebSearch (Google PSE + DuckDuckGo)"));
46
741
  console.log();
47
742
  console.log("Usage examples:");
48
743
  console.log(styleText("cyan", " npx droid-patch --is-custom droid-custom"));
49
744
  console.log(styleText("cyan", " npx droid-patch --skip-login droid-nologin"));
50
745
  console.log(styleText("cyan", " npx droid-patch --is-custom --skip-login droid-patched"));
51
746
  console.log(styleText("cyan", " npx droid-patch --skip-login -o . my-droid"));
747
+ console.log(styleText("cyan", " npx droid-patch --api-base http://localhost:3000 droid-local"));
748
+ console.log(styleText("cyan", " npx droid-patch --websearch droid-search"));
52
749
  process.exit(1);
53
750
  }
54
751
  if (!alias && !dryRun) {
@@ -73,6 +770,29 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
73
770
  pattern: Buffer.from("process.env.FACTORY_API_KEY"),
74
771
  replacement: Buffer.from("\"fk-droid-patch-skip-00000\"")
75
772
  });
773
+ if (apiBase) {
774
+ const originalUrl = "https://api.factory.ai";
775
+ const originalLength = originalUrl.length;
776
+ let normalizedUrl = apiBase.replace(/\/+$/, "");
777
+ if (normalizedUrl.length > originalLength) {
778
+ console.log(styleText("red", `Error: API base URL must be ${originalLength} characters or less`));
779
+ console.log(styleText("gray", ` Your URL: "${normalizedUrl}" (${normalizedUrl.length} chars)`));
780
+ console.log(styleText("gray", ` Maximum: ${originalLength} characters`));
781
+ console.log();
782
+ console.log(styleText("yellow", "Tip: Use a shorter URL or set up a local redirect."));
783
+ console.log(styleText("gray", " Examples:"));
784
+ console.log(styleText("gray", " http://127.0.0.1:3000 (19 chars)"));
785
+ console.log(styleText("gray", " http://localhost:80 (19 chars)"));
786
+ process.exit(1);
787
+ }
788
+ const paddedUrl = normalizedUrl.padEnd(originalLength, " ");
789
+ patches.push({
790
+ name: "apiBase",
791
+ description: `Replace Factory API URL with "${normalizedUrl}"`,
792
+ pattern: Buffer.from(originalUrl),
793
+ replacement: Buffer.from(paddedUrl)
794
+ });
795
+ }
76
796
  try {
77
797
  const result = await patchDroid({
78
798
  inputPath: path,
@@ -103,7 +823,22 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
103
823
  }
104
824
  if (result.success && result.outputPath && alias) {
105
825
  console.log();
106
- await createAlias(result.outputPath, alias, verbose);
826
+ if (webSearch) {
827
+ const websearchDir = join(homedir(), ".droid-patch", "websearch");
828
+ const { wrapperScript } = await createWebSearchUnifiedFiles(websearchDir, result.outputPath, alias);
829
+ await createAliasForWrapper(wrapperScript, alias, verbose);
830
+ console.log();
831
+ console.log(styleText("cyan", "WebSearch providers (optional):"));
832
+ console.log(styleText("gray", " Works out of the box with DuckDuckGo fallback"));
833
+ console.log(styleText("gray", " For better results, configure a provider:"));
834
+ console.log();
835
+ console.log(styleText("yellow", " Smithery Exa"), styleText("gray", " - Best quality, free via smithery.ai"));
836
+ console.log(styleText("gray", " export SMITHERY_API_KEY=... SMITHERY_PROFILE=..."));
837
+ console.log(styleText("yellow", " Google PSE"), styleText("gray", " - 10,000/day free"));
838
+ console.log(styleText("gray", " export GOOGLE_PSE_API_KEY=... GOOGLE_PSE_CX=..."));
839
+ console.log();
840
+ console.log(styleText("gray", " See README for all providers and setup guides"));
841
+ } else await createAlias(result.outputPath, alias, verbose);
107
842
  }
108
843
  if (result.success) {
109
844
  console.log();
@@ -122,9 +857,9 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
122
857
  }).command("remove", "Remove a droid-patch alias or patched binary file").argument("<alias-or-path>", "Alias name or file path to remove").action(async (_options, args) => {
123
858
  const target = args[0];
124
859
  if (target.includes("/") || existsSync(target)) {
125
- const { unlink } = await import("node:fs/promises");
860
+ const { unlink: unlink$1 } = await import("node:fs/promises");
126
861
  try {
127
- await unlink(target);
862
+ await unlink$1(target);
128
863
  console.log(styleText("green", `[*] Removed: ${target}`));
129
864
  } catch (error) {
130
865
  console.error(styleText("red", `Error: ${error.message}`));
@@ -133,6 +868,86 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
133
868
  } else await removeAlias(target);
134
869
  }).command("version", "Print droid-patch version").action(() => {
135
870
  console.log(`droid-patch v${version}`);
871
+ }).command("proxy-status", "Check websearch proxy status").action(async () => {
872
+ const pidFile = "/tmp/droid-search-proxy.pid";
873
+ const logFile = "/tmp/droid-search-proxy.log";
874
+ const port = 23119;
875
+ console.log(styleText("cyan", "═".repeat(60)));
876
+ console.log(styleText(["cyan", "bold"], " WebSearch Proxy Status"));
877
+ console.log(styleText("cyan", "═".repeat(60)));
878
+ console.log();
879
+ try {
880
+ const response = await fetch(`http://127.0.0.1:${port}/health`);
881
+ if (response.ok) {
882
+ const data = await response.json();
883
+ console.log(styleText("green", ` Status: Running ✓`));
884
+ console.log(styleText("white", ` Port: ${port}`));
885
+ if (existsSync(pidFile)) {
886
+ const { readFileSync: readFileSync$1 } = await import("node:fs");
887
+ const pid = readFileSync$1(pidFile, "utf-8").trim();
888
+ console.log(styleText("white", ` PID: ${pid}`));
889
+ }
890
+ if (data.droidRunning !== void 0) console.log(styleText("white", ` Droid running: ${data.droidRunning ? "yes (proxy will stay alive)" : "no"}`));
891
+ if (data.idleTimeout !== void 0) if (data.idleTimeout > 0) {
892
+ const idleMins = Math.floor((data.idleSeconds || 0) / 60);
893
+ const idleSecs = (data.idleSeconds || 0) % 60;
894
+ if (data.droidRunning) console.log(styleText("white", ` Idle: ${idleMins}m ${idleSecs}s (won't shutdown while droid runs)`));
895
+ else if (data.willShutdownIn !== null) {
896
+ const shutdownMins = Math.floor((data.willShutdownIn || 0) / 60);
897
+ const shutdownSecs = (data.willShutdownIn || 0) % 60;
898
+ console.log(styleText("white", ` Idle: ${idleMins}m ${idleSecs}s`));
899
+ console.log(styleText("white", ` Auto-shutdown in: ${shutdownMins}m ${shutdownSecs}s`));
900
+ }
901
+ } else console.log(styleText("white", ` Auto-shutdown: disabled`));
902
+ console.log(styleText("white", ` Log: ${logFile}`));
903
+ console.log();
904
+ console.log(styleText("gray", "To stop the proxy manually:"));
905
+ console.log(styleText("cyan", " npx droid-patch proxy-stop"));
906
+ console.log();
907
+ console.log(styleText("gray", "To disable auto-shutdown:"));
908
+ console.log(styleText("cyan", " export DROID_PROXY_IDLE_TIMEOUT=0"));
909
+ }
910
+ } catch {
911
+ console.log(styleText("yellow", ` Status: Not running`));
912
+ console.log();
913
+ console.log(styleText("gray", "The proxy will start automatically when you run droid-full."));
914
+ console.log(styleText("gray", "It will auto-shutdown after 5 minutes of idle (configurable)."));
915
+ }
916
+ console.log();
917
+ }).command("proxy-stop", "Stop the websearch proxy").action(async () => {
918
+ const pidFile = "/tmp/droid-search-proxy.pid";
919
+ if (!existsSync(pidFile)) {
920
+ console.log(styleText("yellow", "Proxy is not running (no PID file)"));
921
+ return;
922
+ }
923
+ try {
924
+ const { readFileSync: readFileSync$1, unlinkSync: unlinkSync$1 } = await import("node:fs");
925
+ const pid = readFileSync$1(pidFile, "utf-8").trim();
926
+ process.kill(parseInt(pid), "SIGTERM");
927
+ unlinkSync$1(pidFile);
928
+ console.log(styleText("green", `[*] Proxy stopped (PID: ${pid})`));
929
+ } catch (error) {
930
+ console.log(styleText("yellow", `[!] Could not stop proxy: ${error.message}`));
931
+ try {
932
+ const { unlinkSync: unlinkSync$1 } = await import("node:fs");
933
+ unlinkSync$1(pidFile);
934
+ console.log(styleText("gray", "Cleaned up stale PID file"));
935
+ } catch {}
936
+ }
937
+ }).command("proxy-log", "Show websearch proxy logs").action(async () => {
938
+ const logFile = "/tmp/droid-search-proxy.log";
939
+ if (!existsSync(logFile)) {
940
+ console.log(styleText("yellow", "No log file found"));
941
+ return;
942
+ }
943
+ const { readFileSync: readFileSync$1 } = await import("node:fs");
944
+ const log = readFileSync$1(logFile, "utf-8");
945
+ const lines = log.split("\n").slice(-50);
946
+ console.log(styleText("cyan", "═".repeat(60)));
947
+ console.log(styleText(["cyan", "bold"], " WebSearch Proxy Logs (last 50 lines)"));
948
+ console.log(styleText("cyan", "═".repeat(60)));
949
+ console.log();
950
+ console.log(lines.join("\n"));
136
951
  }).run().catch((err) => {
137
952
  console.error(err);
138
953
  process.exit(1);