droid-patch 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as removeAlias, d as listAllMetadata, f as loadAliasMetadata, i as listAliases, l as createMetadata, m as patchDroid, n as createAlias, o as removeAliasesByFilter, p as saveAliasMetadata, r as createAliasForWrapper, t as clearAllAliases, u as formatPatches } from "./alias-C2Iew8yJ.mjs";
2
+ import { a as removeAlias, d as listAllMetadata, f as loadAliasMetadata, i as listAliases, l as createMetadata, m as patchDroid, n as createAlias, o as removeAliasesByFilter, p as saveAliasMetadata, r as createAliasForWrapper, t as clearAllAliases, u as formatPatches } from "./alias-CX4QSelz.mjs";
3
3
  import bin from "tiny-bin";
4
4
  import { styleText } from "node:util";
5
5
  import { existsSync, readFileSync } from "node:fs";
@@ -9,21 +9,16 @@ import { fileURLToPath } from "node:url";
9
9
  import { execSync } from "node:child_process";
10
10
  import { chmod, mkdir, writeFile } from "node:fs/promises";
11
11
 
12
- //#region src/websearch-patch.ts
12
+ //#region src/websearch-external.ts
13
13
  /**
14
- * Generate search proxy server code (runs in background)
15
- * Since BUN_CONFIG_PRELOAD doesn't work with compiled binaries,
16
- * use a local proxy server to intercept search requests instead
14
+ * WebSearch External Providers Mode (--websearch)
17
15
  *
18
- * Each droid instance runs its own proxy server.
19
- * The proxy is killed automatically when droid exits.
20
- * @param factoryApiUrl - Custom Factory API URL (default: https://api.factory.ai)
16
+ * Priority: Smithery Exa > Google PSE > Serper > Brave > SearXNG > DuckDuckGo
21
17
  */
22
- function generateSearchProxyServer(factoryApiUrl = "https://api.factory.ai") {
18
+ function generateSearchProxyServerCode() {
23
19
  return `#!/usr/bin/env node
24
- // Droid WebSearch Proxy Server
25
- // Auto-generated by droid-patch --websearch
26
- // This proxy runs as a child process of droid and is killed when droid exits
20
+ // Droid WebSearch Proxy Server (External Providers Mode)
21
+ // Priority: Smithery Exa > Google PSE > Serper > Brave > SearXNG > DuckDuckGo
27
22
 
28
23
  const http = require('http');
29
24
  const https = require('https');
@@ -31,75 +26,38 @@ const { execSync } = require('child_process');
31
26
  const fs = require('fs');
32
27
 
33
28
  const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
34
- const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0'); // 0 = auto-assign
35
- const FACTORY_API = '${factoryApiUrl}';
29
+ const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0');
30
+ const FACTORY_API = 'https://api.factory.ai';
36
31
 
37
- function log(...args) {
38
- if (DEBUG) console.error('[websearch]', ...args);
39
- }
32
+ function log() { if (DEBUG) console.error.apply(console, ['[websearch]'].concat(Array.from(arguments))); }
40
33
 
41
- // === Search Implementation ===
34
+ // === External Search Providers ===
42
35
 
43
- // Smithery Exa MCP - highest priority, requires SMITHERY_API_KEY and SMITHERY_PROFILE
44
36
  async function searchSmitheryExa(query, numResults) {
45
37
  const apiKey = process.env.SMITHERY_API_KEY;
46
38
  const profile = process.env.SMITHERY_PROFILE;
47
39
  if (!apiKey || !profile) return null;
48
-
49
40
  try {
50
- // Construct URL with authentication
51
- const serverUrl = \`https://server.smithery.ai/exa/mcp?api_key=\${encodeURIComponent(apiKey)}&profile=\${encodeURIComponent(profile)}\`;
52
- log('Smithery Exa request');
53
-
54
- // Use MCP protocol to call the search tool via HTTP POST
55
- const requestBody = JSON.stringify({
56
- jsonrpc: '2.0',
57
- id: 1,
58
- method: 'tools/call',
59
- params: {
60
- name: 'web_search_exa',
61
- arguments: {
62
- query: query,
63
- numResults: numResults
64
- }
65
- }
66
- });
67
-
68
- const curlCmd = \`curl -s -X POST "\${serverUrl}" -H "Content-Type: application/json" -d '\${requestBody.replace(/'/g, "'\\\\\\\\''")}'\`;
69
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 30000 });
70
- const response = JSON.parse(jsonStr);
71
-
72
- // Parse MCP response
41
+ const serverUrl = 'https://server.smithery.ai/exa/mcp?api_key=' + encodeURIComponent(apiKey) + '&profile=' + encodeURIComponent(profile);
42
+ const requestBody = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'web_search_exa', arguments: { query: query, numResults: numResults } } });
43
+ const bodyStr = requestBody.replace(/'/g, "'\\\\''");
44
+ const curlCmd = 'curl -s -X POST "' + serverUrl + '" -H "Content-Type: application/json" -d \\'' + bodyStr + "\\'";
45
+ const response = JSON.parse(execSync(curlCmd, { encoding: 'utf-8', timeout: 30000 }));
73
46
  if (response.result && response.result.content) {
74
- // MCP returns content as array of text blocks
75
- const textContent = response.result.content.find(c => c.type === 'text');
47
+ const textContent = response.result.content.find(function(c) { return c.type === 'text'; });
76
48
  if (textContent && textContent.text) {
77
- try {
78
- const searchResults = JSON.parse(textContent.text);
79
- if (Array.isArray(searchResults) && searchResults.length > 0) {
80
- return searchResults.slice(0, numResults).map(item => ({
81
- title: item.title || '',
82
- url: item.url || '',
83
- content: item.text || item.snippet || item.highlights?.join(' ') || '',
84
- publishedDate: item.publishedDate || null,
85
- author: item.author || null,
86
- score: item.score || null
87
- }));
88
- }
89
- } catch (parseErr) {
90
- log('Smithery response parsing failed');
49
+ const results = JSON.parse(textContent.text);
50
+ if (Array.isArray(results) && results.length > 0) {
51
+ return results.slice(0, numResults).map(function(item) {
52
+ return {
53
+ title: item.title || '', url: item.url || '',
54
+ content: item.text || item.snippet || (item.highlights ? item.highlights.join(' ') : '') || ''
55
+ };
56
+ });
91
57
  }
92
58
  }
93
59
  }
94
-
95
- if (response.error) {
96
- log('Smithery Exa error:', response.error.message || response.error);
97
- return null;
98
- }
99
- } catch (e) {
100
- log('Smithery Exa failed:', e.message);
101
- return null;
102
- }
60
+ } catch (e) { log('Smithery failed:', e.message); }
103
61
  return null;
104
62
  }
105
63
 
@@ -107,305 +65,379 @@ async function searchGooglePSE(query, numResults) {
107
65
  const apiKey = process.env.GOOGLE_PSE_API_KEY;
108
66
  const cx = process.env.GOOGLE_PSE_CX;
109
67
  if (!apiKey || !cx) return null;
110
-
111
68
  try {
112
- const url = \`https://www.googleapis.com/customsearch/v1?key=\${apiKey}&cx=\${cx}&q=\${encodeURIComponent(query)}&num=\${Math.min(numResults, 10)}\`;
113
- log('Google PSE request:', url.replace(apiKey, '***'));
114
-
115
- const curlCmd = \`curl -s "\${url}"\`;
116
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
117
- const data = JSON.parse(jsonStr);
118
-
119
- if (data.error) {
120
- log('Google PSE error:', data.error.message);
121
- return null;
122
- }
123
- return (data.items || []).map(item => ({
124
- title: item.title,
125
- url: item.link,
126
- content: item.snippet || '',
127
- publishedDate: null,
128
- author: null,
129
- score: null
130
- }));
131
- } catch (e) {
132
- log('Google PSE failed:', e.message);
133
- return null;
134
- }
135
- }
136
-
137
- // SearXNG - self-hosted meta search engine
138
- async function searchSearXNG(query, numResults) {
139
- const searxngUrl = process.env.SEARXNG_URL;
140
- if (!searxngUrl) return null;
141
-
142
- try {
143
- const url = \`\${searxngUrl}/search?q=\${encodeURIComponent(query)}&format=json&engines=google,bing,duckduckgo\`;
144
- log('SearXNG request:', url);
145
-
146
- const curlCmd = \`curl -s "\${url}" -H "Accept: application/json"\`;
147
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
148
- const data = JSON.parse(jsonStr);
149
-
150
- if (data.results && data.results.length > 0) {
151
- return data.results.slice(0, numResults).map(item => ({
152
- title: item.title,
153
- url: item.url,
154
- content: item.content || '',
155
- publishedDate: null,
156
- author: null,
157
- score: null
158
- }));
159
- }
160
- } catch (e) {
161
- log('SearXNG failed:', e.message);
162
- }
69
+ const url = 'https://www.googleapis.com/customsearch/v1?key=' + apiKey + '&cx=' + cx + '&q=' + encodeURIComponent(query) + '&num=' + Math.min(numResults, 10);
70
+ const data = JSON.parse(execSync('curl -s "' + url + '"', { encoding: 'utf-8', timeout: 15000 }));
71
+ if (data.error) return null;
72
+ return (data.items || []).map(function(item) { return { title: item.title, url: item.link, content: item.snippet || '' }; });
73
+ } catch (e) { log('Google PSE failed:', e.message); }
163
74
  return null;
164
75
  }
165
76
 
166
- // Serper API - free tier available (2500 queries/month)
167
77
  async function searchSerper(query, numResults) {
168
78
  const apiKey = process.env.SERPER_API_KEY;
169
79
  if (!apiKey) return null;
170
-
171
80
  try {
172
- 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}}'\`;
173
- log('Serper request');
174
-
175
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
176
- const data = JSON.parse(jsonStr);
177
-
81
+ const bodyStr = JSON.stringify({ q: query, num: numResults }).replace(/'/g, "'\\\\''");
82
+ const curlCmd = 'curl -s "https://google.serper.dev/search" -H "X-API-KEY: ' + apiKey + '" -H "Content-Type: application/json" -d \\'' + bodyStr + "\\'";
83
+ const data = JSON.parse(execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 }));
178
84
  if (data.organic && data.organic.length > 0) {
179
- return data.organic.slice(0, numResults).map(item => ({
180
- title: item.title,
181
- url: item.link,
182
- content: item.snippet || '',
183
- publishedDate: null,
184
- author: null,
185
- score: null
186
- }));
85
+ return data.organic.slice(0, numResults).map(function(item) { return { title: item.title, url: item.link, content: item.snippet || '' }; });
187
86
  }
188
- } catch (e) {
189
- log('Serper failed:', e.message);
190
- }
87
+ } catch (e) { log('Serper failed:', e.message); }
191
88
  return null;
192
89
  }
193
90
 
194
- // Brave Search API - free tier available
195
91
  async function searchBrave(query, numResults) {
196
92
  const apiKey = process.env.BRAVE_API_KEY;
197
93
  if (!apiKey) return null;
198
-
199
94
  try {
200
- const url = \`https://api.search.brave.com/res/v1/web/search?q=\${encodeURIComponent(query)}&count=\${numResults}\`;
201
- const curlCmd = \`curl -s "\${url}" -H "Accept: application/json" -H "X-Subscription-Token: \${apiKey}"\`;
202
- log('Brave request');
203
-
204
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
205
- const data = JSON.parse(jsonStr);
206
-
95
+ const url = 'https://api.search.brave.com/res/v1/web/search?q=' + encodeURIComponent(query) + '&count=' + numResults;
96
+ const curlCmd = 'curl -s "' + url + '" -H "Accept: application/json" -H "X-Subscription-Token: ' + apiKey + '"';
97
+ const data = JSON.parse(execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 }));
207
98
  if (data.web && data.web.results && data.web.results.length > 0) {
208
- return data.web.results.slice(0, numResults).map(item => ({
209
- title: item.title,
210
- url: item.url,
211
- content: item.description || '',
212
- publishedDate: null,
213
- author: null,
214
- score: null
215
- }));
99
+ return data.web.results.slice(0, numResults).map(function(item) { return { title: item.title, url: item.url, content: item.description || '' }; });
216
100
  }
217
- } catch (e) {
218
- log('Brave failed:', e.message);
219
- }
101
+ } catch (e) { log('Brave failed:', e.message); }
220
102
  return null;
221
103
  }
222
104
 
223
- // DuckDuckGo - limited reliability due to bot detection
224
- async function searchDuckDuckGo(query, numResults) {
225
- // DuckDuckGo Instant Answer API (limited results but more reliable)
105
+ async function searchSearXNG(query, numResults) {
106
+ const searxngUrl = process.env.SEARXNG_URL;
107
+ if (!searxngUrl) return null;
226
108
  try {
227
- const apiUrl = \`https://api.duckduckgo.com/?q=\${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1\`;
228
- const curlCmd = \`curl -s "\${apiUrl}" -H "User-Agent: Mozilla/5.0"\`;
229
- const jsonStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 15000 });
230
- const data = JSON.parse(jsonStr);
109
+ const url = searxngUrl + '/search?q=' + encodeURIComponent(query) + '&format=json&engines=google,bing,duckduckgo';
110
+ const data = JSON.parse(execSync('curl -s "' + url + '" -H "Accept: application/json"', { encoding: 'utf-8', timeout: 15000 }));
111
+ if (data.results && data.results.length > 0) {
112
+ return data.results.slice(0, numResults).map(function(item) { return { title: item.title, url: item.url, content: item.content || '' }; });
113
+ }
114
+ } catch (e) { log('SearXNG failed:', e.message); }
115
+ return null;
116
+ }
231
117
 
118
+ async function searchDuckDuckGo(query, numResults) {
119
+ try {
120
+ const apiUrl = 'https://api.duckduckgo.com/?q=' + encodeURIComponent(query) + '&format=json&no_html=1&skip_disambig=1';
121
+ const data = JSON.parse(execSync('curl -s "' + apiUrl + '" -H "User-Agent: Mozilla/5.0"', { encoding: 'utf-8', timeout: 15000 }));
232
122
  const results = [];
233
-
234
123
  if (data.Abstract && data.AbstractURL) {
235
- results.push({
236
- title: data.Heading || query,
237
- url: data.AbstractURL,
238
- content: data.Abstract,
239
- publishedDate: null,
240
- author: null,
241
- score: null
242
- });
124
+ results.push({ title: data.Heading || query, url: data.AbstractURL, content: data.Abstract });
243
125
  }
244
-
245
- for (const topic of (data.RelatedTopics || [])) {
246
- if (results.length >= numResults) break;
247
- if (topic.Text && topic.FirstURL) {
248
- results.push({
249
- title: topic.Text.substring(0, 100),
250
- url: topic.FirstURL,
251
- content: topic.Text,
252
- publishedDate: null,
253
- author: null,
254
- score: null
255
- });
256
- }
126
+ var topics = data.RelatedTopics || [];
127
+ for (var i = 0; i < topics.length && results.length < numResults; i++) {
128
+ var topic = topics[i];
129
+ if (topic.Text && topic.FirstURL) results.push({ title: topic.Text.substring(0, 100), url: topic.FirstURL, content: topic.Text });
257
130
  if (topic.Topics) {
258
- for (const st of topic.Topics) {
259
- if (results.length >= numResults) break;
260
- if (st.Text && st.FirstURL) {
261
- results.push({
262
- title: st.Text.substring(0, 100),
263
- url: st.FirstURL,
264
- content: st.Text,
265
- publishedDate: null,
266
- author: null,
267
- score: null
268
- });
269
- }
131
+ for (var j = 0; j < topic.Topics.length && results.length < numResults; j++) {
132
+ var st = topic.Topics[j];
133
+ if (st.Text && st.FirstURL) results.push({ title: st.Text.substring(0, 100), url: st.FirstURL, content: st.Text });
270
134
  }
271
135
  }
272
136
  }
273
-
274
- if (results.length > 0) {
275
- log('DDG API:', results.length, 'results');
276
- return results;
277
- }
278
- } catch (e) {
279
- log('DDG API failed:', e.message);
280
- }
281
-
282
- return [];
137
+ return results.length > 0 ? results : null;
138
+ } catch (e) { log('DuckDuckGo failed:', e.message); }
139
+ return null;
283
140
  }
284
141
 
285
- function parseDDGLiteHTML(html, maxResults) {
286
- const results = [];
287
- const linkRegex = /<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>([^<]+)<\\/a>/gi;
288
- const snippetRegex = /<td[^>]*class="result-snippet"[^>]*>([^<]*)<\\/td>/gi;
142
+ async function search(query, numResults) {
143
+ numResults = numResults || 10;
144
+ log('Search:', query);
145
+
146
+ // Priority: Smithery > Google PSE > Serper > Brave > SearXNG > DuckDuckGo
147
+ var results = await searchSmitheryExa(query, numResults);
148
+ if (results && results.length > 0) return { results: results, source: 'smithery-exa' };
149
+
150
+ results = await searchGooglePSE(query, numResults);
151
+ if (results && results.length > 0) return { results: results, source: 'google-pse' };
152
+
153
+ results = await searchSerper(query, numResults);
154
+ if (results && results.length > 0) return { results: results, source: 'serper' };
155
+
156
+ results = await searchBrave(query, numResults);
157
+ if (results && results.length > 0) return { results: results, source: 'brave' };
158
+
159
+ results = await searchSearXNG(query, numResults);
160
+ if (results && results.length > 0) return { results: results, source: 'searxng' };
161
+
162
+ results = await searchDuckDuckGo(query, numResults);
163
+ if (results && results.length > 0) return { results: results, source: 'duckduckgo' };
164
+
165
+ return { results: [], source: 'none' };
166
+ }
289
167
 
290
- const links = [];
291
- let match;
168
+ // === HTTP Proxy Server ===
292
169
 
293
- while ((match = linkRegex.exec(html)) !== null && links.length < maxResults) {
294
- let url = match[1];
295
- if (url.includes('duckduckgo.com') && !url.includes('uddg=')) continue;
296
- if (url.includes('uddg=')) {
297
- const uddgMatch = url.match(/uddg=([^&]+)/);
298
- if (uddgMatch) url = decodeURIComponent(uddgMatch[1]);
299
- }
300
- links.push({
301
- url: url,
302
- title: decodeHTMLEntities(match[2].trim())
303
- });
304
- }
170
+ const server = http.createServer(async (req, res) => {
171
+ const url = new URL(req.url, 'http://' + req.headers.host);
305
172
 
306
- const snippets = [];
307
- while ((match = snippetRegex.exec(html)) !== null && snippets.length < maxResults) {
308
- snippets.push(decodeHTMLEntities(match[1].trim()));
173
+ if (url.pathname === '/health') {
174
+ res.writeHead(200, { 'Content-Type': 'application/json' });
175
+ res.end(JSON.stringify({ status: 'ok', mode: 'external-providers' }));
176
+ return;
309
177
  }
310
178
 
311
- for (let i = 0; i < links.length && results.length < maxResults; i++) {
312
- results.push({
313
- title: links[i].title,
314
- url: links[i].url,
315
- content: snippets[i] || '',
316
- publishedDate: null,
317
- author: null,
318
- score: null
179
+ if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
180
+ let body = '';
181
+ req.on('data', function(c) { body += c; });
182
+ req.on('end', async function() {
183
+ try {
184
+ const parsed = JSON.parse(body);
185
+ const result = await search(parsed.query, parsed.numResults || 10);
186
+ log('Results:', result.results.length, 'from', result.source);
187
+ res.writeHead(200, { 'Content-Type': 'application/json' });
188
+ res.end(JSON.stringify({ results: result.results }));
189
+ } catch (e) {
190
+ log('Search error:', e.message);
191
+ res.writeHead(500, { 'Content-Type': 'application/json' });
192
+ res.end(JSON.stringify({ error: String(e), results: [] }));
193
+ }
319
194
  });
195
+ return;
320
196
  }
321
197
 
322
- return results;
323
- }
198
+ // Proxy other requests
199
+ const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
200
+ const proxyModule = proxyUrl.protocol === 'https:' ? https : http;
201
+ const proxyReq = proxyModule.request(proxyUrl, {
202
+ method: req.method,
203
+ headers: Object.assign({}, req.headers, { host: proxyUrl.host })
204
+ }, function(proxyRes) {
205
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
206
+ proxyRes.pipe(res);
207
+ });
208
+
209
+ proxyReq.on('error', function(e) {
210
+ res.writeHead(502, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify({ error: 'Proxy failed: ' + e.message }));
212
+ });
324
213
 
325
- function decodeHTMLEntities(str) {
326
- return str
327
- .replace(/&amp;/g, '&')
328
- .replace(/&lt;/g, '<')
329
- .replace(/&gt;/g, '>')
330
- .replace(/&quot;/g, '"')
331
- .replace(/&#39;/g, "'")
332
- .replace(/&nbsp;/g, ' ');
214
+ if (req.method !== 'GET' && req.method !== 'HEAD') req.pipe(proxyReq);
215
+ else proxyReq.end();
216
+ });
217
+
218
+ server.listen(PORT, '127.0.0.1', function() {
219
+ const actualPort = server.address().port;
220
+ const portFile = process.env.SEARCH_PROXY_PORT_FILE;
221
+ if (portFile) fs.writeFileSync(portFile, String(actualPort));
222
+ console.log('PORT=' + actualPort);
223
+ log('External providers proxy on port', actualPort);
224
+ });
225
+
226
+ process.on('SIGTERM', function() { server.close(); process.exit(0); });
227
+ process.on('SIGINT', function() { server.close(); process.exit(0); });
228
+ `;
229
+ }
230
+ function generateExternalSearchProxyServer(factoryApiUrl = "https://api.factory.ai") {
231
+ return generateSearchProxyServerCode().replace("const FACTORY_API = 'https://api.factory.ai';", `const FACTORY_API = '${factoryApiUrl}';`);
333
232
  }
334
233
 
335
- async function search(query, numResults = 10) {
336
- // Priority order:
337
- // 1. Smithery Exa MCP (best quality if configured)
338
- // 2. Google PSE (most reliable if configured)
339
- // 3. Serper (free tier: 2500/month)
340
- // 4. Brave Search (free tier available)
341
- // 5. SearXNG (self-hosted)
342
- // 6. DuckDuckGo (limited due to bot detection)
234
+ //#endregion
235
+ //#region src/websearch-native.ts
236
+ /**
237
+ * WebSearch Native Provider Mode (--websearch-proxy)
238
+ *
239
+ * Uses model's native websearch based on ~/.factory/settings.json configuration
240
+ * Requires proxy plugin (anthropic4droid) to handle format conversion
241
+ *
242
+ * Supported providers: Anthropic, OpenAI (extensible)
243
+ */
244
+ function generateNativeSearchProxyServer(factoryApiUrl = "https://api.factory.ai") {
245
+ return `#!/usr/bin/env node
246
+ // Droid WebSearch Proxy Server (Native Provider Mode)
247
+ // Reads ~/.factory/settings.json for model configuration
248
+ // Requires proxy plugin (anthropic4droid) to handle format conversion
343
249
 
344
- // 1. Smithery Exa MCP (highest priority)
345
- const smitheryResults = await searchSmitheryExa(query, numResults);
346
- if (smitheryResults && smitheryResults.length > 0) {
347
- log('Using Smithery Exa');
348
- return { results: smitheryResults, source: 'smithery-exa' };
349
- }
250
+ const http = require('http');
251
+ const https = require('https');
252
+ const { execSync } = require('child_process');
253
+ const fs = require('fs');
254
+ const path = require('path');
255
+ const os = require('os');
256
+
257
+ const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
258
+ const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0');
259
+ const FACTORY_API = '${factoryApiUrl}';
350
260
 
351
- // 2. Google PSE
352
- const googleResults = await searchGooglePSE(query, numResults);
353
- if (googleResults && googleResults.length > 0) {
354
- log('Using Google PSE');
355
- return { results: googleResults, source: 'google-pse' };
261
+ function log(...args) { if (DEBUG) console.error('[websearch]', ...args); }
262
+
263
+ // === Settings Configuration ===
264
+
265
+ let cachedSettings = null;
266
+ let settingsLastModified = 0;
267
+
268
+ function getFactorySettings() {
269
+ const settingsPath = path.join(os.homedir(), '.factory', 'settings.json');
270
+ try {
271
+ const stats = fs.statSync(settingsPath);
272
+ if (cachedSettings && stats.mtimeMs === settingsLastModified) return cachedSettings;
273
+ cachedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
274
+ settingsLastModified = stats.mtimeMs;
275
+ return cachedSettings;
276
+ } catch (e) {
277
+ log('Failed to load settings.json:', e.message);
278
+ return null;
356
279
  }
280
+ }
357
281
 
358
- // 3. Serper
359
- const serperResults = await searchSerper(query, numResults);
360
- if (serperResults && serperResults.length > 0) {
361
- log('Using Serper');
362
- return { results: serperResults, source: 'serper' };
282
+ function getCurrentModelConfig() {
283
+ const settings = getFactorySettings();
284
+ if (!settings) return null;
285
+
286
+ const currentModelId = settings.sessionDefaultSettings?.model;
287
+ if (!currentModelId) return null;
288
+
289
+ const customModels = settings.customModels || [];
290
+ const modelConfig = customModels.find(m => m.id === currentModelId);
291
+
292
+ if (modelConfig) {
293
+ log('Model:', modelConfig.displayName, '| Provider:', modelConfig.provider);
294
+ return modelConfig;
363
295
  }
296
+
297
+ if (!currentModelId.startsWith('custom:')) return null;
298
+ log('Model not found:', currentModelId);
299
+ return null;
300
+ }
364
301
 
365
- // 4. Brave Search
366
- const braveResults = await searchBrave(query, numResults);
367
- if (braveResults && braveResults.length > 0) {
368
- log('Using Brave Search');
369
- return { results: braveResults, source: 'brave' };
302
+ // === Native Provider WebSearch ===
303
+
304
+ async function searchAnthropicNative(query, numResults, modelConfig) {
305
+ const { baseUrl, apiKey, model } = modelConfig;
306
+
307
+ try {
308
+ const requestBody = {
309
+ model: model,
310
+ max_tokens: 4096,
311
+ stream: false,
312
+ system: 'You are a web search assistant. Use the web_search tool to find relevant information and return the results.',
313
+ tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }],
314
+ tool_choice: { type: 'tool', name: 'web_search' },
315
+ messages: [{ role: 'user', content: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' }]
316
+ };
317
+
318
+ let endpoint = baseUrl;
319
+ if (!endpoint.endsWith('/v1/messages')) endpoint = endpoint.replace(/\\/$/, '') + '/v1/messages';
320
+
321
+ log('Anthropic search:', query, '→', endpoint);
322
+
323
+ const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
324
+ const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "anthropic-version: 2023-06-01" -H "x-api-key: ' + apiKey + '" -d \\'' + bodyStr + "\\'";
325
+ const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
326
+
327
+ let response;
328
+ try { response = JSON.parse(responseStr); } catch { return null; }
329
+ if (response.error) { log('API error:', response.error.message); return null; }
330
+
331
+ const results = [];
332
+ for (const block of (response.content || [])) {
333
+ if (block.type === 'web_search_tool_result') {
334
+ for (const result of (block.content || [])) {
335
+ if (result.type === 'web_search_result') {
336
+ results.push({
337
+ title: result.title || '',
338
+ url: result.url || '',
339
+ content: result.snippet || result.page_content || ''
340
+ });
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ log('Results:', results.length);
347
+ return results.length > 0 ? results.slice(0, numResults) : null;
348
+ } catch (e) {
349
+ log('Anthropic error:', e.message);
350
+ return null;
370
351
  }
352
+ }
371
353
 
372
- // 5. SearXNG
373
- const searxngResults = await searchSearXNG(query, numResults);
374
- if (searxngResults && searxngResults.length > 0) {
375
- log('Using SearXNG');
376
- return { results: searxngResults, source: 'searxng' };
354
+ async function searchOpenAINative(query, numResults, modelConfig) {
355
+ const { baseUrl, apiKey, model } = modelConfig;
356
+
357
+ try {
358
+ const requestBody = {
359
+ model: model,
360
+ tools: [{ type: 'web_search' }],
361
+ tool_choice: 'required',
362
+ input: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.'
363
+ };
364
+
365
+ let endpoint = baseUrl;
366
+ if (!endpoint.endsWith('/responses')) endpoint = endpoint.replace(/\\/$/, '') + '/responses';
367
+
368
+ log('OpenAI search:', query, '→', endpoint);
369
+
370
+ const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
371
+ const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "Authorization: Bearer ' + apiKey + '" -d \\'' + bodyStr + "\\'";
372
+ const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
373
+
374
+ let response;
375
+ try { response = JSON.parse(responseStr); } catch { return null; }
376
+ if (response.error) { log('API error:', response.error.message); return null; }
377
+
378
+ const results = [];
379
+ for (const item of (response.output || [])) {
380
+ if (item.type === 'web_search_call' && item.status === 'completed') {
381
+ for (const result of (item.results || [])) {
382
+ results.push({
383
+ title: result.title || '',
384
+ url: result.url || '',
385
+ content: result.snippet || result.content || ''
386
+ });
387
+ }
388
+ }
389
+ }
390
+
391
+ log('Results:', results.length);
392
+ return results.length > 0 ? results.slice(0, numResults) : null;
393
+ } catch (e) {
394
+ log('OpenAI error:', e.message);
395
+ return null;
377
396
  }
397
+ }
378
398
 
379
- // 6. DuckDuckGo (last resort, limited results)
380
- log('Using DuckDuckGo (fallback)');
381
- const ddgResults = await searchDuckDuckGo(query, numResults);
382
- return { results: ddgResults, source: 'duckduckgo' };
399
+ async function search(query, numResults) {
400
+ numResults = numResults || 10;
401
+ log('Search:', query);
402
+
403
+ const modelConfig = getCurrentModelConfig();
404
+ if (!modelConfig) {
405
+ log('No custom model configured');
406
+ return { results: [], source: 'none' };
407
+ }
408
+
409
+ const provider = modelConfig.provider;
410
+ let results = null;
411
+
412
+ if (provider === 'anthropic') results = await searchAnthropicNative(query, numResults, modelConfig);
413
+ else if (provider === 'openai') results = await searchOpenAINative(query, numResults, modelConfig);
414
+ else log('Unsupported provider:', provider);
415
+
416
+ if (results && results.length > 0) return { results: results, source: 'native-' + provider };
417
+ return { results: [], source: 'none' };
383
418
  }
384
419
 
385
420
  // === HTTP Proxy Server ===
386
421
 
387
422
  const server = http.createServer(async (req, res) => {
388
- const url = new URL(req.url, \`http://\${req.headers.host}\`);
423
+ const url = new URL(req.url, 'http://' + req.headers.host);
389
424
 
390
- // Health check
391
425
  if (url.pathname === '/health') {
392
426
  res.writeHead(200, { 'Content-Type': 'application/json' });
393
- res.end(JSON.stringify({ status: 'ok', port: server.address()?.port || PORT }));
427
+ res.end(JSON.stringify({ status: 'ok', mode: 'native-provider' }));
394
428
  return;
395
429
  }
396
430
 
397
- // Search endpoint - intercept
398
431
  if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
399
432
  let body = '';
400
- req.on('data', c => body += c);
401
- req.on('end', async () => {
433
+ req.on('data', function(c) { body += c; });
434
+ req.on('end', async function() {
402
435
  try {
403
- const { query, numResults } = JSON.parse(body);
404
- log('Search query:', query);
405
- const { results, source } = await search(query, numResults || 10);
406
- log('Results:', results.length, 'from', source);
436
+ const parsed = JSON.parse(body);
437
+ const result = await search(parsed.query, parsed.numResults || 10);
438
+ log('Results:', result.results.length, 'from', result.source);
407
439
  res.writeHead(200, { 'Content-Type': 'application/json' });
408
- res.end(JSON.stringify({ results }));
440
+ res.end(JSON.stringify({ results: result.results }));
409
441
  } catch (e) {
410
442
  log('Search error:', e.message);
411
443
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -415,243 +447,164 @@ const server = http.createServer(async (req, res) => {
415
447
  return;
416
448
  }
417
449
 
418
- // === Standalone mode (controlled by STANDALONE_MODE env) ===
419
- // Whitelist approach: only allow core LLM APIs, mock everything else
450
+ // Standalone mode: mock non-LLM APIs
420
451
  if (process.env.STANDALONE_MODE === '1') {
421
452
  const pathname = url.pathname;
422
-
423
- // Whitelist: Core APIs that should be forwarded to upstream
424
453
  const isCoreLLMApi = pathname.startsWith('/api/llm/a/') || pathname.startsWith('/api/llm/o/');
425
- // /api/tools/exa/search is already handled above
426
454
 
427
455
  if (!isCoreLLMApi) {
428
- // Special handling for specific routes
429
456
  if (pathname === '/api/sessions/create') {
430
- log('Mock (dynamic):', pathname);
431
- const sessionId = \`local-\${Date.now()}-\${Math.random().toString(36).slice(2, 10)}\`;
432
457
  res.writeHead(200, { 'Content-Type': 'application/json' });
433
- res.end(JSON.stringify({ id: sessionId }));
458
+ res.end(JSON.stringify({ id: 'local-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10) }));
434
459
  return;
435
460
  }
436
-
437
461
  if (pathname === '/api/cli/whoami') {
438
- log('Mock (401):', pathname);
439
462
  res.writeHead(401, { 'Content-Type': 'application/json' });
440
- res.end(JSON.stringify({ error: 'Unauthorized', message: 'Local mode - use token fallback' }));
441
- return;
442
- }
443
-
444
- if (pathname === '/api/tools/get-url-contents') {
445
- log('Mock (404):', pathname);
446
- res.writeHead(404, { 'Content-Type': 'application/json' });
447
- res.end(JSON.stringify({ error: 'Not available', message: 'Use local URL fetch fallback' }));
463
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
448
464
  return;
449
465
  }
450
-
451
- // All other non-core APIs: return empty success
452
- log('Mock (default):', pathname);
453
466
  res.writeHead(200, { 'Content-Type': 'application/json' });
454
467
  res.end(JSON.stringify({}));
455
468
  return;
456
469
  }
457
470
  }
458
471
 
459
- // Proxy core LLM requests to upstream API
472
+ // Simple proxy - no SSE transformation (handled by proxy plugin)
460
473
  log('Proxy:', req.method, url.pathname);
461
-
462
474
  const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
463
- // Choose http or https based on target protocol
464
475
  const proxyModule = proxyUrl.protocol === 'https:' ? https : http;
465
476
  const proxyReq = proxyModule.request(proxyUrl, {
466
477
  method: req.method,
467
- headers: { ...req.headers, host: proxyUrl.host }
468
- }, proxyRes => {
478
+ headers: Object.assign({}, req.headers, { host: proxyUrl.host })
479
+ }, function(proxyRes) {
469
480
  res.writeHead(proxyRes.statusCode, proxyRes.headers);
470
481
  proxyRes.pipe(res);
471
482
  });
472
483
 
473
- proxyReq.on('error', e => {
474
- log('Proxy error:', e.message);
484
+ proxyReq.on('error', function(e) {
475
485
  res.writeHead(502, { 'Content-Type': 'application/json' });
476
486
  res.end(JSON.stringify({ error: 'Proxy failed: ' + e.message }));
477
487
  });
478
488
 
479
- if (req.method !== 'GET' && req.method !== 'HEAD') {
480
- req.pipe(proxyReq);
481
- } else {
482
- proxyReq.end();
483
- }
489
+ if (req.method !== 'GET' && req.method !== 'HEAD') req.pipe(proxyReq);
490
+ else proxyReq.end();
484
491
  });
485
492
 
486
- // If port is 0, system will automatically assign an available port
487
- server.listen(PORT, '127.0.0.1', () => {
493
+ server.listen(PORT, '127.0.0.1', function() {
488
494
  const actualPort = server.address().port;
489
- const hasGoogle = process.env.GOOGLE_PSE_API_KEY && process.env.GOOGLE_PSE_CX;
490
-
491
- // Write port file for parent process to read
492
495
  const portFile = process.env.SEARCH_PROXY_PORT_FILE;
493
- if (portFile) {
494
- fs.writeFileSync(portFile, String(actualPort));
495
- }
496
-
497
- // Output PORT= line for wrapper script to parse
496
+ if (portFile) fs.writeFileSync(portFile, String(actualPort));
498
497
  console.log('PORT=' + actualPort);
499
-
500
- const hasSmithery = process.env.SMITHERY_API_KEY && process.env.SMITHERY_PROFILE;
501
- log('Search proxy started on http://127.0.0.1:' + actualPort);
502
- log('Smithery Exa:', hasSmithery ? 'configured (priority 1)' : 'not set');
503
- log('Google PSE:', hasGoogle ? 'configured' : 'not set');
504
- log('Serper:', process.env.SERPER_API_KEY ? 'configured' : 'not set');
505
- log('Brave:', process.env.BRAVE_API_KEY ? 'configured' : 'not set');
506
- log('SearXNG:', process.env.SEARXNG_URL || 'not set');
498
+ log('Native provider proxy on port', actualPort);
507
499
  });
508
500
 
509
- process.on('SIGTERM', () => { server.close(); process.exit(0); });
510
- process.on('SIGINT', () => { server.close(); process.exit(0); });
501
+ process.on('SIGTERM', function() { server.close(); process.exit(0); });
502
+ process.on('SIGINT', function() { server.close(); process.exit(0); });
511
503
  `;
512
504
  }
505
+
506
+ //#endregion
507
+ //#region src/websearch-patch.ts
513
508
  /**
514
- * Generate unified Wrapper script
515
- * Each droid instance runs its own proxy:
516
- * - Uses port 0 to let system auto-assign available port
517
- * - Proxy runs as child process
518
- * - Proxy is killed when droid exits
519
- * - Supports multiple droid instances running simultaneously
509
+ * Generate unified wrapper script
520
510
  */
521
511
  function generateUnifiedWrapper(droidPath, proxyScriptPath, standalone = false) {
522
512
  const standaloneEnv = standalone ? "STANDALONE_MODE=1 " : "";
523
513
  return `#!/bin/bash
524
514
  # Droid with WebSearch
525
515
  # Auto-generated by droid-patch --websearch
526
- # Each instance runs its own proxy on a system-assigned port
527
516
 
528
517
  PROXY_SCRIPT="${proxyScriptPath}"
529
518
  DROID_BIN="${droidPath}"
530
519
  PROXY_PID=""
531
- PORT_FILE="/tmp/droid-websearch-\$\$.port"
520
+ PORT_FILE="/tmp/droid-websearch-$$.port"
532
521
  STANDALONE="${standalone ? "1" : "0"}"
533
522
 
534
- # Passthrough for non-interactive/meta commands (avoid starting a proxy for help/version/etc)
535
523
  should_passthrough() {
536
- # Any help/version flags before "--"
537
- for arg in "\$@"; do
538
- if [ "\$arg" = "--" ]; then
539
- break
540
- fi
541
- case "\$arg" in
542
- --help|-h|--version|-V)
543
- return 0
544
- ;;
545
- esac
524
+ for arg in "$@"; do
525
+ if [ "$arg" = "--" ]; then break; fi
526
+ case "$arg" in --help|-h|--version|-V) return 0 ;; esac
546
527
  done
547
-
548
- # Top-level command token
549
528
  local end_opts=0
550
- for arg in "\$@"; do
551
- if [ "\$arg" = "--" ]; then
552
- end_opts=1
553
- continue
554
- fi
555
- if [ "\$end_opts" -eq 0 ] && [[ "\$arg" == -* ]]; then
556
- continue
557
- fi
558
- case "\$arg" in
559
- help|version|completion|completions|exec)
560
- return 0
561
- ;;
562
- esac
529
+ for arg in "$@"; do
530
+ if [ "$arg" = "--" ]; then end_opts=1; continue; fi
531
+ if [ "$end_opts" -eq 0 ] && [[ "$arg" == -* ]]; then continue; fi
532
+ case "$arg" in help|version|completion|completions|exec) return 0 ;; esac
563
533
  break
564
534
  done
565
-
566
535
  return 1
567
536
  }
568
537
 
569
- if should_passthrough "\$@"; then
570
- exec "\$DROID_BIN" "\$@"
571
- fi
538
+ if should_passthrough "$@"; then exec "$DROID_BIN" "$@"; fi
572
539
 
573
- # Cleanup function - kill proxy when droid exits
574
540
  cleanup() {
575
- if [ -n "\$PROXY_PID" ] && kill -0 "\$PROXY_PID" 2>/dev/null; then
576
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Stopping proxy (PID: \$PROXY_PID)" >&2
577
- kill "\$PROXY_PID" 2>/dev/null
578
- wait "\$PROXY_PID" 2>/dev/null
541
+ if [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null; then
542
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Stopping proxy (PID: $PROXY_PID)" >&2
543
+ kill "$PROXY_PID" 2>/dev/null
544
+ wait "$PROXY_PID" 2>/dev/null
579
545
  fi
580
- rm -f "\$PORT_FILE"
546
+ rm -f "$PORT_FILE"
581
547
  }
582
-
583
- # Set up trap to cleanup on exit
584
548
  trap cleanup EXIT INT TERM
585
549
 
586
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Starting proxy..." >&2
587
- [ "\$STANDALONE" = "1" ] && [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Standalone mode enabled" >&2
550
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Starting proxy..." >&2
551
+ [ "$STANDALONE" = "1" ] && [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Standalone mode enabled" >&2
588
552
 
589
- # Start proxy with port 0 (system will assign available port)
590
- # Proxy writes actual port to PORT_FILE
591
- if [ -n "\$DROID_SEARCH_DEBUG" ]; then
592
- ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="\$PORT_FILE" node "\$PROXY_SCRIPT" 2>&1 &
553
+ if [ -n "$DROID_SEARCH_DEBUG" ]; then
554
+ ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="$PORT_FILE" node "$PROXY_SCRIPT" 2>&1 &
593
555
  else
594
- ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="\$PORT_FILE" node "\$PROXY_SCRIPT" >/dev/null 2>&1 &
556
+ ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="$PORT_FILE" node "$PROXY_SCRIPT" >/dev/null 2>&1 &
595
557
  fi
596
- PROXY_PID=\$!
558
+ PROXY_PID=$!
597
559
 
598
- # Wait for proxy to start and get actual port (max 5 seconds)
599
560
  for i in {1..50}; do
600
- # Check if proxy process is still running
601
- if ! kill -0 "\$PROXY_PID" 2>/dev/null; then
602
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy process died" >&2
561
+ if ! kill -0 "$PROXY_PID" 2>/dev/null; then
562
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy process died" >&2
603
563
  break
604
564
  fi
605
- if [ -f "\$PORT_FILE" ]; then
606
- ACTUAL_PORT=\$(cat "\$PORT_FILE" 2>/dev/null)
607
- if [ -n "\$ACTUAL_PORT" ] && curl -s "http://127.0.0.1:\$ACTUAL_PORT/health" > /dev/null 2>&1; then
608
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy ready on port \$ACTUAL_PORT (PID: \$PROXY_PID)" >&2
565
+ if [ -f "$PORT_FILE" ]; then
566
+ ACTUAL_PORT=$(cat "$PORT_FILE" 2>/dev/null)
567
+ if [ -n "$ACTUAL_PORT" ] && curl -s "http://127.0.0.1:$ACTUAL_PORT/health" > /dev/null 2>&1; then
568
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy ready on port $ACTUAL_PORT (PID: $PROXY_PID)" >&2
609
569
  break
610
570
  fi
611
571
  fi
612
572
  sleep 0.1
613
573
  done
614
574
 
615
- # Check if proxy started successfully
616
- if [ ! -f "\$PORT_FILE" ] || [ -z "\$(cat "\$PORT_FILE" 2>/dev/null)" ]; then
575
+ if [ ! -f "$PORT_FILE" ] || [ -z "$(cat "$PORT_FILE" 2>/dev/null)" ]; then
617
576
  echo "[websearch] Failed to start proxy, running without websearch" >&2
618
577
  cleanup
619
- exec "\$DROID_BIN" "\$@"
578
+ exec "$DROID_BIN" "$@"
620
579
  fi
621
580
 
622
- ACTUAL_PORT=\$(cat "\$PORT_FILE")
623
- rm -f "\$PORT_FILE"
624
-
625
- # Run droid with proxy
626
- export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:\$ACTUAL_PORT"
627
- "\$DROID_BIN" "\$@"
628
- DROID_EXIT_CODE=\$?
581
+ ACTUAL_PORT=$(cat "$PORT_FILE")
582
+ rm -f "$PORT_FILE"
629
583
 
630
- # Cleanup will be called by trap
631
- exit \$DROID_EXIT_CODE
584
+ export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:$ACTUAL_PORT"
585
+ "$DROID_BIN" "$@"
586
+ DROID_EXIT_CODE=$?
587
+ exit $DROID_EXIT_CODE
632
588
  `;
633
589
  }
634
590
  /**
635
591
  * Create unified WebSearch files
636
592
  *
637
- * Approach: Proxy server mode
638
- * - wrapper script starts local proxy server
639
- * - proxy server intercepts search requests, passes through other requests
640
- * - uses FACTORY_API_BASE_URL_OVERRIDE env var to point to proxy
641
- * - alias works directly, no extra steps needed
642
- *
643
593
  * @param outputDir - Directory to write files to
644
594
  * @param droidPath - Path to droid binary
645
595
  * @param aliasName - Alias name for the wrapper
646
596
  * @param apiBase - Custom API base URL for proxy to forward requests to
647
597
  * @param standalone - Standalone mode: mock non-LLM Factory APIs
598
+ * @param useNativeProvider - Use native provider websearch (--websearch-proxy mode)
648
599
  */
649
- async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiBase, standalone = false) {
600
+ async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiBase, standalone = false, useNativeProvider = false) {
650
601
  if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
651
602
  const proxyScriptPath = join(outputDir, `${aliasName}-proxy.js`);
652
603
  const wrapperScriptPath = join(outputDir, aliasName);
653
- await writeFile(proxyScriptPath, generateSearchProxyServer(apiBase || "https://api.factory.ai"));
604
+ const factoryApiUrl = apiBase || "https://api.factory.ai";
605
+ await writeFile(proxyScriptPath, useNativeProvider ? generateNativeSearchProxyServer(factoryApiUrl) : generateExternalSearchProxyServer(factoryApiUrl));
654
606
  console.log(`[*] Created proxy script: ${proxyScriptPath}`);
607
+ console.log(`[*] Mode: ${useNativeProvider ? "native provider (requires proxy plugin)" : "external providers"}`);
655
608
  await writeFile(wrapperScriptPath, generateUnifiedWrapper(droidPath, proxyScriptPath, standalone));
656
609
  await chmod(wrapperScriptPath, 493);
657
610
  console.log(`[*] Created wrapper: ${wrapperScriptPath}`);
@@ -714,12 +667,13 @@ function findDefaultDroidPath() {
714
667
  for (const p of paths) if (existsSync(p)) return p;
715
668
  return join(home, ".droid", "bin", "droid");
716
669
  }
717
- 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 API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--auto-high", "Set default autonomy mode to auto-high (bypass settings.json race condition)").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) => {
670
+ 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 API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy with external providers (Smithery, Google PSE, etc.)").option("--websearch-proxy", "Enable native provider websearch (requires proxy plugin, reads ~/.factory/settings.json)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--auto-high", "Set default autonomy mode to auto-high (bypass settings.json race condition)").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) => {
718
671
  const alias = args?.[0];
719
672
  const isCustom = options["is-custom"];
720
673
  const skipLogin = options["skip-login"];
721
674
  const apiBase = options["api-base"];
722
675
  const websearch = options["websearch"];
676
+ const websearchProxy = options["websearch-proxy"];
723
677
  const standalone = options["standalone"];
724
678
  const websearchTarget = websearch ? apiBase || "https://api.factory.ai" : void 0;
725
679
  const reasoningEffort = options["reasoning-effort"];
@@ -731,24 +685,37 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
731
685
  const backup = options.backup !== false;
732
686
  const verbose = options.verbose;
733
687
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
734
- if (!(!!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!autoHigh || !!apiBase && !websearch) && websearch) {
688
+ const needsBinaryPatch = !!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!autoHigh || !!apiBase && !websearch && !websearchProxy;
689
+ if (websearch && websearchProxy) {
690
+ console.log(styleText("red", "Error: Cannot use --websearch and --websearch-proxy together"));
691
+ console.log(styleText("gray", "Choose one:"));
692
+ console.log(styleText("gray", " --websearch External providers (Smithery, Google PSE, etc.)"));
693
+ console.log(styleText("gray", " --websearch-proxy Native provider (requires proxy plugin)"));
694
+ process.exit(1);
695
+ }
696
+ if (!needsBinaryPatch && (websearch || websearchProxy)) {
735
697
  if (!alias) {
736
- console.log(styleText("red", "Error: Alias name required for --websearch"));
737
- console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
698
+ const flag = websearchProxy ? "--websearch-proxy" : "--websearch";
699
+ console.log(styleText("red", `Error: Alias name required for ${flag}`));
700
+ console.log(styleText("gray", `Usage: npx droid-patch ${flag} <alias>`));
738
701
  process.exit(1);
739
702
  }
740
703
  console.log(styleText("cyan", "═".repeat(60)));
741
704
  console.log(styleText(["cyan", "bold"], " Droid Wrapper Setup"));
742
705
  console.log(styleText("cyan", "═".repeat(60)));
743
706
  console.log();
744
- if (websearch) {
745
- console.log(styleText("white", `WebSearch: enabled`));
707
+ if (websearchProxy) {
708
+ console.log(styleText("white", `WebSearch: native provider mode`));
709
+ console.log(styleText("gray", ` Requires proxy plugin (anthropic4droid)`));
710
+ console.log(styleText("gray", ` Reads model config from ~/.factory/settings.json`));
711
+ } else if (websearch) {
712
+ console.log(styleText("white", `WebSearch: external providers mode`));
746
713
  console.log(styleText("white", `Forward target: ${websearchTarget}`));
747
- if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
748
714
  }
715
+ if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
749
716
  console.log();
750
717
  let execTargetPath = path;
751
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
718
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchProxy ? void 0 : websearchTarget, standalone, websearchProxy);
752
719
  execTargetPath = wrapperScript;
753
720
  const aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
754
721
  const droidVersion = getDroidVersion(path);
@@ -757,6 +724,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
757
724
  skipLogin: false,
758
725
  apiBase: apiBase || null,
759
726
  websearch: !!websearch,
727
+ websearchProxy: !!websearchProxy,
760
728
  reasoningEffort: false,
761
729
  noTelemetry: false,
762
730
  standalone
@@ -773,10 +741,24 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
773
741
  console.log("Run directly:");
774
742
  console.log(styleText("yellow", ` ${alias}`));
775
743
  console.log();
776
- if (websearch) {
777
- console.log(styleText("cyan", "Auto-shutdown:"));
778
- console.log(styleText("gray", " Proxy auto-shuts down after 5 min idle (no manual cleanup needed)"));
779
- console.log(styleText("gray", " To disable: export DROID_PROXY_IDLE_TIMEOUT=0"));
744
+ if (websearchProxy) {
745
+ console.log(styleText("cyan", "Native Provider WebSearch (--websearch-proxy):"));
746
+ console.log(styleText("gray", " Uses model's built-in websearch via proxy plugin"));
747
+ console.log(styleText("gray", " Reads model config from ~/.factory/settings.json"));
748
+ console.log();
749
+ console.log(styleText("yellow", "IMPORTANT: Requires proxy plugin (anthropic4droid)"));
750
+ console.log();
751
+ console.log("Supported providers:");
752
+ console.log(styleText("yellow", " - anthropic: Claude web_search_20250305 server tool"));
753
+ console.log(styleText("yellow", " - openai: OpenAI web_search tool"));
754
+ console.log(styleText("gray", " - generic-chat-completion-api: Not supported"));
755
+ console.log();
756
+ console.log("Debug mode:");
757
+ console.log(styleText("gray", " export DROID_SEARCH_DEBUG=1 # Basic logs"));
758
+ console.log(styleText("gray", " export DROID_SEARCH_VERBOSE=1 # Full request/response"));
759
+ } else if (websearch) {
760
+ console.log(styleText("cyan", "External Providers WebSearch (--websearch):"));
761
+ console.log(styleText("gray", " Uses external search providers (Smithery, Google PSE, etc.)"));
780
762
  console.log();
781
763
  console.log("Search providers (in priority order):");
782
764
  console.log(styleText("yellow", " 1. Smithery Exa (best quality):"));
@@ -947,16 +929,22 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
947
929
  if (result.success && result.outputPath && alias) {
948
930
  console.log();
949
931
  let execTargetPath = result.outputPath;
950
- if (websearch) {
951
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
932
+ if (websearch || websearchProxy) {
933
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchProxy ? void 0 : websearchTarget, standalone, websearchProxy);
952
934
  execTargetPath = wrapperScript;
953
935
  console.log();
954
- console.log(styleText("cyan", "WebSearch enabled"));
955
- console.log(styleText("white", ` Forward target: ${websearchTarget}`));
936
+ if (websearchProxy) {
937
+ console.log(styleText("cyan", "WebSearch enabled (native provider mode)"));
938
+ console.log(styleText("gray", " Requires proxy plugin (anthropic4droid)"));
939
+ console.log(styleText("gray", " Reads model config from ~/.factory/settings.json"));
940
+ } else {
941
+ console.log(styleText("cyan", "WebSearch enabled (external providers mode)"));
942
+ console.log(styleText("white", ` Forward target: ${websearchTarget}`));
943
+ }
956
944
  if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
957
945
  }
958
946
  let aliasResult;
959
- if (websearch) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
947
+ if (websearch || websearchProxy) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
960
948
  else aliasResult = await createAlias(result.outputPath, alias, verbose);
961
949
  const droidVersion = getDroidVersion(path);
962
950
  await saveAliasMetadata(createMetadata(alias, path, {
@@ -964,6 +952,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
964
952
  skipLogin: !!skipLogin,
965
953
  apiBase: apiBase || null,
966
954
  websearch: !!websearch,
955
+ websearchProxy: !!websearchProxy,
967
956
  reasoningEffort: !!reasoningEffort,
968
957
  noTelemetry: !!noTelemetry,
969
958
  standalone: !!standalone,