droid-patch 0.10.0 → 0.11.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.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,392 @@ 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
+ });
213
+
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
+ });
324
225
 
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, ' ');
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 to handle format conversion:
241
+ * - Anthropic provider: anthropic4droid plugin
242
+ * - OpenAI provider: openai4droid plugin (adds CODEX_INSTRUCTIONS)
243
+ *
244
+ * Supported providers:
245
+ * - Anthropic: web_search_20250305 server tool, results in web_search_tool_result
246
+ * - OpenAI: web_search tool, results in message.content[].annotations[] as url_citation
247
+ */
248
+ function generateNativeSearchProxyServer(factoryApiUrl = "https://api.factory.ai") {
249
+ return `#!/usr/bin/env node
250
+ // Droid WebSearch Proxy Server (Native Provider Mode)
251
+ // Reads ~/.factory/settings.json for model configuration
252
+ // Requires proxy plugin (anthropic4droid) to handle format conversion
343
253
 
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
- }
254
+ const http = require('http');
255
+ const https = require('https');
256
+ const { execSync } = require('child_process');
257
+ const fs = require('fs');
258
+ const path = require('path');
259
+ const os = require('os');
260
+
261
+ const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
262
+ const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0');
263
+ const FACTORY_API = '${factoryApiUrl}';
264
+
265
+ function log(...args) { if (DEBUG) console.error('[websearch]', ...args); }
350
266
 
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' };
267
+ // === Settings Configuration ===
268
+
269
+ let cachedSettings = null;
270
+ let settingsLastModified = 0;
271
+
272
+ function getFactorySettings() {
273
+ const settingsPath = path.join(os.homedir(), '.factory', 'settings.json');
274
+ try {
275
+ const stats = fs.statSync(settingsPath);
276
+ if (cachedSettings && stats.mtimeMs === settingsLastModified) return cachedSettings;
277
+ cachedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
278
+ settingsLastModified = stats.mtimeMs;
279
+ return cachedSettings;
280
+ } catch (e) {
281
+ log('Failed to load settings.json:', e.message);
282
+ return null;
356
283
  }
284
+ }
357
285
 
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' };
286
+ function getCurrentModelConfig() {
287
+ const settings = getFactorySettings();
288
+ if (!settings) return null;
289
+
290
+ const currentModelId = settings.sessionDefaultSettings?.model;
291
+ if (!currentModelId) return null;
292
+
293
+ const customModels = settings.customModels || [];
294
+ const modelConfig = customModels.find(m => m.id === currentModelId);
295
+
296
+ if (modelConfig) {
297
+ log('Model:', modelConfig.displayName, '| Provider:', modelConfig.provider);
298
+ return modelConfig;
363
299
  }
300
+
301
+ if (!currentModelId.startsWith('custom:')) return null;
302
+ log('Model not found:', currentModelId);
303
+ return null;
304
+ }
305
+
306
+ // === Native Provider WebSearch ===
364
307
 
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' };
308
+ async function searchAnthropicNative(query, numResults, modelConfig) {
309
+ const { baseUrl, apiKey, model } = modelConfig;
310
+
311
+ try {
312
+ const requestBody = {
313
+ model: model,
314
+ max_tokens: 4096,
315
+ stream: false,
316
+ system: 'You are a web search assistant. Use the web_search tool to find relevant information and return the results.',
317
+ tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }],
318
+ tool_choice: { type: 'tool', name: 'web_search' },
319
+ messages: [{ role: 'user', content: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' }]
320
+ };
321
+
322
+ let endpoint = baseUrl;
323
+ if (!endpoint.endsWith('/v1/messages')) endpoint = endpoint.replace(/\\/$/, '') + '/v1/messages';
324
+
325
+ log('Anthropic search:', query, '→', endpoint);
326
+
327
+ const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
328
+ 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 + "\\'";
329
+ const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
330
+
331
+ let response;
332
+ try { response = JSON.parse(responseStr); } catch { return null; }
333
+ if (response.error) { log('API error:', response.error.message); return null; }
334
+
335
+ const results = [];
336
+ for (const block of (response.content || [])) {
337
+ if (block.type === 'web_search_tool_result') {
338
+ for (const result of (block.content || [])) {
339
+ if (result.type === 'web_search_result') {
340
+ results.push({
341
+ title: result.title || '',
342
+ url: result.url || '',
343
+ content: result.snippet || result.page_content || ''
344
+ });
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ log('Results:', results.length);
351
+ return results.length > 0 ? results.slice(0, numResults) : null;
352
+ } catch (e) {
353
+ log('Anthropic error:', e.message);
354
+ return null;
370
355
  }
356
+ }
371
357
 
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' };
358
+ async function searchOpenAINative(query, numResults, modelConfig) {
359
+ const { baseUrl, apiKey, model } = modelConfig;
360
+
361
+ try {
362
+ // Note: instructions will be added by openai4droid proxy plugin
363
+ const requestBody = {
364
+ model: model,
365
+ stream: false,
366
+ tools: [{ type: 'web_search' }],
367
+ tool_choice: 'required',
368
+ input: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.'
369
+ };
370
+
371
+ let endpoint = baseUrl;
372
+ if (!endpoint.endsWith('/responses')) endpoint = endpoint.replace(/\\/$/, '') + '/responses';
373
+
374
+ log('OpenAI search:', query, '→', endpoint);
375
+
376
+ const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
377
+ const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "Authorization: Bearer ' + apiKey + '" -d \\'' + bodyStr + "\\'";
378
+ const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
379
+
380
+ let response;
381
+ try { response = JSON.parse(responseStr); } catch { return null; }
382
+ if (response.error) { log('API error:', response.error.message); return null; }
383
+
384
+ // Extract results from url_citation annotations in message output
385
+ const results = [];
386
+ for (const item of (response.output || [])) {
387
+ if (item.type === 'message' && Array.isArray(item.content)) {
388
+ for (const content of item.content) {
389
+ if (content.type === 'output_text' && Array.isArray(content.annotations)) {
390
+ for (const annotation of content.annotations) {
391
+ if (annotation.type === 'url_citation' && annotation.url) {
392
+ results.push({
393
+ title: annotation.title || '',
394
+ url: annotation.url || '',
395
+ content: annotation.title || ''
396
+ });
397
+ }
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+
404
+ log('Results:', results.length);
405
+ return results.length > 0 ? results.slice(0, numResults) : null;
406
+ } catch (e) {
407
+ log('OpenAI error:', e.message);
408
+ return null;
377
409
  }
410
+ }
378
411
 
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' };
412
+ async function search(query, numResults) {
413
+ numResults = numResults || 10;
414
+ log('Search:', query);
415
+
416
+ const modelConfig = getCurrentModelConfig();
417
+ if (!modelConfig) {
418
+ log('No custom model configured');
419
+ return { results: [], source: 'none' };
420
+ }
421
+
422
+ const provider = modelConfig.provider;
423
+ let results = null;
424
+
425
+ if (provider === 'anthropic') results = await searchAnthropicNative(query, numResults, modelConfig);
426
+ else if (provider === 'openai') results = await searchOpenAINative(query, numResults, modelConfig);
427
+ else log('Unsupported provider:', provider);
428
+
429
+ if (results && results.length > 0) return { results: results, source: 'native-' + provider };
430
+ return { results: [], source: 'none' };
383
431
  }
384
432
 
385
433
  // === HTTP Proxy Server ===
386
434
 
387
435
  const server = http.createServer(async (req, res) => {
388
- const url = new URL(req.url, \`http://\${req.headers.host}\`);
436
+ const url = new URL(req.url, 'http://' + req.headers.host);
389
437
 
390
- // Health check
391
438
  if (url.pathname === '/health') {
392
439
  res.writeHead(200, { 'Content-Type': 'application/json' });
393
- res.end(JSON.stringify({ status: 'ok', port: server.address()?.port || PORT }));
440
+ res.end(JSON.stringify({ status: 'ok', mode: 'native-provider' }));
394
441
  return;
395
442
  }
396
443
 
397
- // Search endpoint - intercept
398
444
  if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
399
445
  let body = '';
400
- req.on('data', c => body += c);
401
- req.on('end', async () => {
446
+ req.on('data', function(c) { body += c; });
447
+ req.on('end', async function() {
402
448
  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);
449
+ const parsed = JSON.parse(body);
450
+ const result = await search(parsed.query, parsed.numResults || 10);
451
+ log('Results:', result.results.length, 'from', result.source);
407
452
  res.writeHead(200, { 'Content-Type': 'application/json' });
408
- res.end(JSON.stringify({ results }));
453
+ res.end(JSON.stringify({ results: result.results }));
409
454
  } catch (e) {
410
455
  log('Search error:', e.message);
411
456
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -415,243 +460,164 @@ const server = http.createServer(async (req, res) => {
415
460
  return;
416
461
  }
417
462
 
418
- // === Standalone mode (controlled by STANDALONE_MODE env) ===
419
- // Whitelist approach: only allow core LLM APIs, mock everything else
463
+ // Standalone mode: mock non-LLM APIs
420
464
  if (process.env.STANDALONE_MODE === '1') {
421
465
  const pathname = url.pathname;
422
-
423
- // Whitelist: Core APIs that should be forwarded to upstream
424
466
  const isCoreLLMApi = pathname.startsWith('/api/llm/a/') || pathname.startsWith('/api/llm/o/');
425
- // /api/tools/exa/search is already handled above
426
467
 
427
468
  if (!isCoreLLMApi) {
428
- // Special handling for specific routes
429
469
  if (pathname === '/api/sessions/create') {
430
- log('Mock (dynamic):', pathname);
431
- const sessionId = \`local-\${Date.now()}-\${Math.random().toString(36).slice(2, 10)}\`;
432
470
  res.writeHead(200, { 'Content-Type': 'application/json' });
433
- res.end(JSON.stringify({ id: sessionId }));
471
+ res.end(JSON.stringify({ id: 'local-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10) }));
434
472
  return;
435
473
  }
436
-
437
474
  if (pathname === '/api/cli/whoami') {
438
- log('Mock (401):', pathname);
439
475
  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' }));
476
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
448
477
  return;
449
478
  }
450
-
451
- // All other non-core APIs: return empty success
452
- log('Mock (default):', pathname);
453
479
  res.writeHead(200, { 'Content-Type': 'application/json' });
454
480
  res.end(JSON.stringify({}));
455
481
  return;
456
482
  }
457
483
  }
458
484
 
459
- // Proxy core LLM requests to upstream API
485
+ // Simple proxy - no SSE transformation (handled by proxy plugin)
460
486
  log('Proxy:', req.method, url.pathname);
461
-
462
487
  const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
463
- // Choose http or https based on target protocol
464
488
  const proxyModule = proxyUrl.protocol === 'https:' ? https : http;
465
489
  const proxyReq = proxyModule.request(proxyUrl, {
466
490
  method: req.method,
467
- headers: { ...req.headers, host: proxyUrl.host }
468
- }, proxyRes => {
491
+ headers: Object.assign({}, req.headers, { host: proxyUrl.host })
492
+ }, function(proxyRes) {
469
493
  res.writeHead(proxyRes.statusCode, proxyRes.headers);
470
494
  proxyRes.pipe(res);
471
495
  });
472
496
 
473
- proxyReq.on('error', e => {
474
- log('Proxy error:', e.message);
497
+ proxyReq.on('error', function(e) {
475
498
  res.writeHead(502, { 'Content-Type': 'application/json' });
476
499
  res.end(JSON.stringify({ error: 'Proxy failed: ' + e.message }));
477
500
  });
478
501
 
479
- if (req.method !== 'GET' && req.method !== 'HEAD') {
480
- req.pipe(proxyReq);
481
- } else {
482
- proxyReq.end();
483
- }
502
+ if (req.method !== 'GET' && req.method !== 'HEAD') req.pipe(proxyReq);
503
+ else proxyReq.end();
484
504
  });
485
505
 
486
- // If port is 0, system will automatically assign an available port
487
- server.listen(PORT, '127.0.0.1', () => {
506
+ server.listen(PORT, '127.0.0.1', function() {
488
507
  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
508
  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
509
+ if (portFile) fs.writeFileSync(portFile, String(actualPort));
498
510
  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');
511
+ log('Native provider proxy on port', actualPort);
507
512
  });
508
513
 
509
- process.on('SIGTERM', () => { server.close(); process.exit(0); });
510
- process.on('SIGINT', () => { server.close(); process.exit(0); });
514
+ process.on('SIGTERM', function() { server.close(); process.exit(0); });
515
+ process.on('SIGINT', function() { server.close(); process.exit(0); });
511
516
  `;
512
517
  }
518
+
519
+ //#endregion
520
+ //#region src/websearch-patch.ts
513
521
  /**
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
522
+ * Generate unified wrapper script
520
523
  */
521
524
  function generateUnifiedWrapper(droidPath, proxyScriptPath, standalone = false) {
522
525
  const standaloneEnv = standalone ? "STANDALONE_MODE=1 " : "";
523
526
  return `#!/bin/bash
524
527
  # Droid with WebSearch
525
528
  # Auto-generated by droid-patch --websearch
526
- # Each instance runs its own proxy on a system-assigned port
527
529
 
528
530
  PROXY_SCRIPT="${proxyScriptPath}"
529
531
  DROID_BIN="${droidPath}"
530
532
  PROXY_PID=""
531
- PORT_FILE="/tmp/droid-websearch-\$\$.port"
533
+ PORT_FILE="/tmp/droid-websearch-$$.port"
532
534
  STANDALONE="${standalone ? "1" : "0"}"
533
535
 
534
- # Passthrough for non-interactive/meta commands (avoid starting a proxy for help/version/etc)
535
536
  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
537
+ for arg in "$@"; do
538
+ if [ "$arg" = "--" ]; then break; fi
539
+ case "$arg" in --help|-h|--version|-V) return 0 ;; esac
546
540
  done
547
-
548
- # Top-level command token
549
541
  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
542
+ for arg in "$@"; do
543
+ if [ "$arg" = "--" ]; then end_opts=1; continue; fi
544
+ if [ "$end_opts" -eq 0 ] && [[ "$arg" == -* ]]; then continue; fi
545
+ case "$arg" in help|version|completion|completions|exec) return 0 ;; esac
563
546
  break
564
547
  done
565
-
566
548
  return 1
567
549
  }
568
550
 
569
- if should_passthrough "\$@"; then
570
- exec "\$DROID_BIN" "\$@"
571
- fi
551
+ if should_passthrough "$@"; then exec "$DROID_BIN" "$@"; fi
572
552
 
573
- # Cleanup function - kill proxy when droid exits
574
553
  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
554
+ if [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null; then
555
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Stopping proxy (PID: $PROXY_PID)" >&2
556
+ kill "$PROXY_PID" 2>/dev/null
557
+ wait "$PROXY_PID" 2>/dev/null
579
558
  fi
580
- rm -f "\$PORT_FILE"
559
+ rm -f "$PORT_FILE"
581
560
  }
582
-
583
- # Set up trap to cleanup on exit
584
561
  trap cleanup EXIT INT TERM
585
562
 
586
- [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Starting proxy..." >&2
587
- [ "\$STANDALONE" = "1" ] && [ -n "\$DROID_SEARCH_DEBUG" ] && echo "[websearch] Standalone mode enabled" >&2
563
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Starting proxy..." >&2
564
+ [ "$STANDALONE" = "1" ] && [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Standalone mode enabled" >&2
588
565
 
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 &
566
+ if [ -n "$DROID_SEARCH_DEBUG" ]; then
567
+ ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="$PORT_FILE" node "$PROXY_SCRIPT" 2>&1 &
593
568
  else
594
- ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="\$PORT_FILE" node "\$PROXY_SCRIPT" >/dev/null 2>&1 &
569
+ ${standaloneEnv}SEARCH_PROXY_PORT=0 SEARCH_PROXY_PORT_FILE="$PORT_FILE" node "$PROXY_SCRIPT" >/dev/null 2>&1 &
595
570
  fi
596
- PROXY_PID=\$!
571
+ PROXY_PID=$!
597
572
 
598
- # Wait for proxy to start and get actual port (max 5 seconds)
599
573
  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
574
+ if ! kill -0 "$PROXY_PID" 2>/dev/null; then
575
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy process died" >&2
603
576
  break
604
577
  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
578
+ if [ -f "$PORT_FILE" ]; then
579
+ ACTUAL_PORT=$(cat "$PORT_FILE" 2>/dev/null)
580
+ if [ -n "$ACTUAL_PORT" ] && curl -s "http://127.0.0.1:$ACTUAL_PORT/health" > /dev/null 2>&1; then
581
+ [ -n "$DROID_SEARCH_DEBUG" ] && echo "[websearch] Proxy ready on port $ACTUAL_PORT (PID: $PROXY_PID)" >&2
609
582
  break
610
583
  fi
611
584
  fi
612
585
  sleep 0.1
613
586
  done
614
587
 
615
- # Check if proxy started successfully
616
- if [ ! -f "\$PORT_FILE" ] || [ -z "\$(cat "\$PORT_FILE" 2>/dev/null)" ]; then
588
+ if [ ! -f "$PORT_FILE" ] || [ -z "$(cat "$PORT_FILE" 2>/dev/null)" ]; then
617
589
  echo "[websearch] Failed to start proxy, running without websearch" >&2
618
590
  cleanup
619
- exec "\$DROID_BIN" "\$@"
591
+ exec "$DROID_BIN" "$@"
620
592
  fi
621
593
 
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=\$?
594
+ ACTUAL_PORT=$(cat "$PORT_FILE")
595
+ rm -f "$PORT_FILE"
629
596
 
630
- # Cleanup will be called by trap
631
- exit \$DROID_EXIT_CODE
597
+ export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:$ACTUAL_PORT"
598
+ "$DROID_BIN" "$@"
599
+ DROID_EXIT_CODE=$?
600
+ exit $DROID_EXIT_CODE
632
601
  `;
633
602
  }
634
603
  /**
635
604
  * Create unified WebSearch files
636
605
  *
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
606
  * @param outputDir - Directory to write files to
644
607
  * @param droidPath - Path to droid binary
645
608
  * @param aliasName - Alias name for the wrapper
646
609
  * @param apiBase - Custom API base URL for proxy to forward requests to
647
610
  * @param standalone - Standalone mode: mock non-LLM Factory APIs
611
+ * @param useNativeProvider - Use native provider websearch (--websearch-proxy mode)
648
612
  */
649
- async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiBase, standalone = false) {
613
+ async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiBase, standalone = false, useNativeProvider = false) {
650
614
  if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
651
615
  const proxyScriptPath = join(outputDir, `${aliasName}-proxy.js`);
652
616
  const wrapperScriptPath = join(outputDir, aliasName);
653
- await writeFile(proxyScriptPath, generateSearchProxyServer(apiBase || "https://api.factory.ai"));
617
+ const factoryApiUrl = apiBase || "https://api.factory.ai";
618
+ await writeFile(proxyScriptPath, useNativeProvider ? generateNativeSearchProxyServer(factoryApiUrl) : generateExternalSearchProxyServer(factoryApiUrl));
654
619
  console.log(`[*] Created proxy script: ${proxyScriptPath}`);
620
+ console.log(`[*] Mode: ${useNativeProvider ? "native provider (requires proxy plugin)" : "external providers"}`);
655
621
  await writeFile(wrapperScriptPath, generateUnifiedWrapper(droidPath, proxyScriptPath, standalone));
656
622
  await chmod(wrapperScriptPath, 493);
657
623
  console.log(`[*] Created wrapper: ${wrapperScriptPath}`);
@@ -714,12 +680,13 @@ function findDefaultDroidPath() {
714
680
  for (const p of paths) if (existsSync(p)) return p;
715
681
  return join(home, ".droid", "bin", "droid");
716
682
  }
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) => {
683
+ 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
684
  const alias = args?.[0];
719
685
  const isCustom = options["is-custom"];
720
686
  const skipLogin = options["skip-login"];
721
687
  const apiBase = options["api-base"];
722
688
  const websearch = options["websearch"];
689
+ const websearchProxy = options["websearch-proxy"];
723
690
  const standalone = options["standalone"];
724
691
  const websearchTarget = websearch ? apiBase || "https://api.factory.ai" : void 0;
725
692
  const reasoningEffort = options["reasoning-effort"];
@@ -731,24 +698,37 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
731
698
  const backup = options.backup !== false;
732
699
  const verbose = options.verbose;
733
700
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
734
- if (!(!!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!autoHigh || !!apiBase && !websearch) && websearch) {
701
+ const needsBinaryPatch = !!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!autoHigh || !!apiBase && !websearch && !websearchProxy;
702
+ if (websearch && websearchProxy) {
703
+ console.log(styleText("red", "Error: Cannot use --websearch and --websearch-proxy together"));
704
+ console.log(styleText("gray", "Choose one:"));
705
+ console.log(styleText("gray", " --websearch External providers (Smithery, Google PSE, etc.)"));
706
+ console.log(styleText("gray", " --websearch-proxy Native provider (requires proxy plugin)"));
707
+ process.exit(1);
708
+ }
709
+ if (!needsBinaryPatch && (websearch || websearchProxy)) {
735
710
  if (!alias) {
736
- console.log(styleText("red", "Error: Alias name required for --websearch"));
737
- console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
711
+ const flag = websearchProxy ? "--websearch-proxy" : "--websearch";
712
+ console.log(styleText("red", `Error: Alias name required for ${flag}`));
713
+ console.log(styleText("gray", `Usage: npx droid-patch ${flag} <alias>`));
738
714
  process.exit(1);
739
715
  }
740
716
  console.log(styleText("cyan", "═".repeat(60)));
741
717
  console.log(styleText(["cyan", "bold"], " Droid Wrapper Setup"));
742
718
  console.log(styleText("cyan", "═".repeat(60)));
743
719
  console.log();
744
- if (websearch) {
745
- console.log(styleText("white", `WebSearch: enabled`));
720
+ if (websearchProxy) {
721
+ console.log(styleText("white", `WebSearch: native provider mode`));
722
+ console.log(styleText("gray", ` Requires proxy plugin (anthropic4droid)`));
723
+ console.log(styleText("gray", ` Reads model config from ~/.factory/settings.json`));
724
+ } else if (websearch) {
725
+ console.log(styleText("white", `WebSearch: external providers mode`));
746
726
  console.log(styleText("white", `Forward target: ${websearchTarget}`));
747
- if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
748
727
  }
728
+ if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
749
729
  console.log();
750
730
  let execTargetPath = path;
751
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
731
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchProxy ? void 0 : websearchTarget, standalone, websearchProxy);
752
732
  execTargetPath = wrapperScript;
753
733
  const aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
754
734
  const droidVersion = getDroidVersion(path);
@@ -757,6 +737,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
757
737
  skipLogin: false,
758
738
  apiBase: apiBase || null,
759
739
  websearch: !!websearch,
740
+ websearchProxy: !!websearchProxy,
760
741
  reasoningEffort: false,
761
742
  noTelemetry: false,
762
743
  standalone
@@ -773,10 +754,24 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
773
754
  console.log("Run directly:");
774
755
  console.log(styleText("yellow", ` ${alias}`));
775
756
  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"));
757
+ if (websearchProxy) {
758
+ console.log(styleText("cyan", "Native Provider WebSearch (--websearch-proxy):"));
759
+ console.log(styleText("gray", " Uses model's built-in websearch via proxy plugin"));
760
+ console.log(styleText("gray", " Reads model config from ~/.factory/settings.json"));
761
+ console.log();
762
+ console.log(styleText("yellow", "IMPORTANT: Requires proxy plugin (anthropic4droid)"));
763
+ console.log();
764
+ console.log("Supported providers:");
765
+ console.log(styleText("yellow", " - anthropic: Claude web_search_20250305 server tool"));
766
+ console.log(styleText("yellow", " - openai: OpenAI web_search tool"));
767
+ console.log(styleText("gray", " - generic-chat-completion-api: Not supported"));
768
+ console.log();
769
+ console.log("Debug mode:");
770
+ console.log(styleText("gray", " export DROID_SEARCH_DEBUG=1 # Basic logs"));
771
+ console.log(styleText("gray", " export DROID_SEARCH_VERBOSE=1 # Full request/response"));
772
+ } else if (websearch) {
773
+ console.log(styleText("cyan", "External Providers WebSearch (--websearch):"));
774
+ console.log(styleText("gray", " Uses external search providers (Smithery, Google PSE, etc.)"));
780
775
  console.log();
781
776
  console.log("Search providers (in priority order):");
782
777
  console.log(styleText("yellow", " 1. Smithery Exa (best quality):"));
@@ -947,16 +942,22 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
947
942
  if (result.success && result.outputPath && alias) {
948
943
  console.log();
949
944
  let execTargetPath = result.outputPath;
950
- if (websearch) {
951
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
945
+ if (websearch || websearchProxy) {
946
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchProxy ? void 0 : websearchTarget, standalone, websearchProxy);
952
947
  execTargetPath = wrapperScript;
953
948
  console.log();
954
- console.log(styleText("cyan", "WebSearch enabled"));
955
- console.log(styleText("white", ` Forward target: ${websearchTarget}`));
949
+ if (websearchProxy) {
950
+ console.log(styleText("cyan", "WebSearch enabled (native provider mode)"));
951
+ console.log(styleText("gray", " Requires proxy plugin (anthropic4droid)"));
952
+ console.log(styleText("gray", " Reads model config from ~/.factory/settings.json"));
953
+ } else {
954
+ console.log(styleText("cyan", "WebSearch enabled (external providers mode)"));
955
+ console.log(styleText("white", ` Forward target: ${websearchTarget}`));
956
+ }
956
957
  if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
957
958
  }
958
959
  let aliasResult;
959
- if (websearch) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
960
+ if (websearch || websearchProxy) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
960
961
  else aliasResult = await createAlias(result.outputPath, alias, verbose);
961
962
  const droidVersion = getDroidVersion(path);
962
963
  await saveAliasMetadata(createMetadata(alias, path, {
@@ -964,6 +965,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
964
965
  skipLogin: !!skipLogin,
965
966
  apiBase: apiBase || null,
966
967
  websearch: !!websearch,
968
+ websearchProxy: !!websearchProxy,
967
969
  reasoningEffort: !!reasoningEffort,
968
970
  noTelemetry: !!noTelemetry,
969
971
  standalone: !!standalone,