droid-patch 0.15.2 → 0.16.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
@@ -29,9 +29,14 @@ const fs = require('fs');
29
29
  const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
30
30
  const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0');
31
31
  const FACTORY_API = 'https://api.factory.ai';
32
+ const SEARCH_ROUTE_ALIASES = new Set(['/api/tools/web-search', '/api/tools/exa/search']);
32
33
 
33
34
  function log() { if (DEBUG) console.error.apply(console, ['[websearch]'].concat(Array.from(arguments))); }
34
35
 
36
+ function isSearchRequest(url, method) {
37
+ return method === 'POST' && SEARCH_ROUTE_ALIASES.has(url.pathname);
38
+ }
39
+
35
40
  // === External Search Providers ===
36
41
 
37
42
  async function searchSmitheryExa(query, numResults) {
@@ -204,7 +209,7 @@ const server = http.createServer(async (req, res) => {
204
209
  return;
205
210
  }
206
211
 
207
- if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
212
+ if (isSearchRequest(url, req.method)) {
208
213
  let body = '';
209
214
  req.on('data', function(c) { body += c; });
210
215
  req.on('end', async function() {
@@ -281,7 +286,6 @@ function generateNativeSearchProxyServer(factoryApiUrl = "https://api.factory.ai
281
286
 
282
287
  const http = require('http');
283
288
  const https = require('https');
284
- const { execSync } = require('child_process');
285
289
  const fs = require('fs');
286
290
  const path = require('path');
287
291
  const os = require('os');
@@ -289,13 +293,18 @@ const os = require('os');
289
293
  const DEBUG = process.env.DROID_SEARCH_DEBUG === '1';
290
294
  const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0');
291
295
  const FACTORY_API = '${factoryApiUrl}';
296
+ const SEARCH_ROUTE_ALIASES = new Set(['/api/tools/web-search', '/api/tools/exa/search']);
297
+ const SUPPORTED_PROVIDERS = new Set(['anthropic', 'openai']);
292
298
 
293
299
  function log(...args) { if (DEBUG) console.error('[websearch]', ...args); }
294
300
 
295
- // === Settings Configuration ===
301
+ function isSearchRequest(url, method) {
302
+ return method === 'POST' && SEARCH_ROUTE_ALIASES.has(url.pathname);
303
+ }
296
304
 
297
305
  let cachedSettings = null;
298
306
  let settingsLastModified = 0;
307
+ let lastObservedProvider = null;
299
308
 
300
309
  function getFactorySettings() {
301
310
  const settingsPath = path.join(os.homedir(), '.factory', 'settings.json');
@@ -311,151 +320,409 @@ function getFactorySettings() {
311
320
  }
312
321
  }
313
322
 
314
- function getCurrentModelConfig() {
315
- const settings = getFactorySettings();
316
- if (!settings) return null;
317
-
318
- const currentModelId = settings.sessionDefaultSettings?.model;
319
- if (!currentModelId) return null;
320
-
321
- const customModels = settings.customModels || [];
322
- const modelConfig = customModels.find(m => m.id === currentModelId);
323
-
324
- if (modelConfig) {
325
- log('Model:', modelConfig.displayName, '| Provider:', modelConfig.provider);
326
- return modelConfig;
323
+ function listCustomModels(settings) {
324
+ return Array.isArray(settings && settings.customModels) ? settings.customModels : [];
325
+ }
326
+
327
+ function isSupportedModel(modelConfig, preferredProvider) {
328
+ return !!(
329
+ modelConfig &&
330
+ SUPPORTED_PROVIDERS.has(modelConfig.provider) &&
331
+ (!preferredProvider || modelConfig.provider === preferredProvider) &&
332
+ modelConfig.id &&
333
+ modelConfig.baseUrl &&
334
+ modelConfig.apiKey &&
335
+ modelConfig.model
336
+ );
337
+ }
338
+
339
+ function buildCandidateModelIds(settings) {
340
+ const candidates = [
341
+ process.env.DROID_SEARCH_MODEL_ID,
342
+ settings && settings.sessionDefaultSettings && settings.sessionDefaultSettings.model,
343
+ settings && settings.missionModelSettings && settings.missionModelSettings.workerModel,
344
+ settings && settings.missionModelSettings && settings.missionModelSettings.validationWorkerModel,
345
+ settings && settings.missionModelSettings && settings.missionModelSettings.orchestratorModel,
346
+ ];
347
+ const unique = [];
348
+ for (const candidate of candidates) {
349
+ if (candidate && !unique.includes(candidate)) unique.push(candidate);
327
350
  }
328
-
329
- if (!currentModelId.startsWith('custom:')) return null;
330
- log('Model not found:', currentModelId);
331
- return null;
351
+ return unique;
332
352
  }
333
353
 
334
- // === Native Provider WebSearch ===
354
+ function summarizeSupportedModels(settings, preferredProvider) {
355
+ return listCustomModels(settings)
356
+ .filter(function(modelConfig) { return isSupportedModel(modelConfig, preferredProvider); })
357
+ .map(function(modelConfig) { return modelConfig.id + ' [' + modelConfig.provider + ']'; })
358
+ .join(', ');
359
+ }
335
360
 
336
- async function searchAnthropicNative(query, numResults, modelConfig) {
337
- const { baseUrl, apiKey, model } = modelConfig;
338
-
339
- try {
340
- const requestBody = {
341
- model: model,
342
- max_tokens: 4096,
343
- stream: false,
344
- system: 'You are a web search assistant. Use the web_search tool to find relevant information and return the results.',
345
- tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }],
346
- tool_choice: { type: 'tool', name: 'web_search' },
347
- messages: [{ role: 'user', content: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' }]
361
+ function getCurrentModelConfig(preferredProvider) {
362
+ const settings = getFactorySettings();
363
+ if (!settings) {
364
+ return {
365
+ error: 'Failed to load ~/.factory/settings.json for native websearch',
366
+ statusCode: 500,
348
367
  };
349
-
350
- let endpoint = baseUrl;
351
- if (!endpoint.endsWith('/v1/messages')) endpoint = endpoint.replace(/\\/$/, '') + '/v1/messages';
352
-
353
- log('Anthropic search:', query, '→', endpoint);
354
-
355
- const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
356
- 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 + "\\'";
357
- const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
358
-
359
- let response;
360
- try { response = JSON.parse(responseStr); } catch { return null; }
361
- if (response.error) { log('API error:', response.error.message); return null; }
362
-
363
- const results = [];
364
- for (const block of (response.content || [])) {
365
- if (block.type === 'web_search_tool_result') {
366
- for (const result of (block.content || [])) {
367
- if (result.type === 'web_search_result') {
368
- results.push({
369
- title: result.title || '',
370
- url: result.url || '',
371
- content: result.snippet || result.page_content || ''
372
- });
373
- }
368
+ }
369
+
370
+ const customModels = listCustomModels(settings);
371
+ const candidateIds = buildCandidateModelIds(settings);
372
+ for (const candidateId of candidateIds) {
373
+ const modelConfig = customModels.find(function(model) {
374
+ return model.id === candidateId && isSupportedModel(model, preferredProvider);
375
+ });
376
+ if (modelConfig) {
377
+ lastObservedProvider = modelConfig.provider;
378
+ log('Resolved model:', modelConfig.id, '| Provider:', modelConfig.provider);
379
+ return { modelConfig: modelConfig, source: candidateId };
380
+ }
381
+ }
382
+
383
+ const fallbackModels = customModels.filter(function(modelConfig) {
384
+ return isSupportedModel(modelConfig, preferredProvider);
385
+ });
386
+ if (fallbackModels.length === 1) {
387
+ lastObservedProvider = fallbackModels[0].provider;
388
+ log('Falling back to only supported model:', fallbackModels[0].id);
389
+ return { modelConfig: fallbackModels[0], source: 'single-supported-model' };
390
+ }
391
+
392
+ const providerLabel = preferredProvider || 'anthropic/openai';
393
+ const supported = summarizeSupportedModels(settings, preferredProvider);
394
+ const message = supported
395
+ ? 'Could not resolve an active ' + providerLabel + ' custom model for native websearch. Available models: ' + supported
396
+ : 'No supported ' + providerLabel + ' custom models found in ~/.factory/settings.json';
397
+
398
+ return { error: message, statusCode: 400 };
399
+ }
400
+
401
+ function normalizeEndpoint(baseUrl, suffix) {
402
+ return baseUrl.endsWith(suffix) ? baseUrl : baseUrl.replace(/\\/$/, '') + suffix;
403
+ }
404
+
405
+ function extractErrorMessage(value) {
406
+ if (!value) return 'Unknown upstream error';
407
+ if (typeof value === 'string') return value;
408
+ if (typeof value.message === 'string') return value.message;
409
+ return JSON.stringify(value);
410
+ }
411
+
412
+ function sleep(ms) {
413
+ return new Promise(function(resolve) { setTimeout(resolve, ms); });
414
+ }
415
+
416
+ function getRetryDelayMs(attempt) {
417
+ return Math.min(250 * Math.pow(2, attempt - 1), 2000);
418
+ }
419
+
420
+ function isRetryableSearchFailure(statusCode, message) {
421
+ if (statusCode === 408 || statusCode === 425 || statusCode === 429) return true;
422
+ if (statusCode >= 500 && statusCode <= 599) return true;
423
+
424
+ const normalized = String(message || '').toLowerCase();
425
+ return normalized.includes('proxy failed:') ||
426
+ normalized.includes('client network socket disconnected before secure tls connection was established') ||
427
+ normalized.includes('fetch failed') ||
428
+ normalized.includes('socket hang up') ||
429
+ normalized.includes('request timed out') ||
430
+ normalized.includes('timed out') ||
431
+ normalized.includes('econnreset') ||
432
+ normalized.includes('ecconnreset') ||
433
+ normalized.includes('econnrefused') ||
434
+ normalized.includes('ehostunreach') ||
435
+ normalized.includes('enotfound') ||
436
+ normalized.includes('eai_again');
437
+ }
438
+
439
+ function pushUniqueResult(results, result) {
440
+ if (!result || !result.url) return;
441
+ if (results.some(function(existing) { return existing.url === result.url; })) return;
442
+ results.push({
443
+ title: result.title || result.url,
444
+ url: result.url,
445
+ content: result.content || '',
446
+ });
447
+ }
448
+
449
+ function parseOpenAITextResults(text, numResults) {
450
+ const results = [];
451
+ const lines = String(text || '').split(/\\r?\\n/);
452
+ let current = null;
453
+
454
+ function flushCurrent() {
455
+ if (!current || !current.url) {
456
+ current = null;
457
+ return;
458
+ }
459
+ pushUniqueResult(results, {
460
+ title: current.title,
461
+ url: current.url,
462
+ content: current.content.join(' ').trim(),
463
+ });
464
+ current = null;
465
+ }
466
+
467
+ for (const rawLine of lines) {
468
+ const line = rawLine.trim();
469
+ if (!line) continue;
470
+
471
+ const titleMatch = line.match(/^\\d+\\.\\s+(?:\\*\\*(.+?)\\*\\*|(.+))$/);
472
+ if (titleMatch) {
473
+ flushCurrent();
474
+ current = {
475
+ title: (titleMatch[1] || titleMatch[2] || '').trim(),
476
+ url: '',
477
+ content: [],
478
+ };
479
+ continue;
480
+ }
481
+
482
+ const urlMatch = line.match(/^(?:[-*]\\s+)?(https?:\\/\\/\\S+)/);
483
+ if (urlMatch) {
484
+ if (!current) {
485
+ current = { title: urlMatch[1], url: '', content: [] };
486
+ }
487
+ current.url = urlMatch[1].replace(/[),.;]+$/, '');
488
+ continue;
489
+ }
490
+
491
+ const bulletTextMatch = line.match(/^[-*]\\s+(.+)/);
492
+ const contentText = (bulletTextMatch ? bulletTextMatch[1] : line).trim();
493
+ if (!current) {
494
+ continue;
495
+ }
496
+ if (contentText) {
497
+ current.content.push(contentText);
498
+ }
499
+ }
500
+
501
+ flushCurrent();
502
+ return results.slice(0, numResults);
503
+ }
504
+
505
+ async function postJson(endpoint, headers, requestBody) {
506
+ const maxAttempts = 5;
507
+ let lastError = null;
508
+
509
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
510
+ const controller = new AbortController();
511
+ const timeoutId = setTimeout(function() { controller.abort(); }, 60000);
512
+
513
+ try {
514
+ const response = await fetch(endpoint, {
515
+ method: 'POST',
516
+ headers: headers,
517
+ body: JSON.stringify(requestBody),
518
+ signal: controller.signal,
519
+ });
520
+ const responseText = await response.text();
521
+ let payload = {};
522
+ if (responseText) {
523
+ try {
524
+ payload = JSON.parse(responseText);
525
+ } catch {
526
+ throw new Error('Invalid JSON response from ' + endpoint);
527
+ }
528
+ }
529
+
530
+ if (!response.ok) {
531
+ const message = extractErrorMessage(payload && payload.error) || ('HTTP ' + response.status);
532
+ if (attempt < maxAttempts && isRetryableSearchFailure(response.status, message)) {
533
+ lastError = new Error(message);
534
+ const delayMs = getRetryDelayMs(attempt);
535
+ log('Retrying request after upstream error:', response.status, message, '| next attempt', attempt + 1, 'of', maxAttempts);
536
+ await sleep(delayMs);
537
+ continue;
538
+ }
539
+ throw new Error(message);
540
+ }
541
+
542
+ if (payload && payload.error) {
543
+ const message = extractErrorMessage(payload.error);
544
+ if (attempt < maxAttempts && isRetryableSearchFailure(undefined, message)) {
545
+ lastError = new Error(message);
546
+ const delayMs = getRetryDelayMs(attempt);
547
+ log('Retrying request after payload error:', message, '| next attempt', attempt + 1, 'of', maxAttempts);
548
+ await sleep(delayMs);
549
+ continue;
374
550
  }
551
+ throw new Error(message);
375
552
  }
553
+
554
+ return payload;
555
+ } catch (e) {
556
+ const message = e && e.name === 'AbortError' ? 'Request timed out' : (e && e.message ? e.message : String(e));
557
+ if (attempt < maxAttempts && isRetryableSearchFailure(undefined, message)) {
558
+ lastError = new Error(message);
559
+ const delayMs = getRetryDelayMs(attempt);
560
+ log('Retrying request after transport error:', message, '| next attempt', attempt + 1, 'of', maxAttempts);
561
+ await sleep(delayMs);
562
+ continue;
563
+ }
564
+ throw new Error(message);
565
+ } finally {
566
+ clearTimeout(timeoutId);
567
+ }
568
+ }
569
+
570
+ throw lastError || new Error('Request failed');
571
+ }
572
+
573
+ async function searchAnthropicNative(query, numResults, modelConfig) {
574
+ const endpoint = normalizeEndpoint(modelConfig.baseUrl, '/v1/messages');
575
+ const requestBody = {
576
+ model: modelConfig.model,
577
+ max_tokens: 4096,
578
+ stream: false,
579
+ system: 'You are a web search assistant. Use the web_search tool to find relevant information and return the results.',
580
+ tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }],
581
+ tool_choice: { type: 'tool', name: 'web_search' },
582
+ messages: [{ role: 'user', content: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' }],
583
+ };
584
+
585
+ log('Anthropic search:', query, '→', endpoint);
586
+ const response = await postJson(endpoint, {
587
+ 'Content-Type': 'application/json',
588
+ 'anthropic-version': '2023-06-01',
589
+ 'x-api-key': modelConfig.apiKey,
590
+ }, requestBody);
591
+
592
+ const results = [];
593
+ for (const block of (response.content || [])) {
594
+ if (block.type !== 'web_search_tool_result') continue;
595
+ for (const result of (block.content || [])) {
596
+ if (result.type !== 'web_search_result') continue;
597
+ results.push({
598
+ title: result.title || '',
599
+ url: result.url || '',
600
+ content: result.snippet || result.page_content || '',
601
+ });
376
602
  }
377
-
378
- log('Results:', results.length);
379
- return results.length > 0 ? results.slice(0, numResults) : null;
380
- } catch (e) {
381
- log('Anthropic error:', e.message);
382
- return null;
383
603
  }
604
+
605
+ log('Anthropic results:', results.length);
606
+ return results.slice(0, numResults);
384
607
  }
385
608
 
386
609
  async function searchOpenAINative(query, numResults, modelConfig) {
387
- const { baseUrl, apiKey, model } = modelConfig;
388
-
389
- try {
390
- // Note: instructions will be added by openai4droid proxy plugin
391
- const requestBody = {
392
- model: model,
393
- stream: false,
394
- tools: [{ type: 'web_search' }],
395
- tool_choice: 'required',
396
- input: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.'
397
- };
398
-
399
- let endpoint = baseUrl;
400
- if (!endpoint.endsWith('/responses')) endpoint = endpoint.replace(/\\/$/, '') + '/responses';
401
-
402
- log('OpenAI search:', query, '→', endpoint);
403
-
404
- const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''");
405
- const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "Authorization: Bearer ' + apiKey + '" -d \\'' + bodyStr + "\\'";
406
- const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 });
407
-
408
- let response;
409
- try { response = JSON.parse(responseStr); } catch { return null; }
410
- if (response.error) { log('API error:', response.error.message); return null; }
411
-
412
- // Extract results from url_citation annotations in message output
413
- const results = [];
414
- for (const item of (response.output || [])) {
415
- if (item.type === 'message' && Array.isArray(item.content)) {
610
+ const endpoint = normalizeEndpoint(modelConfig.baseUrl, '/responses');
611
+ const input = 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.';
612
+ const requestVariants = [
613
+ {
614
+ label: 'web_search',
615
+ body: {
616
+ model: modelConfig.model,
617
+ stream: false,
618
+ tools: [{ type: 'web_search' }],
619
+ tool_choice: 'required',
620
+ input: input,
621
+ },
622
+ },
623
+ {
624
+ label: 'web_search_preview',
625
+ body: {
626
+ model: modelConfig.model,
627
+ stream: false,
628
+ tools: [{ type: 'web_search_preview' }],
629
+ input: input,
630
+ },
631
+ },
632
+ ];
633
+
634
+ let lastError = null;
635
+ for (const variant of requestVariants) {
636
+ try {
637
+ log('OpenAI search:', query, '→', endpoint, '(' + variant.label + ')');
638
+ const response = await postJson(endpoint, {
639
+ 'Content-Type': 'application/json',
640
+ Authorization: 'Bearer ' + modelConfig.apiKey,
641
+ }, variant.body);
642
+
643
+ const results = [];
644
+ const textBlocks = [];
645
+ for (const item of (response.output || [])) {
646
+ if (item.type !== 'message' || !Array.isArray(item.content)) continue;
416
647
  for (const content of item.content) {
417
- if (content.type === 'output_text' && Array.isArray(content.annotations)) {
418
- for (const annotation of content.annotations) {
419
- if (annotation.type === 'url_citation' && annotation.url) {
420
- results.push({
421
- title: annotation.title || '',
422
- url: annotation.url || '',
423
- content: annotation.title || ''
424
- });
425
- }
426
- }
648
+ if (content.type !== 'output_text') continue;
649
+ if (content.text) {
650
+ textBlocks.push(content.text);
427
651
  }
652
+ if (!Array.isArray(content.annotations)) continue;
653
+ for (const annotation of content.annotations) {
654
+ if (annotation.type !== 'url_citation' || !annotation.url) continue;
655
+ pushUniqueResult(results, {
656
+ title: annotation.title || '',
657
+ url: annotation.url || '',
658
+ content: annotation.title || '',
659
+ });
660
+ }
661
+ }
662
+ }
663
+
664
+ if (results.length === 0) {
665
+ for (const textBlock of textBlocks) {
666
+ for (const parsedResult of parseOpenAITextResults(textBlock, numResults)) {
667
+ pushUniqueResult(results, parsedResult);
668
+ }
669
+ if (results.length >= numResults) break;
428
670
  }
429
671
  }
672
+
673
+ log('OpenAI results:', results.length, 'via', variant.label);
674
+ return results.slice(0, numResults);
675
+ } catch (e) {
676
+ lastError = e;
677
+ log('OpenAI variant failed:', variant.label, '-', e.message);
430
678
  }
431
-
432
- log('Results:', results.length);
433
- return results.length > 0 ? results.slice(0, numResults) : null;
434
- } catch (e) {
435
- log('OpenAI error:', e.message);
436
- return null;
437
679
  }
680
+
681
+ throw lastError || new Error('OpenAI web search failed');
438
682
  }
439
683
 
440
684
  async function search(query, numResults) {
441
685
  numResults = numResults || 10;
442
686
  log('Search:', query);
443
-
444
- const modelConfig = getCurrentModelConfig();
445
- if (!modelConfig) {
446
- log('No custom model configured');
447
- return { results: [], source: 'none' };
687
+
688
+ const resolved = getCurrentModelConfig(lastObservedProvider);
689
+ if (!resolved.modelConfig) {
690
+ return {
691
+ results: [],
692
+ source: 'none',
693
+ error: resolved.error,
694
+ statusCode: resolved.statusCode || 400,
695
+ };
696
+ }
697
+
698
+ try {
699
+ let results = [];
700
+ if (resolved.modelConfig.provider === 'anthropic') {
701
+ results = await searchAnthropicNative(query, numResults, resolved.modelConfig);
702
+ } else if (resolved.modelConfig.provider === 'openai') {
703
+ results = await searchOpenAINative(query, numResults, resolved.modelConfig);
704
+ } else {
705
+ return {
706
+ results: [],
707
+ source: 'none',
708
+ error: 'Unsupported provider: ' + resolved.modelConfig.provider,
709
+ statusCode: 400,
710
+ };
711
+ }
712
+
713
+ return {
714
+ results: results,
715
+ source: 'native-' + resolved.modelConfig.provider,
716
+ modelId: resolved.modelConfig.id,
717
+ };
718
+ } catch (e) {
719
+ return {
720
+ results: [],
721
+ source: 'none',
722
+ error: e && e.message ? e.message : String(e),
723
+ statusCode: 502,
724
+ };
448
725
  }
449
-
450
- const provider = modelConfig.provider;
451
- let results = null;
452
-
453
- if (provider === 'anthropic') results = await searchAnthropicNative(query, numResults, modelConfig);
454
- else if (provider === 'openai') results = await searchOpenAINative(query, numResults, modelConfig);
455
- else log('Unsupported provider:', provider);
456
-
457
- if (results && results.length > 0) return { results: results, source: 'native-' + provider };
458
- return { results: [], source: 'none' };
459
726
  }
460
727
 
461
728
  // === HTTP Proxy Server ===
@@ -469,14 +736,20 @@ const server = http.createServer(async (req, res) => {
469
736
  return;
470
737
  }
471
738
 
472
- if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') {
739
+ if (isSearchRequest(url, req.method)) {
473
740
  let body = '';
474
741
  req.on('data', function(c) { body += c; });
475
742
  req.on('end', async function() {
476
743
  try {
477
744
  const parsed = JSON.parse(body);
478
745
  const result = await search(parsed.query, parsed.numResults || 10);
479
- log('Results:', result.results.length, 'from', result.source);
746
+ if (result.error) {
747
+ log('Search failed:', result.error);
748
+ res.writeHead(result.statusCode || 500, { 'Content-Type': 'application/json' });
749
+ res.end(JSON.stringify({ error: result.error, results: [] }));
750
+ return;
751
+ }
752
+ log('Results:', result.results.length, 'from', result.source, 'model', result.modelId || 'unknown');
480
753
  res.writeHead(200, { 'Content-Type': 'application/json' });
481
754
  res.end(JSON.stringify({ results: result.results }));
482
755
  } catch (e) {
@@ -511,6 +784,8 @@ const server = http.createServer(async (req, res) => {
511
784
  }
512
785
 
513
786
  // Simple proxy - no SSE transformation (handled by proxy plugin)
787
+ if (url.pathname.startsWith('/api/llm/a/')) lastObservedProvider = 'anthropic';
788
+ if (url.pathname.startsWith('/api/llm/o/')) lastObservedProvider = 'openai';
514
789
  log('Proxy:', req.method, url.pathname);
515
790
  const proxyUrl = new URL(FACTORY_API + url.pathname + url.search);
516
791
  const proxyModule = proxyUrl.protocol === 'https:' ? https : http;
@@ -571,7 +846,7 @@ should_passthrough() {
571
846
  for arg in "$@"; do
572
847
  if [ "$arg" = "--" ]; then end_opts=1; continue; fi
573
848
  if [ "$end_opts" -eq 0 ] && [[ "$arg" == -* ]]; then continue; fi
574
- case "$arg" in help|version|completion|completions|exec|plugin) return 0 ;; esac
849
+ case "$arg" in help|version|completion|completions|plugin) return 0 ;; esac
575
850
  break
576
851
  done
577
852
  return 1
@@ -625,6 +900,9 @@ rm -f "$PORT_FILE"
625
900
 
626
901
  export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:$ACTUAL_PORT"
627
902
  export FACTORY_API_BASE_URL="http://127.0.0.1:$ACTUAL_PORT"
903
+ export FACTORY_APP_BASE_URL_OVERRIDE="http://127.0.0.1:$ACTUAL_PORT"
904
+ export FACTORY_APP_BASE_URL="http://127.0.0.1:$ACTUAL_PORT"
905
+ export TOOLS_WEBSEARCH_BASE_URL="http://127.0.0.1:$ACTUAL_PORT"
628
906
  "$DROID_BIN" "$@"
629
907
  DROID_EXIT_CODE=$?
630
908
  exit $DROID_EXIT_CODE
@@ -654,7 +932,6 @@ for %%a in (%*) do (
654
932
  if "%%a"=="version" set "PASSTHROUGH=1"
655
933
  if "%%a"=="completion" set "PASSTHROUGH=1"
656
934
  if "%%a"=="completions" set "PASSTHROUGH=1"
657
- if "%%a"=="exec" set "PASSTHROUGH=1"
658
935
  if "%%a"=="plugin" set "PASSTHROUGH=1"
659
936
  )
660
937
  if "%PASSTHROUGH%"=="1" (
@@ -708,6 +985,9 @@ del "%PORT_FILE%" 2>nul
708
985
 
709
986
  set "FACTORY_API_BASE_URL_OVERRIDE=http://127.0.0.1:%ACTUAL_PORT%"
710
987
  set "FACTORY_API_BASE_URL=http://127.0.0.1:%ACTUAL_PORT%"
988
+ set "FACTORY_APP_BASE_URL_OVERRIDE=http://127.0.0.1:%ACTUAL_PORT%"
989
+ set "FACTORY_APP_BASE_URL=http://127.0.0.1:%ACTUAL_PORT%"
990
+ set "TOOLS_WEBSEARCH_BASE_URL=http://127.0.0.1:%ACTUAL_PORT%"
711
991
  "%DROID_BIN%" %*
712
992
  set "DROID_EXIT_CODE=%ERRORLEVEL%"
713
993
 
@@ -1129,6 +1409,40 @@ const SKIP_LOGIN_PATCH_RULES = [{
1129
1409
  alreadyPatchedRegexPattern: SKIP_LOGIN_V068_PLUS_PATCHED_REGEX
1130
1410
  }]
1131
1411
  }];
1412
+ const FACTORYD_SELF_PATH_REGEX = /(function ([A-Za-z$_][A-Za-z0-9$_]*)\(([A-Za-z$_][A-Za-z0-9$_]*)\)\{if\()([A-Za-z$_][A-Za-z0-9$_]*)\.basename\(process\.execPath\)\.includes\("droid"\)(\)return process\.execPath;return \3\?"droid-dev":"droid"\})/g;
1413
+ const FACTORYD_SELF_PATH_PATCHED_REGEX = /function ([A-Za-z$_][A-Za-z0-9$_]*)\(([A-Za-z$_][A-Za-z0-9$_]*)\)\{if\(\(1\|\|([A-Za-z$_][A-Za-z0-9$_]*)\.basename\(process\.execPath\)\.includes\(""\)\)\)return process\.execPath;return \2\?"droid-dev":"droid"\}/g;
1414
+ const FACTORYD_SKIP_LOGIN_AUTH_REGEX = /async function ([A-Za-z$_][A-Za-z0-9$_]*)\(([A-Za-z$_][A-Za-z0-9$_]*)\)\{let ([A-Za-z$_][A-Za-z0-9$_]*)=([A-Za-z$_][A-Za-z0-9$_]*)\(\)\.apiBaseUrl,([A-Za-z$_][A-Za-z0-9$_]*)=await fetch\(`\$\{\3\}\/api\/cli\/whoami`,\{method:"GET",headers:\{Authorization:`Bearer \$\{\2\}`\}\}\),([A-Za-z$_][A-Za-z0-9$_]*)=await \5\.text\(\);if\(!\5\.ok\)throw new ([A-Za-z$_][A-Za-z0-9$_]*)\("API key verification failed",\{statusCode:\5\.status,body:\6\}\);let ([A-Za-z$_][A-Za-z0-9$_]*)=([A-Za-z$_][A-Za-z0-9$_]*)\(\6,([A-Za-z$_][A-Za-z0-9$_]*),"whoami response"\);return\{userId:\8\.userId,email:"",orgId:\8\.orgId\}\}/g;
1415
+ const FACTORYD_SKIP_LOGIN_AUTH_PATCHED_REGEX = /async function [A-Za-z$_][A-Za-z0-9$_]*\(([A-Za-z$_][A-Za-z0-9$_]*)\)\{if\(\/\^fk\/\.test\(\1\)\)return\{userId:"f",orgId:"f"\};let ([A-Za-z$_][A-Za-z0-9$_]*)=await fetch\(`\$\{([A-Za-z$_][A-Za-z0-9$_]*)\(\)\.apiBaseUrl\}\/api\/cli\/whoami`,\{headers:\{Authorization:`Bearer \$\{\1\}`\}\}\);if\(!\2\.ok\)throw new [A-Za-z$_][A-Za-z0-9$_]*\("API key verification failed"\);\2=[A-Za-z$_][A-Za-z0-9$_]*\(await \2\.text\(\),([A-Za-z$_][A-Za-z0-9$_]*),"whoami response"\);return\{userId:\2\.userId,email:"",orgId:\2\.orgId\}\s+\}/g;
1416
+ function createFactorydSelfPathPatch() {
1417
+ return {
1418
+ name: "factorydSelfPath",
1419
+ description: "Force factoryd auto-start to reuse the current executable instead of falling back to plain droid",
1420
+ pattern: Buffer.from(""),
1421
+ replacement: Buffer.from(""),
1422
+ regexPattern: FACTORYD_SELF_PATH_REGEX,
1423
+ regexReplacement: "$1(1||$4.basename(process.execPath).includes(\"\"))$5",
1424
+ alreadyPatchedRegexPattern: FACTORYD_SELF_PATH_PATCHED_REGEX
1425
+ };
1426
+ }
1427
+ function createFactorydSkipLoginAuthPatch() {
1428
+ return {
1429
+ name: "factorydSkipLoginAuth",
1430
+ description: "Allow mission/factoryd auth to reuse fk- API key sessions via the shared /api/cli/whoami helper",
1431
+ pattern: Buffer.from(""),
1432
+ replacement: Buffer.from(""),
1433
+ regexPattern: FACTORYD_SKIP_LOGIN_AUTH_REGEX,
1434
+ regexReplacement: "async function $1($2){if(/^fk/.test($2))return{userId:\"f\",orgId:\"f\"};let $3=await fetch(`${$4().apiBaseUrl}/api/cli/whoami`,{headers:{Authorization:`Bearer ${$2}`}});if(!$3.ok)throw new $7(\"API key verification failed\");$3=$9(await $3.text(),$10,\"whoami response\");return{userId:$3.userId,email:\"\",orgId:$3.orgId} }",
1435
+ alreadyPatchedRegexPattern: FACTORYD_SKIP_LOGIN_AUTH_PATCHED_REGEX
1436
+ };
1437
+ }
1438
+ function needsBinaryPatches(config) {
1439
+ return config.isCustom || config.skipLogin || config.reasoningEffort || !!config.noTelemetry || !!config.apiBase && !config.websearch && !config.websearchProxy;
1440
+ }
1441
+ function createMissionFactorydPatches(config) {
1442
+ const patches = [createFactorydSelfPathPatch()];
1443
+ if (config.skipLogin) patches.push(createFactorydSkipLoginAuthPatch());
1444
+ return patches;
1445
+ }
1132
1446
  function findDefaultDroidPath() {
1133
1447
  const home = homedir();
1134
1448
  if (IS_WINDOWS) {
@@ -1192,7 +1506,15 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1192
1506
  const verbose = options.verbose;
1193
1507
  const droidVersion = getDroidVersion(path);
1194
1508
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
1195
- const needsBinaryPatch = !!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!apiBase && !websearch && !websearchProxy;
1509
+ const needsBinaryPatch = needsBinaryPatches({
1510
+ isCustom: !!isCustom,
1511
+ skipLogin: !!skipLogin,
1512
+ apiBase,
1513
+ websearch: !!websearch,
1514
+ websearchProxy: !!websearchProxy,
1515
+ reasoningEffort: !!reasoningEffort,
1516
+ noTelemetry: !!noTelemetry
1517
+ });
1196
1518
  if (websearch && websearchProxy) {
1197
1519
  console.log(styleText("red", "Error: Cannot use --websearch and --websearch-proxy together"));
1198
1520
  console.log(styleText("gray", "Choose one:"));
@@ -1311,7 +1633,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1311
1633
  console.log(styleText(["cyan", "bold"], " Droid Binary Patcher"));
1312
1634
  console.log(styleText("cyan", "═".repeat(60)));
1313
1635
  console.log();
1314
- const patches = [];
1636
+ const patches = createMissionFactorydPatches({ skipLogin });
1315
1637
  if (isCustom) patches.push({
1316
1638
  name: "isCustom",
1317
1639
  description: "Change isCustom:!0 to isCustom:!1",
@@ -1458,8 +1780,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1458
1780
  console.log(styleText(["blue", "bold"], " DRY RUN COMPLETE"));
1459
1781
  console.log(styleText("blue", "═".repeat(60)));
1460
1782
  console.log();
1461
- console.log(styleText("gray", "To apply the patches, run without --dry-run:"));
1462
- console.log(styleText("cyan", ` npx droid-patch --is-custom ${alias || "<alias-name>"}`));
1783
+ console.log(styleText("gray", "To apply the patches, rerun the same command without --dry-run."));
1463
1784
  process.exit(0);
1464
1785
  }
1465
1786
  if (outputDir && result.success && result.outputPath) {
@@ -1623,7 +1944,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1623
1944
  continue;
1624
1945
  }
1625
1946
  try {
1626
- const patches = [];
1947
+ const patches = needsBinaryPatches(meta.patches) ? createMissionFactorydPatches({ skipLogin: meta.patches.skipLogin }) : [];
1627
1948
  if (meta.patches.isCustom) patches.push({
1628
1949
  name: "isCustom",
1629
1950
  description: "Change isCustom:!0 to isCustom:!1",
@@ -1759,9 +2080,9 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
1759
2080
  }
1760
2081
  }
1761
2082
  let execTargetPath = patches.length > 0 ? outputPath : newBinaryPath;
1762
- if (meta.patches.websearch || !!meta.patches.proxy) {
2083
+ if (meta.patches.websearch || !!meta.patches.websearchProxy || !!meta.patches.proxy) {
1763
2084
  const forwardTarget = meta.patches.apiBase || meta.patches.proxy || "https://api.factory.ai";
1764
- const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, meta.name, forwardTarget, meta.patches.standalone || false);
2085
+ const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, meta.name, forwardTarget, meta.patches.standalone || false, meta.patches.websearchProxy || false);
1765
2086
  execTargetPath = wrapperScript;
1766
2087
  if (verbose) {
1767
2088
  console.log(styleText("gray", ` Regenerated websearch wrapper`));